Skip to content
Ownfold
Esc
navigateopen⌘Jpreview
On this page

Vault server API

Reference for authenticated vault coordination, encrypted-write validation, rate limits, audit hooks, operation results, and security boundaries.

@ownfold/server coordinates authenticated vault metadata. It validates ownership and envelope context, calls an application-owned storage adapter, enforces optimistic concurrency, and returns typed Result values. It has no cryptographic engine and cannot decrypt application data.

Installation

pnpm add @ownfold/server@beta

Add one database adapter and one HTTP or RPC integration. For example:

pnpm add @ownfold/drizzle@beta @ownfold/fetch@beta

createVaultServer(options)

import { createVaultServer } from "@ownfold/server"
import { drizzleVaultAdapter } from "@ownfold/drizzle"
import { Result } from "better-result"
import { db } from "@/db"
import { auth } from "@/lib/auth"

export const vaultServer = createVaultServer({
  adapter: drizzleVaultAdapter(db),

  getUserId: async ({ request }) => {
    const session = await auth.api.getSession({
      headers: request.headers,
    })
    return session?.user.id ?? null
  },

  checkRateLimit: async ({ operation, userId }) => {
    const allowed = await rateLimiter.consume(`ownfold:${operation}:${userId}`)
    return allowed
      ? Result.ok(undefined)
      : Result.err(rateLimitExceeded(operation))
  },

  onAuditEvent: async (event) => {
    await securityAudit.write(event)
  },

  onAuditDeliveryError: async ({ event }) => {
    metrics.increment("ownfold.audit_delivery_failed", {
      operation: event.operation,
    })
  },
})
PropType
adapterVaultAdapter & VaultRotationAdapter
TypeVaultAdapter & VaultRotationAdapter
getUserId(input: { readonly request: Request }) => Promise<string | null>
Type(input: { readonly request: Request }) => Promise<string | null>
now?() => Date
Type() => Date
checkRateLimit?VaultRateLimitHook
TypeVaultRateLimitHook
onAuditEvent?VaultAuditHook
TypeVaultAuditHook
onAuditDeliveryError?VaultAuditDeliveryErrorHook
TypeVaultAuditDeliveryErrorHook

Adapter

adapter must implement both VaultAdapter and VaultRotationAdapter. Official Drizzle, Prisma, PostgreSQL, and SQLite adapters share the same compliance suite. A custom adapter must preserve revision predicates and transaction boundaries; returning a success-shaped value after a partial write corrupts lifecycle state.

Authentication resolver

getUserId({ request }) must authenticate the host application’s session and return its immutable user identifier. It may return null for an anonymous or expired session. The server rejects an empty ID, an ID longer than 256 characters, and exceptions from the resolver.

The request body, route parameter, cookie value without verification, device ID, vault ID, and Recovery Kit metadata are not acceptable ownership sources.

Clock

now defaults to new Date(). It supplies server-owned timestamps for device activity, revocation, pairing approval/cancellation, rotation checkpoints, and audit events. Override it in tests only. Production clocks should be synchronized; pairing validation allows at most 60 seconds of future skew.

Operation pipeline

Every public operation follows the same security order:

Resolve the authenticated user

Authentication runs before storage access. Failure produces AuthenticationFailedError and a rejected audit event.

Apply the host rate limit

checkRateLimit receives the operation, request, and authenticated user ID. A thrown hook fails closed with RATE_LIMIT_CHECK_FAILED.

Validate active vault and input context

Device, pairing, rotation, and encrypted-write operations compare vault IDs, device IDs, public keys, key versions, time windows, and revisions.

Call the adapter

The adapter performs the read or atomic mutation. Driver failures remain StorageAdapterError; stale state remains ConflictError.

Return a remote-safe view

Internal userId fields and ORM rows are not returned. Root keys and plaintext never existed in the server process.

Reading and creating vault metadata

getVault({ request })

Returns RemoteVaultState | null. null means the authenticated account has not created a vault; it is not an authentication failure.

createVault({ request, vault })

Creates metadata supplied by the browser after local root-key and Recovery Kit creation. Input contains vaultId, createdAt, keyVersion, and recoveryKitId, but no Recovery Kit payload or secret. Duplicate or conflicting creation returns ConflictError.

markRecoveryVerified(...)

Marks the current recoveryKitId verified with expectedRevision. The browser calls this only after locally importing, decrypting, and comparing the downloaded kit and enrolling its device. The server does not verify the file or secret.

replaceRecoveryKit(...)

Atomically changes the authoritative kit identifier. The operation checks the authenticated active vault, current kit identifier, vault identifier, and expected revision. The replacement kit remains on the user’s device; only its identifier is stored remotely.

Encrypted application-record writes

validateEncryptedRecordWrite() connects application-owned data routes to Ownfold’s envelope and ownership validation. It does not persist the application record.

const validated = await vaultServer.validateEncryptedRecordWrite({
  request,
  body: await request.json(),
  namespace: "notes",
  recordId: params.noteId,
})

if (validated.status === "error") {
  return ownfoldErrorResponse(validated.error)
}

await notes.insert({
  id: params.noteId,
  ownerId: authenticatedUserId,
  encryptedPayload: validated.value.encryptedPayload,
})

