Fastify
Integrate Fastify with the Node bridge, Better Auth, SQLite or PostgreSQL, origin checks, body limits, hooks, ciphertext routes, and shutdown.
@ownfold/fastify bridges Fastify’s raw Node request and response to Ownfold’s standard fetch
handler. It calls reply.hijack() because Ownfold writes the response through
http.ServerResponse.
The runnable reference is examples/fastify-better-auth-sqlite.
That example is one combination. Use the database and identity choosers to replace SQLite or Better Auth, including a private no-auth single-user process, without changing the Fastify handler.
Install packages
pnpm add @ownfold/better-auth@beta @ownfold/fastify@beta @ownfold/server@beta @ownfold/sqlite@beta better-auth fastify
Use @ownfold/postgres, @ownfold/drizzle, or @ownfold/prisma instead of SQLite when several
application instances share coordination state.
Configure the server and adapter
import { betterAuthUserResolver } from "@ownfold/better-auth"
import { SqliteVaultAdapter, applyOwnfoldSqliteSchema } from "@ownfold/sqlite"
import { createVaultServer } from "@ownfold/server"
import { DatabaseSync } from "node:sqlite"
import { auth } from "./auth"
const database = new DatabaseSync("./data/ownfold.sqlite")
database.exec("PRAGMA journal_mode = WAL")
database.exec("PRAGMA foreign_keys = ON")
applyOwnfoldSqliteSchema(database)
export const vaultServer = createVaultServer({
adapter: new SqliteVaultAdapter(database),
getUserId: betterAuthUserResolver(auth),
})
Run schema changes as a deployment step in production instead of relying on request-time startup migration. The helper is convenient for local examples and idempotent schema bootstrap.
Mount the Fastify handler
import { createFastifyVaultHandler } from "@ownfold/fastify"
import Fastify from "fastify"
import { vaultServer } from "./vault-server"
const app = Fastify({
logger: {
redact: ["req.headers.authorization", "req.headers.cookie", "req.body"],
},
bodyLimit: 64 * 1024,
trustProxy: ["127.0.0.1"],
})
app.all(
"/api/ownfold/*",
createFastifyVaultHandler({
server: vaultServer,
origin: "https://app.example.com",
maxBodyBytes: 65_536,
}),
)
originNodeVaultOrigin
NodeVaultOriginmaxBodyBytes?number
numberorigin is the canonical public origin used to reconstruct the Web Request. Pass a fixed HTTPS
string whenever possible. A function may select from a validated allowlist for multi-tenant hosts:
origin: ({ request }) => {
const host = request.headers.host
if (host === "app.example.com") return "https://app.example.com"
if (host === "admin.example.com") return "https://admin.example.com"
throw new Error("Unrecognized public host")
}
Do not echo arbitrary Host, X-Forwarded-Host, or X-Forwarded-Proto values. Fastify’s
trustProxy affects its normalized request metadata, but Ownfold’s function receives the raw Node
request. Validate the proxy chain and allowlist explicitly.
What reply.hijack() changes
The adapter hijacks the reply before forwarding request.raw and reply.raw. Fastify will not
serialize another response afterward. Hooks that need to authenticate, assign request IDs, enforce
limits, or set process context must run before the handler. Do not attach logic that expects
Fastify’s normal response serialization after a hijacked reply.
Ownfold’s bridge sets its response status, headers, and body on the raw response. Do not call
reply.send() in the same handler.
Better Auth
Mount Better Auth at its own /api/auth/* path and pass the real server instance to
betterAuthUserResolver(auth). Recovery secrets and application record bodies never enter Better
Auth. Password reset restores access to the account session, not to encrypted vault data.
If auth middleware decorates request, remember that Ownfold’s resolver validates the reconstructed
Web request through the configured auth API. Keep cookie/header forwarding intact.
Body limits
There are two relevant limits:
- Fastify’s
bodyLimitprotects routes parsed by Fastify. maxBodyBytesprotects the raw Node-to-Web bridge before Ownfold parses protocol JSON.
Ownfold’s fetch layer also rejects protocol bodies above 65,536 bytes. Keep the outer limit at or above that value. Application encrypted-record routes may need a separate, deliberate ciphertext size limit; do not make coordination endpoints unbounded to accommodate files.
SQLite versus PostgreSQL
Use for one Node process and modest coordination traffic. Keep one dedicated connection, enable foreign keys, use WAL where appropriate, back up the database consistently, and avoid nesting adapter calls inside a host transaction on the same synchronous connection.
Use for horizontal scaling, managed failover, or multiple workers. Set a bounded pool, TLS verification, statement/query timeouts, migration ordering, and graceful pool shutdown.
The coordination database never contains application plaintext. Your separate records table stores the versioned encrypted envelope and authorization metadata.
Ciphertext-only record route
import type { FastifyRequest } from "fastify"
const publicOrigin = "https://app.example.com"
const toAuthenticationRequest = (request: FastifyRequest): Request => {
const headers = new Headers()
for (const [name, value] of Object.entries(request.headers)) {
if (value === undefined) continue
headers.set(name, Array.isArray(value) ? value.join(", ") : String(value))
}
return new Request(new URL(request.url, publicOrigin), {
headers,
method: request.method,
})
}
app.put<{ Params: { id: string } }>("/api/notes/:id", async (request, reply) => {
const webRequest = toAuthenticationRequest(request)
const validated = await vaultServer.validateEncryptedRecordWrite({
request: webRequest,
body: request.body,
namespace: "notes",
recordId: request.params.id,
})
if (validated.status === "error") {
return reply.code(mapOwnfoldStatus(validated.error)).send({
code: validated.error.code,
message: validated.error.message,
})
}
await notes.upsert({
userId: await requireUserId(webRequest),
id: request.params.id,
encryptedPayload: validated.value.encryptedPayload,
})
return reply.code(204).send()
})
If the application route already uses a verified Fastify session decoration, it may obtain the user
ID there; envelope validation still needs an authenticated Web Request whose resolver produces
the same stable ID.
Hooks and logging
Useful pre-handler controls include request IDs, authentication setup, rate limits, and strict
content-type checks. Do not log request.body on Ownfold or encrypted-record routes. Ciphertext is
not plaintext, but it is still sensitive user data and can expose sizes, access patterns, and
persisted payloads.
Global error handlers should distinguish unexpected framework failures from expected Ownfold protocol responses. Never serialize driver errors, SQL, session cookies, or stack traces to the browser.
Graceful shutdown
Stop accepting new requests, wait for in-flight handlers, then close the database connection/pool. Do not terminate a process halfway through a response. Ownfold’s revisioned operations recover from ambiguous network outcomes, but orderly shutdown reduces retries and operational uncertainty.
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.once(signal, async () => {
await app.close()
database.close()
})
}
Production checklist
- Use a fixed or validated public origin.
- Configure
trustProxywith exact proxy hops or addresses, never broad trust by habit. - Redact cookies, authorization headers, bodies, and query parameters where secrets may appear.
- Apply Ownfold and application migrations before traffic.
- Use HTTPS and secure cookies.
- Set separate rate limits for recovery, pairing, and rotation operations.
- Test shutdown during onboarding and rotation, then verify retry/reconciliation.
- Confirm backups restore coordination tables consistently with application ciphertext metadata.
Troubleshooting
Fastify reports that a reply was already sent
Do not call reply.send() around createFastifyVaultHandler. The adapter hijacks and completes
the raw response itself.
Generated URLs have the wrong scheme or host
Fix the explicit origin or validated origin function. Do not solve it by trusting all forwarded
headers.
Requests fail before reaching Ownfold
Compare Fastify’s bodyLimit, content-type parser behavior, hooks, and wildcard route order with
the adapter’s maxBodyBytes.
SQLite locks under multiple instances
SQLite is not the appropriate shared coordination backend for that topology. Move to PostgreSQL, Drizzle, or Prisma on a shared transactional database.
Complete example
examples/fastify-better-auth-sqlite includes Better Auth, Ownfold schema bootstrap, encrypted-note
routes, origin validation, process controls, and a build-tested client application.