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,
})
},
})
adapterVaultAdapter & VaultRotationAdapter
VaultAdapter & VaultRotationAdaptergetUserId(input: { readonly request: Request }) => Promise<string | null>
(input: { readonly request: Request }) => Promise<string | null>now?() => Date
() => DatecheckRateLimit?VaultRateLimitHook
VaultRateLimitHookonAuditEvent?VaultAuditHook
VaultAuditHookonAuditDeliveryError?VaultAuditDeliveryErrorHook
VaultAuditDeliveryErrorHookAdapter
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;
createdAtis no more than 60 seconds in the future;expiresAtis 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
operationVaultServerOperation
VaultServerOperationrequestRequest
RequestuserIdstring
stringApply 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
occurredAtstring
stringoperationVaultServerOperation
VaultServerOperationuserIdstring | null
string | nulldecision"allowed"
"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.