Adapter and transport contracts
Implement custom databases and browser transports while preserving Ownfold's identity, concurrency, and secret-handling boundaries.
Ownfold separates persistence from networking. A storage adapter runs behind the authenticated server; a transport runs between the browser client and that server. Neither contract carries record plaintext, recovery secrets, or raw keys.
Browser client → VaultTransport → authenticated VaultServer → VaultAdapter → database
Result model
Fallible contract methods return better-result values. Expected conflicts and operational failures
are values, not thrown exceptions. Branch on result.status and then on result.error.code.
const result = await adapter.getVault({ userId })
if (result.status === "error") {
return result
}
return result.value
An implementation may catch a driver exception at its boundary, but must convert it to a typed, secret-safe Ownfold error. Never return raw SQL, connection strings, request bodies, or encrypted payloads in an error message.
Storage adapter
VaultAdapter owns vault metadata, recovery status, devices, and pairing.
getVault(input: { readonly userId: string; }) => Promise<Result$1<VaultRecord | null, StorageAdapterError>>
(input: { readonly userId: string; }) => Promise<Result$1<VaultRecord | null, StorageAdapterError>>createVault(input: CreateVaultInput) => Promise<Result$1<VaultRecord, StorageAdapterError | ConflictError>>
(input: CreateVaultInput) => Promise<Result$1<VaultRecord, StorageAdapterError | ConflictError>>updateRecoveryStatus(input: UpdateRecoveryStatusInput) => Promise<Result$1<VaultRecord, StorageAdapterError | ConflictError>>
(input: UpdateRecoveryStatusInput) => Promise<Result$1<VaultRecord, StorageAdapterError | ConflictError>>replaceRecoveryKit(input: ReplaceRecoveryKitInput) => Promise<Result$1<VaultRecord, StorageAdapterError | ConflictError>>
(input: ReplaceRecoveryKitInput) => Promise<Result$1<VaultRecord, StorageAdapterError | ConflictError>>listDevices(input: { readonly userId: string; readonly vaultId: string; }) => Promise<Result$1<readonly DeviceRecord[], StorageAdapterError>>
(input: { readonly userId: string; readonly vaultId: string; }) => Promise<Result$1<readonly DeviceRecord[], StorageAdapterError>>registerDevice(input: RegisterDeviceInput) => Promise<Result$1<DeviceRecord, StorageAdapterError | ConflictError>>
(input: RegisterDeviceInput) => Promise<Result$1<DeviceRecord, StorageAdapterError | ConflictError>>touchDevice(input: TouchDeviceInput) => Promise<Result$1<DeviceRecord, StorageAdapterError | ConflictError>>
(input: TouchDeviceInput) => Promise<Result$1<DeviceRecord, StorageAdapterError | ConflictError>>revokeDevice(input: RevokeDeviceInput) => Promise<Result$1<DeviceRecord, StorageAdapterError | ConflictError>>
(input: RevokeDeviceInput) => Promise<Result$1<DeviceRecord, StorageAdapterError | ConflictError>>createPairing(input: CreatePairingInput) => Promise<Result$1<PairingRecord, StorageAdapterError | ConflictError>>
(input: CreatePairingInput) => Promise<Result$1<PairingRecord, StorageAdapterError | ConflictError>>getPairing(input: { readonly userId: string; readonly requestId: string; }) => Promise<Result$1<PairingRecord | null, StorageAdapterError>>
(input: { readonly userId: string; readonly requestId: string; }) => Promise<Result$1<PairingRecord | null, StorageAdapterError>>approvePairing(input: ApprovePairingInput) => Promise<Result$1<ApprovedPairing, StorageAdapterError | ConflictError>>
(input: ApprovePairingInput) => Promise<Result$1<ApprovedPairing, StorageAdapterError | ConflictError>>cancelPairing(input: CancelPairingInput) => Promise<Result$1<PairingRecord, StorageAdapterError | ConflictError>>
(input: CancelPairingInput) => Promise<Result$1<PairingRecord, StorageAdapterError | ConflictError>>The server supplies userId; it comes from the authenticated session and is never accepted from a
browser request. Adapter implementations must include both userId and the relevant vault or record
identifier in every ownership-sensitive query.
Required semantics
| Operation | Required behavior |
|---|---|
getVault |
Return null only when no vault exists for that user; validate stored rows. |
createVault |
Be idempotent for the identical vault identity; reject a different existing vault. |
| Recovery updates | Match vault, Recovery Kit ID, and expected revision. |
registerDevice |
Be idempotent for the same device identity; reject conflicting public keys or envelopes. |
touchDevice |
Update activity without weakening security revision checks. |
revokeDevice |
Match ownership and expected revision; never reactivate a revoked device implicitly. |
| Pairing approval | Approve the request and register its device in one transaction. |
| Pairing cancellation | Match the current request revision and preserve terminal states. |
Creation retries can occur after a response is lost. Idempotency means returning the existing equivalent record, not silently accepting a different record under the same identity.
Rotation adapter
VaultRotationAdapter is separate so an early custom adapter can explicitly omit key rotation
instead of implementing unsafe partial behavior.
getRotation(input: { readonly userId: string; readonly vaultId: string; }) => Promise<Result$1<RotationRecord | null, StorageAdapterError>>
(input: { readonly userId: string; readonly vaultId: string; }) => Promise<Result$1<RotationRecord | null, StorageAdapterError>>beginRotation(input: BeginRotationInput) => Promise<Result$1<RotationRecord, StorageAdapterError | ConflictError>>
(input: BeginRotationInput) => Promise<Result$1<RotationRecord, StorageAdapterError | ConflictError>>updateRotationProgress(input: UpdateRotationProgressInput) => Promise<Result$1<RotationRecord, StorageAdapterError | ConflictError>>
(input: UpdateRotationProgressInput) => Promise<Result$1<RotationRecord, StorageAdapterError | ConflictError>>completeRotation(input: CompleteRotationInput) => Promise<Result$1<CompletedRotation, StorageAdapterError | ConflictError>>
(input: CompleteRotationInput) => Promise<Result$1<CompletedRotation, StorageAdapterError | ConflictError>>completeRotation is the critical atomic boundary. It must validate the rotation revision, vault
revision, and every active device revision before changing any of them. It then commits the new key
version, Recovery Kit ID, replacement device envelopes, and completed rotation together. One stale
input must roll back the entire cutover.
Progress updates are resumable metadata. processedRecords must not move backwards, and a stale
checkpoint must not overwrite newer work.
Test a storage adapter
import {
checkVaultAdapterCompliance,
checkVaultRotationAdapterCompliance,
} from "@ownfold/testing"
const lifecycle = await checkVaultAdapterCompliance(adapter)
if (lifecycle.status === "error") throw lifecycle.error
const rotation = await checkVaultRotationAdapterCompliance(adapter)
if (rotation.status === "error") throw rotation.error
Run the suites against a disposable database. They create fixed records and deliberately issue stale writes. In addition, test driver failures, malformed stored JSON, duplicate delivery, and concurrent transactions using the real production database engine.
Browser transport
VaultTransport mirrors authenticated lifecycle operations without accepting userId.
getVault() => Promise<Result$1<VaultMetadata | null, TransportError>>
() => Promise<Result$1<VaultMetadata | null, TransportError>>createVault(input: CreateRemoteVaultInput) => Promise<Result$1<VaultMetadata, TransportError>>
(input: CreateRemoteVaultInput) => Promise<Result$1<VaultMetadata, TransportError>>markRecoveryVerified(input: MarkRecoveryVerifiedInput) => Promise<Result$1<VaultMetadata, TransportError>>
(input: MarkRecoveryVerifiedInput) => Promise<Result$1<VaultMetadata, TransportError>>replaceRecoveryKit(input: ReplaceRemoteRecoveryKitInput) => Promise<Result$1<VaultMetadata, TransportError>>
(input: ReplaceRemoteRecoveryKitInput) => Promise<Result$1<VaultMetadata, TransportError>>listDevices() => Promise<Result$1<readonly DeviceSummary[], TransportError>>
() => Promise<Result$1<readonly DeviceSummary[], TransportError>>registerDevice(input: RegisterRemoteDeviceInput) => Promise<Result$1<DeviceSummary, TransportError>>
(input: RegisterRemoteDeviceInput) => Promise<Result$1<DeviceSummary, TransportError>>touchDevice(input: TouchRemoteDeviceInput) => Promise<Result$1<DeviceSummary, TransportError>>
(input: TouchRemoteDeviceInput) => Promise<Result$1<DeviceSummary, TransportError>>revokeDevice(input: RevokeRemoteDeviceInput) => Promise<Result$1<DeviceSummary, TransportError>>
(input: RevokeRemoteDeviceInput) => Promise<Result$1<DeviceSummary, TransportError>>createPairing(input: CreatePairingRequestInput) => Promise<Result$1<PairingRequest, TransportError>>
(input: CreatePairingRequestInput) => Promise<Result$1<PairingRequest, TransportError>>getPairing(input: GetPairingRequestInput) => Promise<Result$1<PairingRequest | null, TransportError>>
(input: GetPairingRequestInput) => Promise<Result$1<PairingRequest | null, TransportError>>approvePairing(input: ApprovePairingRequestInput) => Promise<Result$1<PairingRequest, TransportError>>
(input: ApprovePairingRequestInput) => Promise<Result$1<PairingRequest, TransportError>>cancelPairing(input: CancelPairingRequestInput) => Promise<Result$1<PairingRequest, TransportError>>
(input: CancelPairingRequestInput) => Promise<Result$1<PairingRequest, TransportError>>The transport may use fetch, tRPC, server functions, or an application-specific RPC layer. It must:
- send credentials according to the host application’s session policy;
- validate untrusted response bodies before exposing them to lifecycle code;
- preserve stable Ownfold error codes;
- reject non-success protocol responses as
TransportErrorvalues; - never retry non-idempotent work without the operation’s stable identifier; and
- make requests only to developer-configured application infrastructure.
VaultRotationTransport adds resumable rotation operations:
getRotation() => Promise<Result$1<RotationState | null, TransportError>>
() => Promise<Result$1<RotationState | null, TransportError>>beginRotation(input: BeginRemoteRotationInput) => Promise<Result$1<RotationState, TransportError>>
(input: BeginRemoteRotationInput) => Promise<Result$1<RotationState, TransportError>>updateRotationProgress(input: UpdateRemoteRotationProgressInput) => Promise<Result$1<RotationState, TransportError>>
(input: UpdateRemoteRotationProgressInput) => Promise<Result$1<RotationState, TransportError>>completeRotation(input: CompleteRemoteRotationInput) => Promise<Result$1<CompletedRemoteRotation, TransportError>>
(input: CompleteRemoteRotationInput) => Promise<Result$1<CompletedRemoteRotation, TransportError>>Rotation transports carry checkpoints and encrypted device envelopes. Record-key unwrapping, rewrapping, and Recovery Kit generation stay in the browser.
What may cross the network
| Allowed | Forbidden |
|---|---|
| Vault and device identifiers | Root vault key |
| Key versions and revisions | Record data key |
| Device public keys | Device private key |
| Encrypted device envelopes | Recovery password or recovery code |
| Pairing public offers | Decrypted record content |
| Rotation checkpoints/counts | Unencrypted replacement keys |
Encrypted application records use the host application’s own API. They are not part of
VaultTransport, which exists only for vault lifecycle coordination.
Custom transport example
import type { VaultTransport } from "@ownfold/core"
export const transport: VaultTransport = {
async getVault() {
return callVaultEndpoint("GET", "/vault")
},
async createVault(input) {
return callVaultEndpoint("POST", "/vault", input)
},
// Implement every remaining operation with the same validated protocol boundary.
}
Do not return response.json() directly as a typed value. HTTP data is untrusted even when your
server produced it; parse the success and error shapes before constructing a result.
Compliance checklist
- Ownership always comes from the server session.
- ORM clients and transaction objects never enter shared contracts.
- All persisted JSON is parsed on read.
- Same-identity creation is idempotent; conflicting identity is rejected.
- Every mutable security record uses optimistic concurrency.
- Pairing approval is atomic with device registration.
- Rotation cutover is atomic across all affected rows.
- Expected failures are typed result errors.
- Transport mocks prove plaintext and recovery secrets never appear in calls.
- No implementation has a default Ownfold-owned URL, telemetry endpoint, or API key.