@keyring-dev/nest
v0.1.1
Published
NestJS module, guard and decorators for Keyring. Works on the Express and the Fastify platform adapters.
Readme
@keyring-dev/nest
NestJS module, guard and decorators for Keyring. Works on the Express and the Fastify platform adapters. MIT.
# .env
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 defaultimport { Controller, Get, Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { KeyringContext, KeyringGuard, KeyringModule } from '@keyring-dev/nest';
@Controller('/v1/orders')
export class OrdersController {
@Get()
list(@KeyringContext() keyring: KeyringContext | null) {
return {
tenant: keyring?.tenantId,
env: keyring?.env,
orders: keyring?.orders,
};
}
}
@Module({
imports: [KeyringModule.forRoot({ resources: { orders } })],
controllers: [OrdersController],
providers: [{ provide: APP_GUARD, useClass: KeyringGuard }],
})
export class AppModule {}curl -H "Authorization: Bearer kr_test_..." http://localhost:3000/v1/ordersThat is a controller and a module; bootstrap it as you would any other —
NestFactory.create(AppModule) and app.listen, with import 'reflect-metadata'
first.
This 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's exact type. A runnable version of it is built and
executed against a stub control plane in the source repository's own test
suite.
@KeyringContext() gives you 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 ?. is not defensive
style. It is null on a route carrying @SkipKeyring() and on any route
KeyringGuard did not run on; a request the guard refused never reaches your
handler at all. The same value is on request.keyring for anything that reads
the raw request.
Everything the guard 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, and both platform
adapters go through the same one.
What forRoot wires, and what it deliberately does not
KeyringModule.forRoot(options) provides the client, exports it as
KEYRING_INSTANCE and KeyringGuard, and registers one global interceptor:
the environment scope (currentEnv() inside your handler and anything it
starts), the idempotency state machine, and the usage event. That interceptor
authorizes nothing, and on a request no guard verified it returns immediately.
It does not register the guard for you. The guard is the authorization decision, and a module import that quietly starts refusing requests is the wrong default for one. Wire it the way a Nest application wires every other guard:
providers: [{ provide: APP_GUARD, useClass: KeyringGuard }]; // globally@UseGuards(KeyringGuard) // or per controller, or per handlerDoing both is harmless: the guard runs once per request whatever the wiring. Nest dedupes neither a global guard nor a controller one, and two runs would charge the rate limit twice and write two usage events — on exactly the routes a customer was careful enough to guard twice.
forRootAsync({ imports, inject, useFactory }) is there for options that come
from a ConfigService. The module is global: true by default so a feature
module can @UseGuards(KeyringGuard) without importing it; pass
global: false if you would rather import it explicitly. app.close() closes
the client the module built — a client you passed in as instance is yours to
close.
@SkipKeyring()
On a handler or a controller: no verification, no context, no usage event — the
same branch routes: { 'GET /healthz': { skip: true } } takes in the other two
adapters, because it goes through the same Keyring.handle call rather than
around it. A health check is the case it exists for; it does not make the guard
optional anywhere else.
Per-route onUnavailable, scopes and maxStalenessMs are not decorators
here: they are the routes table on the client, keyed by the controller's own
route pattern.
KeyringModule.forRoot({
routes: {
'POST /v1/payments': { onUnavailable: 'closed' }, // money: refuse rather than guess
'/v1/internal/*': { onUnavailable: 'stale-then-closed' },
},
});One table rather than two spellings, and keyring.stats().unmatchedRoutes names
a rule that never matched — which is what catches a typo that would otherwise
leave a money route on the fail-open default.
Where it differs from the other two adapters
Verification runs after the body is parsed. @keyring-dev/express and
@keyring-dev/fastify both verify before any parser, so an unauthenticated caller
cannot get a 5 MB payload parsed on the way to a 401. On NestJS they cannot:
the platform adapter parses before its router, and a guard is the earliest
point that can read the @SkipKeyring() metadata a route carries. Bound the
body on the platform adapter instead — bodyParser options on
NestExpressApplication, bodyLimit on FastifyAdapter.
request.keyring is set by the guard, so it starts there. Anything that
runs before it sees no property at all: Nest middleware, a platform-level
app.use, and — the likeliest case — another APP_GUARD registered ahead of
KeyringGuard, such as your own session guard. Read it through @KeyringContext(), which answers null rather than
undefined in that case.
Everything else is the same behaviour, and the package's own test suite runs
the same table twice, once per platform: the denial body, the RateLimit header spellings,
the three onUnavailable modes, rateLimitOnUnavailable, the idempotency state
machine including a replayed 202 and a replayed 422, and one usage event per
request.
