Skip to content
Ownfold
Esc
navigateopen⌘Jpreview
On this page

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.

PropType
getVault(input: { readonly userId: string; }) => Promise<Result$1<VaultRecord | null, StorageAdapterError>>
Type(input: { readonly userId: string; }) => Promise<Result$1<VaultRecord | null, StorageAdapterError>>
createVault(input: CreateVaultInput) => Promise<Result$1<VaultRecord, StorageAdapterError | ConflictError>>
Type(input: CreateVaultInput) => Promise<Result$1<VaultRecord, StorageAdapterError | ConflictError>>
updateRecoveryStatus(input: UpdateRecoveryStatusInput) => Promise<Result$1<VaultRecord, StorageAdapterError | ConflictError>>
Type(input: UpdateRecoveryStatusInput) => Promise<Result$1<VaultRecord, StorageAdapterError | ConflictError>>
replaceRecoveryKit(input: ReplaceRecoveryKitInput) => Promise<Result$1<VaultRecord, StorageAdapterError | ConflictError>>
Type(input: ReplaceRecoveryKitInput) => Promise<Result$1<VaultRecord, StorageAdapterError | ConflictError>>
listDevices(input: { readonly userId: string; readonly vaultId: string; }) => Promise<Result$1<readonly DeviceRecord[], StorageAdapterError>>
Type(input: { readonly userId: string; readonly vaultId: string; }) => Promise<Result$1<readonly DeviceRecord[], StorageAdapterError>>
registerDevice(input: RegisterDeviceInput) => Promise<Result$1<DeviceRecord, StorageAdapterError | ConflictError>>
Type(input: RegisterDeviceInput) => Promise<Result$1<DeviceRecord, StorageAdapterError | ConflictError>>
touchDevice(input: TouchDeviceInput) => Promise<Result$1<DeviceRecord, StorageAdapterError | ConflictError>>
Type(input: TouchDeviceInput) => Promise<Result$1<DeviceRecord, StorageAdapterError | ConflictError>>
revokeDevice(input: RevokeDeviceInput) => Promise<Result$1<DeviceRecord, StorageAdapterError | ConflictError>>
Type(input: RevokeDeviceInput) => Promise<Result$1<DeviceRecord, StorageAdapterError | ConflictError>>
createPairing(input: CreatePairingInput) => Promise<Result$1<PairingRecord, StorageAdapterError | ConflictError>>
Type(input: CreatePairingInput) => Promise<Result$1<PairingRecord, StorageAdapterError | ConflictError>>
getPairing(input: { readonly userId: string; readonly requestId: string; }) => Promise<Result$1<PairingRecord | null, StorageAdapterError>>
Type(input: { readonly userId: string; readonly requestId: string; }) => Promise<Result$1<PairingRecord | null, StorageAdapterError>>
approvePairing(input: ApprovePairingInput) => Promise<Result$1<ApprovedPairing, StorageAdapterError | ConflictError>>
Type(input: ApprovePairingInput) => Promise<Result$1<ApprovedPairing, StorageAdapterError | ConflictError>>
cancelPairing(input: CancelPairingInput) => Promise<Result$1<PairingRecord, StorageAdapterError | ConflictError>>
Type(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.

PropType
getRotation(input: { readonly userId: string; readonly vaultId: string; }) => Promise<Result$1<RotationRecord | null, StorageAdapterError>>
Type(input: { readonly userId: string; readonly vaultId: string; }) => Promise<Result$1<RotationRecord | null, StorageAdapterError>>
beginRotation(input: BeginRotationInput) => Promise<Result$1<RotationRecord, StorageAdapterError | ConflictError>>
Type(input: BeginRotationInput) => Promise<Result$1<RotationRecord, StorageAdapterError | ConflictError>>
updateRotationProgress(input: UpdateRotationProgressInput) => Promise<Result$1<RotationRecord, StorageAdapterError | ConflictError>>
Type(input: UpdateRotationProgressInput) => Promise<Result$1<RotationRecord, StorageAdapterError | ConflictError>>
completeRotation(input: CompleteRotationInput) => Promise<Result$1<CompletedRotation, StorageAdapterError | ConflictError>>
Type(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.

PropType
getVault() => Promise<Result$1<VaultMetadata | null, TransportError>>
Type() => Promise<Result$1<VaultMetadata | null, TransportError>>
createVault(input: CreateRemoteVaultInput) => Promise<Result$1<VaultMetadata, TransportError>>
Type(input: CreateRemoteVaultInput) => Promise<Result$1<VaultMetadata, TransportError>>
markRecoveryVerified(input: MarkRecoveryVerifiedInput) => Promise<Result$1<VaultMetadata, TransportError>>
Type(input: MarkRecoveryVerifiedInput) => Promise<Result$1<VaultMetadata, TransportError>>
replaceRecoveryKit(input: ReplaceRemoteRecoveryKitInput) => Promise<Result$1<VaultMetadata, TransportError>>
Type(input: ReplaceRemoteRecoveryKitInput) => Promise<Result$1<VaultMetadata, TransportError>>
listDevices() => Promise<Result$1<readonly DeviceSummary[], TransportError>>
Type() => Promise<Result$1<readonly DeviceSummary[], TransportError>>
registerDevice(input: RegisterRemoteDeviceInput) => Promise<Result$1<DeviceSummary, TransportError>>
Type(input: RegisterRemoteDeviceInput) => Promise<Result$1<DeviceSummary, TransportError>>
touchDevice(input: TouchRemoteDeviceInput) => Promise<Result$1<DeviceSummary, TransportError>>
Type(input: TouchRemoteDeviceInput) => Promise<Result$1<DeviceSummary, TransportError>>
revokeDevice(input: RevokeRemoteDeviceInput) => Promise<Result$1<DeviceSummary, TransportError>>
Type(input: RevokeRemoteDeviceInput) => Promise<Result$1<DeviceSummary, TransportError>>
createPairing(input: CreatePairingRequestInput) => Promise<Result$1<PairingRequest, TransportError>>
Type(input: CreatePairingRequestInput) => Promise<Result$1<PairingRequest, TransportError>>
getPairing(input: GetPairingRequestInput) => Promise<Result$1<PairingRequest | null, TransportError>>
Type(input: GetPairingRequestInput) => Promise<Result$1<PairingRequest | null, TransportError>>
approvePairing(input: ApprovePairingRequestInput) => Promise<Result$1<PairingRequest, TransportError>>
Type(input: ApprovePairingRequestInput) => Promise<Result$1<PairingRequest, TransportError>>
cancelPairing(input: CancelPairingRequestInput) => Promise<Result$1<PairingRequest, TransportError>>
Type(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 TransportError values;
  • 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:

PropType
getRotation() => Promise<Result$1<RotationState | null, TransportError>>
Type() => Promise<Result$1<RotationState | null, TransportError>>
beginRotation(input: BeginRemoteRotationInput) => Promise<Result$1<RotationState, TransportError>>
Type(input: BeginRemoteRotationInput) => Promise<Result$1<RotationState, TransportError>>
updateRotationProgress(input: UpdateRemoteRotationProgressInput) => Promise<Result$1<RotationState, TransportError>>
Type(input: UpdateRemoteRotationProgressInput) => Promise<Result$1<RotationState, TransportError>>
completeRotation(input: CompleteRemoteRotationInput) => Promise<Result$1<CompletedRemoteRotation, TransportError>>
Type(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.

Last updated on August 4, 2026

Was this page helpful?