tRPC integration
Mount Ownfold's v11 coordination router and adapt an inferred tRPC client without coupling application ciphertext to tRPC.
The tRPC adapter does not select a database or authentication provider. Choose both in the framework recipe builder; no frontend package is required.
@ownfold/trpc supplies a typed vault-lifecycle router and a browser transport. It targets tRPC v11.
Encrypted application records remain in your application’s own routers and tables.
Runnable example: examples/trpc-sqlite.
Install
pnpm add @ownfold/server@beta @ownfold/trpc@beta @trpc/server
This is sufficient for a backend-only tRPC service. Add @trpc/client and @ownfold/browser only
when a browser client will consume the router.
Create the router
import { createVaultTRPCRouter } from "@ownfold/trpc"
import { vaultServer } from "../vault-server"
export const vaultRouter = createVaultTRPCRouter({ server: vaultServer })
The router exposes vault, recovery, device, pairing, and rotation procedures. Each mutation input is parsed with Ownfold’s Standard Schema-compatible validators before server logic runs.
Add the request to context
Ownfold needs the original Web Request, because VaultServer resolves the authenticated user from
its cookies or authorization headers.
import type { VaultTRPCContext } from "@ownfold/trpc"
export const createContext = ({ request }: { request: Request }): VaultTRPCContext => ({ request })
If your tRPC adapter provides a Node request, convert it once at the framework boundary while preserving headers, method, URL, and body semantics. Do not manufacture a user ID from client input.
Merge into the application router
export const appRouter = router({
vault: vaultRouter,
notes: notesRouter,
})
export type AppRouter = typeof appRouter
The Ownfold router creates its own v11 router instance with the required context shape. Mount it as
a nested router under a stable key such as vault.
Browser transport
This section is optional and does not affect server setup.
import { createVaultClient } from "@ownfold/browser"
import { trpcVaultTransport } from "@ownfold/trpc/client"
import { trpcClient } from "./trpc-client"
export const vault = createVaultClient({
transport: trpcVaultTransport(trpcClient.vault),
})
The structural client interface works with a normally inferred tRPC client. No generated code or
manual cast should be required when the router is mounted under vault.
Procedure surface
| Procedures | Purpose |
|---|---|
getVault, createVault |
Vault metadata lifecycle. |
markRecoveryVerified, replaceRecoveryKit |
Recovery status and replacement coordination. |
listDevices, registerDevice, touchDevice, revokeDevice |
Device lifecycle. |
createPairing, getPairing, approvePairing, cancelPairing |
Encrypted pairing transport. |
getRotation, beginRotation, updateRotationProgress, completeRotation |
Resumable root-key rotation metadata. |
No procedure accepts userId. Authentication is resolved from ctx.request on every operation.
Error translation
The server router maps authentication failures to UNAUTHORIZED, conflicts to CONFLICT, explicit
limits to TOO_MANY_REQUESTS, limiter failures to SERVICE_UNAVAILABLE, and storage failures to a
scrubbed INTERNAL_SERVER_ERROR. Other validated client failures become BAD_REQUEST.
The browser transport catches tRPC client failures and returns TransportError with
VAULT_TRPC_FAILED. The current transport deliberately does not expose a tRPC cause object, which
could contain framework or server details. Refresh remote state before retrying revision-sensitive
operations.
Application ciphertext stays separate
const encrypted = await vault.encryptJson({
namespace: "notes",
recordId: noteId,
value: note,
})
if (encrypted.status === "error") return handleError(encrypted.error)
await trpcClient.notes.create.mutate({
id: noteId,
encryptedPayload: encrypted.value,
})
The notes procedure authorizes ownership and stores only the envelope. It must not import a root key or decrypt the payload. This separation lets applications use REST, GraphQL, or another protocol for records while still using tRPC for Ownfold coordination.
Protected procedures
You may place the mounted router behind application-wide authenticated middleware, but keep
Ownfold’s own getUserId check enabled. The server resolver is the authoritative identity boundary
and prevents a routing mistake from becoming cross-user vault access.
Testing
- Create callers with authenticated and unauthenticated
Requestobjects. - Verify invalid inputs fail before adapter calls.
- Verify client-supplied ownership fields are rejected.
- Assert transport mocks never receive plaintext, root keys, or recovery secrets.
- Exercise conflict and rate-limit translation.
- Run a full browser onboarding/recovery flow through the actual tRPC HTTP adapter.
Troubleshooting
Context type is incompatible
Ensure the context object has request: Request. If your application context has additional fields,
return them alongside request; structural typing preserves the richer type.
The browser client is not assignable
Confirm the Ownfold router is mounted under the property passed to trpcVaultTransport and server
and client both infer from the same exported AppRouter type. Avoid duplicating procedure types.
All calls return VAULT_TRPC_FAILED
Inspect the tRPC network request and server logs for session, context, and routing failures. The browser error is intentionally scrubbed. Confirm the original request reaches context creation.
Application data appears in the vault router
Move record CRUD back to the host router. Ownfold’s router should transport lifecycle metadata and encrypted key envelopes only.
Production checklist
- Use tRPC v11 on both client and server.
- Preserve the original request in context.
- Resolve ownership only through
VaultServer.getUserId. - Mount the router under a stable namespace.
- Keep application encrypted-record procedures separate.
- Disable request-body logging for ciphertext routes.
- Test unauthenticated, stale-revision, and malformed-input paths.