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 → authenticated vault/passkey server → adapter contracts → 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>>updateDeviceLabel(input: UpdateDeviceLabelInput) => Promise<Result$1<DeviceRecord, StorageAdapterError | ConflictError>>
(input: UpdateDeviceLabelInput) => 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, every active device revision, and every active passkey method before changing any of them.
It then commits the new key version, Recovery Kit ID, replacement device and passkey 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.
Passkey vault access adapter
PasskeyVaultAccessAdapter persists one-time WebAuthn challenges and server-safe access-method
metadata. It never receives the WebAuthn PRF output, a device private key, or an unwrapped root key.
createPasskeyChallenge(input: CreatePasskeyChallengeInput) => Promise<Result$1<PasskeyChallengeRecord, StorageAdapterError>>
Stores one active challenge per user and operation. The write must atomically remove expired challenges and supersede the previous challenge for that user/operation before inserting.
(input: CreatePasskeyChallengeInput) => Promise<Result$1<PasskeyChallengeRecord, StorageAdapterError>>consumePasskeyChallenge(input: ConsumePasskeyChallengeInput) => Promise<Result$1<PasskeyChallengeRecord | null, StorageAdapterError>>
(input: ConsumePasskeyChallengeInput) => Promise<Result$1<PasskeyChallengeRecord | null, StorageAdapterError>>createPasskeyVaultAccess(input: CreatePasskeyVaultAccessInput) => Promise<Result$1<PasskeyVaultAccessRecord, StorageAdapterError | ConflictError>>
(input: CreatePasskeyVaultAccessInput) => Promise<Result$1<PasskeyVaultAccessRecord, StorageAdapterError | ConflictError>>getPasskeyVaultAccess(input: { readonly userId: string; readonly vaultId: string; readonly credentialId: string; }) => Promise<Result$1<PasskeyVaultAccessRecord | null, StorageAdapterError>>
(input: { readonly userId: string; readonly vaultId: string; readonly credentialId: string; }) => Promise<Result$1<PasskeyVaultAccessRecord | null, StorageAdapterError>>listPasskeyVaultAccess(input: { readonly userId: string; readonly vaultId: string; }) => Promise<Result$1<readonly PasskeyVaultAccessRecord[], StorageAdapterError>>
(input: { readonly userId: string; readonly vaultId: string; }) => Promise<Result$1<readonly PasskeyVaultAccessRecord[], StorageAdapterError>>touchPasskeyVaultAccess(input: TouchPasskeyVaultAccessInput) => Promise<Result$1<PasskeyVaultAccessRecord, StorageAdapterError | ConflictError>>
(input: TouchPasskeyVaultAccessInput) => Promise<Result$1<PasskeyVaultAccessRecord, StorageAdapterError | ConflictError>>revokePasskeyVaultAccess(input: RevokePasskeyVaultAccessInput) => Promise<Result$1<PasskeyVaultAccessRecord, StorageAdapterError | ConflictError>>
(input: RevokePasskeyVaultAccessInput) => Promise<Result$1<PasskeyVaultAccessRecord, StorageAdapterError | ConflictError>>Challenge consumption must atomically delete and return a record only when challenge ID, user, and
operation all match. Access methods are scoped by authenticated user and vault, and mutable methods
use their expected revision. Revocation is terminal. Rotation must replace every active method’s
portable root-key envelope atomically through VaultRotationAdapter.completeRotation.
Test a storage adapter
import {
checkVaultAdapterCompliance,
checkVaultRotationAdapterCompliance,
checkPasskeyVaultAccessAdapterCompliance,
} 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
const passkeys = await checkPasskeyVaultAccessAdapterCompliance(adapter)
if (passkeys.status === "error") throw passkeys.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>>getDeviceInventory() => Promise<Result$1<RemoteDeviceInventory, TransportError>>
() => Promise<Result$1<RemoteDeviceInventory, 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>>updateDeviceLabel(input: UpdateRemoteDeviceLabelInput) => Promise<Result$1<DeviceSummary, TransportError>>
(input: UpdateRemoteDeviceLabelInput) => 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 |
| Sanitized WebAuthn responses | WebAuthn PRF output or portable-device private key |
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.
- Passkey challenges are single-use and scoped to user and operation.
- Passkey access methods use optimistic concurrency and terminal revocation.
- Rotation cutover is atomic across vault, devices, passkey methods, and rotation state.
- 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.