return new Response(null, { status: 204 })

The validator requires the authenticated account to have a vault and authenticates the expected:

  • active vaultId;
  • active key version;
  • route-owned namespace and record ID;
  • authenticated user ID as ownerId;
  • envelope version, algorithms, encoding, and structured shape.

It rejects extra plaintext-shaped fields in the write object. Derive namespace from server code and recordId from the canonical route/database identifier, never from parallel client fields.

Device operations

Method Validation and mutation
listDevices({ request }) Requires an active vault and returns remote-safe device summaries.
registerDevice({ request, device }) Checks vault ID, device ID, recipient public key, envelope key version, then inserts atomically.
touchDevice({ request, device }) Writes a server timestamp and rejects a mismatched vault or stale revision.
revokeDevice({ request, device }) Writes revokedAt using the server clock. Revocation does not re-encrypt records; clients enforce loss of local access.

Device labels, creation time, last-active time, status, and public keys are server-visible metadata. Do not let labels contain arbitrary HTML, decrypted titles, or secrets.

Pairing operations

createPairing() accepts an offer created by the new browser. The server enforces:

  • offer vault equals the authenticated active vault;
  • createdAt is no more than 60 seconds in the future;
  • expiresAt is in the future;
  • lifetime is no more than 15 minutes.

approvePairing() reloads the current pending record, rejects expired or non-pending requests, and matches the returned device envelope to the exact vault, device, public key, and current key version. Adapter approval must atomically mark the pairing approved and register the device.

cancelPairing() uses an expected revision. getPairing() scopes lookup by the authenticated user; knowing a request ID alone grants no access.

Rotation operations

Method Required invariant
getRotation() Reads only the authenticated active vault’s rotation.
beginRotation() fromKeyVersion equals the active version and toKeyVersion is exactly from + 1.
updateRotationProgress() Vault and rotation identity match; checkpoint and count update under expected revision.
completeRotation() Replacement envelopes cover every active device exactly once and match its ID, public key, vault, and next version.

Adapter completion must atomically mark rotation complete, update vault key/recovery metadata, and replace every active device envelope. A timeout after commit is handled by idempotent retry and subsequent state reconciliation.

Rate limiting

PropType
operationVaultServerOperation
TypeVaultServerOperation
requestRequest
TypeRequest
userIdstring
Typestring

Apply tighter limits to expensive or security-sensitive operations such as create vault, verify recovery, replace kit, create/approve pairing, and begin/complete rotation. Reads and device activity may use different buckets. Key by authenticated user plus operation; add IP or session identifiers only as secondary signals.

Return a RateLimitExceededError value. Do not throw for a normal rejection. If the hook itself throws or its dependency is unavailable, Ownfold rejects the operation before adapter access.

Audit events

PropType
occurredAtstring
Typestring
operationVaultServerOperation
TypeVaultServerOperation
userIdstring | null
Typestring | null
decision"allowed"
Type"allowed"

Events contain only timestamp, operation, user ID or null, decision, and rejected error code. They do not include requests, response bodies, envelope bytes, Recovery Kits, public keys, secrets, or plaintext.

Audit delivery is deliberately fail-safe for operation semantics: a hook failure cannot turn an already committed operation into an ambiguous client failure. Use onAuditDeliveryError to alert on delivery loss. That fallback must also avoid throwing and must not include secret-bearing context.

HTTP error mapping

Framework adapters translate transport protocol errors, but custom routes should preserve stable codes. A reasonable mapping is:

Error HTTP status Client action
AuthenticationFailedError 401 Refresh or sign in.
RateLimitExceededError 429 Back off; preserve local pending state.
InvalidInputError or persisted-format parser error 400 Reject the payload; do not retry unchanged.
ConflictError 409 Reload remote state and resume using the returned/current revision.
StorageAdapterError 503 Retry only when the operation is documented as idempotent; inspect commit state first.

Do not serialize stack traces, causes, database messages, SQL, session objects, or submitted payloads.

Deliberately absent APIs

The server package does not and must not provide:

  • root-key import or export;
  • Recovery Kit password or recovery-code endpoints;
  • record decryption or plaintext validation;
  • plaintext search, analytics, logging, or error reporting;
  • administrative master keys or maintainer recovery;
  • silent recovery tied to an authentication password reset.

If an integration requires any of these, it breaks Ownfold’s user-owned E2EE model rather than extending the server package.

Troubleshooting

Every request returns AUTHENTICATION_REQUIRED

Confirm the host session cookie/header reaches the mounted route and getUserId validates it. Do not work around this by reading a body userId.

Writes return CONFLICT errors

The browser is using a stale revision or lifecycle identity. Reload remote state and resume the documented operation; do not remove the revision predicate.

Encrypted record validation rejects owner context

Browser encryption must use the same immutable user ID returned by getUserId. Verify the host auth provider does not expose different IDs on client and server.

Audit delivery failed after a successful mutation

Treat the operation result as authoritative. Alert through onAuditDeliveryError and repair the audit sink separately; do not retry a non-idempotent mutation solely to recreate an event.

Last updated on August 4, 2026

Was this page helpful?