@keyring-dev/next
v0.1.1
Published
Next.js App Router adapter for Keyring. Wraps a route handler on the Node runtime; verification is CPU-only and makes no network call.
Readme
@keyring-dev/next
Next.js App Router adapter for Keyring, on the Node runtime. MIT.
# .env.local
KEYRING_SECRET_KEY=krsk_live_... # your control-plane key
KEYRING_PROJECT_ID=... # the project whose policy this process caches
KEYRING_BASE_URL=https://... # your control plane; no default// app/api/orders/route.ts
import { getKeyring, withKeyring } from '@keyring-dev/next';
export const runtime = 'nodejs';
const keyring = getKeyring({
resources: { orders: { live: liveOrders, test: testOrders } },
});
export const GET = withKeyring(
(_request, { keyring: context }) =>
Response.json({
tenant: context?.tenantId,
env: context?.env,
orders: context?.orders,
}),
{ instance: keyring },
);curl -H "Authorization: Bearer kr_test_..." http://localhost:3000/api/ordersThis block is compiled the way a customer compiles it — tsc against the
published dist/, with no project references and no path mapping — and that
check pins the context property's exact type. A runnable version of it is
executed against a stub control plane in the source repository's own test
suite, which also runs next build over this exact block in a real Next.js
project, because Next.js type-checks a route handler's signature at build
time and never in next dev.
context is KeyringContext | null, the same shape req.keyring has in
@keyring-dev/express and @keyring-dev/fastify — one row of
@keyring-dev/sdk's cross-language contract table, so the ?. above is not
defensive style. It is null on a route configured skip: true and on a
request the wrapper refused; a refused request never reaches your handler at
all. Unlike Express's property there is no "from here onward" caveat: the
wrapper builds the object it hands you, so the field is always present.
Everything the wrapper does is in @keyring-dev/sdk — read its README for the
three onUnavailable modes, the three ergonomic levels, rate limiting,
idempotency and the telemetry buffer. This package is translation.
One client per process, and what the dev server does to that
getKeyring() returns a process-wide client held on globalThis, the same
pattern a Next.js codebase already uses for globalThis.prisma, and for the
same reason. The client owns a poller on an interval, an LRU that has to
outlive a request, a disk snapshot and a telemetry buffer. next dev
re-evaluates a route module on every save, so a client constructed at module
scope without this would be a new poller on every edit, with the previous
ones still holding their timers — nothing calls close() on a module the
bundler dropped.
The options are read by whichever call constructs the instance; a later call
with different options gets the instance that already exists rather than a
second one. So configure it in one place — a lib/keyring.ts that exports
getKeyring({ ... }) — and pass it as instance from each route, as the
quickstart does. A call that hands it options after the
client exists is reported through that call's onError, so an edit that did
not take says so instead of looking like one that did.
The edge runtime, middleware.ts and proxy.ts
They cannot run this SDK, and this package does not pretend otherwise.
Verification is a cache lookup in your own process, not a call to us: a poller
on an interval, an LRU of the project's keys, a disk snapshot that makes the
next boot survive an outage of ours, and AsyncLocalStorage for currentEnv().
The edge runtime has no long-lived process to hold any of that and no
filesystem to persist it, and the only edge-shaped alternative — asking us over
the network on every request — is the single thing this architecture exists not
to do. There is no degraded edge mode here to reach for.
So every route that uses withKeyring needs export const runtime = 'nodejs',
and middleware.ts (and Next.js 16's proxy.ts) is out, because Next.js runs
it on the edge runtime.
Under the edge-light and worker export conditions — the ones the edge
runtime resolves, under webpack and under Turbopack alike — @keyring-dev/next is
a module that throws that instruction the moment it is evaluated, exports the
same names the real entry does (each one a function that throws too), and
never imports the SDK. The exports matter as much as the throw: Turbopack
checks an import against the target module's exports statically, before
anything runs, so a module with none of them failed that check with "Export
withKeyring doesn't exist in target module" instead of ever reaching the
message below. An export const runtime = 'edge' route therefore fails next
build naming runtime = 'nodejs' and middleware.ts, rather than on
webpack's UnhandledSchemeError: Reading from "node:crypto" — what it produced
before these export conditions existed — or on Turbopack's static export
error. The source repository's own test suite builds such a route under both
bundlers and asserts which message comes out.
Pages Router
Not supported. withKeyring wraps an App Router route handler — a
Request in, a Response out — and a pages/api/* handler is the Node
(req, res) pair instead. Wrapping both would be two code paths in a package
whose whole job is to be thin, so this one does App Router only.
Serverless, and why the default may be the wrong one there
A process that starts cold and serves one request never gets a policy snapshot
in time. Under stale-then-open — the SDK default — a store that has never
loaded admits any well-formed key, unverified, for as long as it stays that
way, and the key format's checksum is CRC-32, so producing one costs an
attacker nothing. That is the third row of @keyring-dev/sdk's failure-mode
table and it is the row a fresh serverless invocation sits in.
If you deploy this to a platform that gives you a fresh process per request or
close to it, set onUnavailable: 'stale-then-closed' and accept the other cost
— during an outage of ours a cold process refuses every request until its first
successful poll. On a long-lived Node server (next start, a container, a VM)
the default is the default for the usual reasons, and persist (on by default)
makes a restart that keeps its filesystem serve from the snapshot it left.
Per-route options
The wrapper takes the same rule the routes table takes, for the route this
file serves:
export const POST = withKeyring(handler, { onUnavailable: 'closed' }); // money
export const GET = withKeyring(handler, { skip: true }); // no verification, no eventA rule passed here replaces the routes table's rule for this request; it
does not merge with it. That matters on a money route. With
routes: { 'POST /api/payments': { onUnavailable: 'closed' } } on the client,
withKeyring(handler, { scopes: ['write:payments'] }) discards the closed and
puts the route back on the default, stale-then-open — where a key the cache
has never seen is admitted unverified and, by design, not scope-checked. The
scopes you added to tighten the route protect nothing there and cost it the
closed mode. Put every setting a route needs in one place: all of it on the
wrapper, or all of it in routes. @keyring-dev/sdk's own test suite pins the
replacement.
skip: true leaves context.keyring null, runs your handler and records no
usage event. scopes and maxStalenessMs are accepted too. A dynamic route can
pass route: '/api/orders/[id]' so the request log and any routes rule see
the pattern rather than a different concrete path on every request.
next build needs the same environment as the server
next build evaluates each route module to collect page data, and the
quickstart builds its client at module scope — so the build environment needs
KEYRING_SECRET_KEY, KEYRING_PROJECT_ID and KEYRING_BASE_URL too, or the
build fails on the SDK's "missing configuration" the moment getKeyring()
constructs the client.
Construction alone makes no network call and starts no poller — getKeyring()
detects next build's NEXT_PHASE and defers start() to the process that
actually serves requests, so a build worker never presents your live secret
key to the control plane. The source repository's own test suite builds the
quickstart against a counting stub and asserts it receives zero requests.
Rate limits and idempotency
Both are the SDK's, unchanged. Rate-limit headers are set on whatever Response
your handler returned — including one it proxied from fetch(), whose headers
are immutable, which is rebuilt rather than left unlabelled. A request carrying
Idempotency-Key has its body read from a clone before your handler runs (your
handler still gets an unconsumed body) and its response buffered and stored
before the bytes reach the caller, which is what makes a replay correct for
a process that dies mid-stream.
