@aether-zone/organon
v0.5.0
Published
Shared NestJS building blocks for aether-zone: RFC 9457 problem responses, the pistis resource-server contract and guard, and RabbitMQ events.
Readme
@aether-zone/organon
A NestJS library.
pnpm add @aether-zone/organonimport { Module } from '@nestjs/common';
import { OrganonModule } from '@aether-zone/organon';
@Module({ imports: [OrganonModule] })
export class AppModule {}Errors as Problem JSON
ProblemException is an error that already knows how it renders;
ProblemExceptionFilter renders every failure — not just that one — as an
RFC 9457 problem document, served as
application/problem+json.
import { APP_FILTER } from '@nestjs/core';
import { ProblemExceptionFilter } from '@aether-zone/organon';
@Module({
providers: [{ provide: APP_FILTER, useClass: ProblemExceptionFilter }],
})
export class AppModule {}throw new ProblemException({
status: HttpStatus.CONFLICT,
type: 'https://example.com/probs/slug-taken',
title: 'That slug is already in use',
detail: `"${slug}" belongs to another organization.`,
extensions: { slug },
});{
"type": "https://example.com/probs/slug-taken",
"title": "That slug is already in use",
"status": 409,
"detail": "\"acme\" belongs to another organization.",
"instance": "/organizations/acme",
"slug": "acme"
}Four behaviours are deliberate:
- An unexpected error never reaches the client. Anything that is not an
HttpExceptionbecomes a bare 500 whose message is dropped — those messages name queries, paths and drivers. The stack is logged instead, so dropping it does not mean losing it. ValidationPipe's messages become anerrorsextension, not a joined sentence, because a client marking up form fields needs them apart.- A blank
typegets the status reason phrase as itstitle, which is what RFC 9457 §4.2.1 asks for. - An extension may not shadow a standard member.
extensions: { status }throws rather than silently producing a document whosestatusdisagrees with the response code.
One import
OrganonModule.forRoot() wires configuration, logging, health and the problem
filter together.
@Module({
imports: [
OrganonModule.forRoot({
config: { schema: envSchema },
logging: { base: { service: 'akouo' } },
health: { indicators: [DatabaseHealth] },
}),
],
})
export class AppModule {}Each part is registrable on its own, and this changes none of their behaviour. It exists because two pairs of them only work properly when they know about each other:
- The problem filter reports the request id the logger issued. A 500 deliberately tells the client nothing, so that id is the only route from a reported failure to the stack trace explaining it.
- The health probes are excluded from the request log, derived from the health path so it stays right when the path is moved. An orchestrator polls them every few seconds; left in, they are most of the log.
config is omitted by default — there is no schema a library could supply.
health, logging and problem are on; pass false to any of them to leave
that part out.
OrganonModule.forRoot({ problem: false }); // keep your own error rendering
OrganonModule.forRoot({ health: false }); // no probes; nothing is excluded
// from the log eitherConfiguration
AppConfigModule loads the environment and validates it against a schema the
application supplies.
import { AppConfigModule, ENV, baseEnvSchema, booleanFromString } from '@aether-zone/organon';
export const envSchema = baseEnvSchema.extend({
DATABASE_URL: z.string().min(1),
DEBUG_MODE: booleanFromString.default(false),
});
export type Env = z.infer<typeof envSchema>;
@Module({ imports: [AppConfigModule.forRoot({ schema: envSchema })] })
export class AppModule {}The schema is a parameter, not something this module owns. A library cannot
know what an application's environment looks like, and a schema fixed here
would name variables that mean nothing to most consumers — worse, require
them, which is a boot failure for everyone who does not happen to set them.
baseEnvSchema is deliberately small — NODE_ENV, PORT and LOG_LEVEL,
the three every service has; extend it with your own.
LOG_LEVEL is validated against the levels the logger knows, so a typo fails
the boot rather than being ignored. It is optional rather than defaulted:
JsonLogger already falls back to log under NODE_ENV=production and
debug elsewhere, and a default here would either restate that rule or quietly
override it — silencing debug output in development because a variable was
unset. Pass it straight through:
const app = await NestFactory.create(AppModule, {
logger: new JsonLogger({ level: env.LOG_LEVEL, base: { service: 'akouo' } }),
});Case and surrounding space are forgiven (LOG_LEVEL=DEBUG works), but the
names are Nest's, so the middle one is log — info is refused, with the
valid options named.
Inject the whole validated environment rather than fishing keys out of
ConfigService:
constructor(@Inject(ENV) private readonly env: Env) {}ENV is the parsed object, so defaults and coercions are already applied —
env.PORT is a number, and DEBUG_MODE=false is false rather than a truthy
string. EnvService<Env> still narrows ConfigService where you want it, but
note it is a type alias and so not a DI token: name ConfigService in the
@Inject as well.
An invalid environment fails the boot, listing every problem:
Invalid environment configuration:
- NODE_ENV: Invalid option: expected one of "development"|"test"|"production"
- PORT: Invalid input: expected number, received NaNNestFactory defaults to abortOnError: true, which logs that and exits; with
{ logger: false } there is nothing left to print it and you get a silent exit
- Pass
abortOnError: falseto handle the rejection yourself.
Accepting pistis tokens
PistisAuthModule makes a service a resource server for the pistis
authorization server: it accepts the bearer tokens pistis minted, and does
nothing else.
import { PistisAuthModule, jwksUriFor } from '@aether-zone/organon';
const issuer = 'https://pistis.example.com';
@Module({
imports: [
PistisAuthModule.register({ issuer, audience: issuer, jwksUri: jwksUriFor(issuer) }),
],
})
export class AppModule {}registerAsync takes the same options from a factory, which is what reading
them out of the validated environment needs:
PistisAuthModule.registerAsync({
inject: [ConfigService],
useFactory: (config: EnvService<Env>) => {
const issuer = config.get('OAUTH_ISSUER', { infer: true });
return {
issuer,
audience: config.get('OAUTH_AUDIENCE', { infer: true }) ?? issuer,
jwksUri: config.get('OAUTH_JWKS_URI', { infer: true }) ?? jwksUriFor(issuer),
};
},
});| Option | |
| --- | --- |
| issuer | The iss every token must carry — pistis's public origin, not this service's. A token from anywhere else is refused even if its signature is good. |
| audience | The aud every token must carry. pistis defaults this to its issuer. |
| jwksUri | Where pistis publishes its public signing keys. jwksUriFor(issuer) derives it the way RFC 8414 lays it out, so a deployment normally configures the issuer alone. |
| tokenType | The typ the token header must carry. Defaults to at+jwt. |
It is not part of OrganonModule.forRoot(). Health, logging and problem
rendering all have a default worth having; an issuer does not, and a service
that is not a resource server should not be made to name one.
There is nothing to sign in to. A resource server issues no tokens, stores
no passwords and keeps no user table. Signing in happens in whatever web app
runs the authorization code flow against pistis; what arrives here is the
result. The only identity worth keeping is the token's sub — names and email
addresses live in pistis.
Every route requires a token, because the module registers its guard as an
APP_GUARD. @Public() opts one out:
@Public()
@Get('version')
version() {
return { version: process.env.APP_VERSION };
}@CurrentUser() hands the handler what the token resolved to:
@Get('me')
me(@CurrentUser() principal: Principal) {
return { id: principal.id, scopes: principal.scopes };
}interface Principal {
id: string; // the token's `sub`
clientId: string; // which registered client it was issued to
scopes: string[]; // already split out of the space-delimited claim
organizations: Record<string, OrganizationMembershipClaim>;
}hasScopes(principal, 'meetings:write') answers the scope question;
parseScope and formatScope are there for anything that has to read or write
the claim itself.
Five things are deliberate:
- Validation is offline. The signature is checked against pistis's published
JWKS and nothing else is asked of it, so a request costs no round trip to the
authorization server. The cost is that a revoked token stays good until it
expires — pistis keeps a row per
jtiprecisely so it can answer that, through/oauth/introspect, if revocation ever needs to take effect sooner. RS256is pinned rather than read from the token's ownalg. That is what closesalg: noneand the RSA-to-HMAC confusion attack, and it is the same rule pistis applies when verifying.- The header's
typis checked too. A pistis session token is signed by the same key, so the signature alone does not tell the two apart. Refusing anything butat+jwtmeans a widenedaudiencecannot quietly turn a session into an access token. - The default is closed. A new controller is authenticated because nobody did anything, which is the only default worth having.
- Signing keys are cached by
kidand refetched when a token names an unknown one, which makes key rotation a non-event: the first token signed by a new key misses, triggers one fetch, and every later token hits. An unknownkidcannot be used to hammer pistis — there is a floor between refetches.
Acting in an organization
A token carries the subject's memberships in its orgs claim, so deciding
whether a request may act in the organization it names takes no query and no
call back to pistis.
@Controller('organizations/:organizationId/meetings')
@UseGuards(OrganizationGuard)
export class MeetingController {
@Get()
list(@CurrentActor() actor: Actor) {
return this.meetings.list(actor);
}
@Delete(':id')
@RequireRole('admin')
remove(@CurrentActor() actor: Actor, @Param('id') id: string) {
return this.meetings.remove(actor, id);
}
}OrganizationGuard reads the organization out of the path, refuses a caller who
may not act in it, and leaves an Actor for @CurrentActor(). @RequireRole()
raises the bar from plain membership; absent, membership is what is required.
interface Actor extends Principal {
organizationId: string; // the organization this request named
role: MembershipRole; // 'owner' | 'admin' | 'member'
organizationName: string; // display only, and as stale as the token
}Principal says who the caller is and everywhere they could act; an Actor
narrows that to the one organization at hand. A service that takes an Actor
rather than a Principal and an id cannot filter a query by the wrong
organization: there is only one to reach for.
The guard injects nothing but Reflector, so a module declaring an
organization-scoped controller imports nothing to use it —
@UseGuards(OrganizationGuard) is the whole of the wiring.
OrganizationGuard expects :organizationId. For a service that names it
something else, organizationGuardFor builds the same guard around another
parameter — once, at module scope, because Nest caches guard instances per
class and calling it inside @UseGuards() would make a new one per controller:
const TenantGuard = organizationGuardFor('tenantId');Underneath is actorIn(principal, organizationId, atLeast?), which is the whole
decision without the request: it answers null when the caller may not act
there, and an Actor when they may. Reach for it directly outside a controller
— resolving an organization from a message body rather than a path, say. Where
the organization id comes from is deliberately the caller's problem, which is
what lets the guard above be the only piece that knows about URLs.
Three things are deliberate:
- An unknown organization and someone else's give the same 403. A 404 for one
and a 403 for the other would answer "does this organization exist" for anyone
who cared to ask — and for the same reason,
actorInreturns the samenullwhether the caller is not a member or merely not senior enough. UsemembershipInandroleInwhere you genuinely need to tell them apart. - The claim is as stale as the token. Someone removed from an organization keeps access until their client refreshes. pistis re-resolves the map on every issue, refreshes included, so a refresh is what catches a client up. That is the price of not asking pistis on every request, and it is worth naming rather than discovering.
organizationNameis display only. A rename in pistis is invisible to an already-issued token, whileroleis the fact every access decision turns on. Never key anything on the name.
Peer dependencies
All required, none optional: @nestjs/common, @nestjs/core, @nestjs/config,
@nestjs/passport, passport, passport-jwt, @golevelup/nestjs-rabbitmq,
reflect-metadata, rxjs and zod.
The passport three and the RabbitMQ client are required for the same reason:
the root barrel re-exports auth/ and messaging/, which import them as
values, so requiring this package requires them — even for a consumer that only
wants a problem filter, and even for one that never touches a queue. Splitting
the entry points is what would buy that back, and the cost grows with each part
that has a client library behind it.
@golevelup/nestjs-rabbitmq asks for @nestjs/common and @nestjs/core
^11.1.21 where this package asks for ^11.0.1. A consumer on an earlier 11.x
will see an unmet-peer warning from it.
Events over RabbitMQ
RabbitMqModule wraps @golevelup/nestjs-rabbitmq rather than
replacing it: @RabbitSubscribe, AmqpConnection and the rest are that
package's and are used directly.
RabbitMqModule.registerAsync({
inject: [ENV],
useFactory: (env: Env) => ({ uri: env.RABBITMQ_URI }),
});| Option | |
| --- | --- |
| uri | amqp://user:pass@host:5672, or a vhost URL. |
| exchange | The topic exchange events go to. aether-zone by default. |
| prefetch | Messages a consumer holds unacknowledged at once. 1. |
| connectTimeoutMs | Wait this long for the broker before finishing the boot; false to start anyway and connect in the background. |
Publish with the routing key and the caller's token:
await this.events.publish('recording.stored', { recordingId }, accessToken);Subscribe with the package's own decorator:
@RabbitSubscribe({
exchange: 'aether-zone',
routingKey: 'recording.*',
queue: 'transcription.recordings',
})
async onRecording(event: RecordingStoredEvent) {}Name the queue. An anonymous one is exclusive and vanishes with the process, so a restart loses whatever arrived meanwhile.
The envelope
Every event carries id, occurredAt and an accessToken, filled in by
EventPublisher so no publisher has to remember them:
interface RabbitEvent {
id: string; // unique per publish; survives a redelivery
occurredAt: string; // when it happened, not when it was delivered
accessToken: string; // whoever caused it
}The token is there because work that starts from an event has no request to borrow one from. Without it a consumer calling another service must act as itself, which loses which person the work was for and needs an authority of its own for something a person asked for.
Three things follow, and none are theoretical:
- A token in a message is a credential in a queue. It is written to the broker's disk for a durable queue, readable by anything that can read the queue, and lands in the dead-letter queue if the consumer keeps failing. Broker access is token access; grant it accordingly.
- It expires. A message that waits — a backlog, a retry, an outage — can be
delivered with a token no longer worth presenting.
isExpired(event)answers that, readingexpwithout verifying the signature, which is all that is needed to decide whether presenting it is worth trying. A consumer that finds one should fall back to its own credentials or give up, not retry forever. - It is not proof of anything by itself. A consumer acting on its claims must verify it, exactly as it would a token from an HTTP header.
persistent is set on every publish, so an event outlives a broker restart —
the point of sending it rather than doing the work inline. Delivery is
at-least-once: id is what a consumer deduplicates on.
Health
@Module({ imports: [HealthModule.forRoot()] })
export class AppModule {}| Route | Question | Checks dependencies |
| --- | --- | --- |
| GET /health/live | Is the process running? | no |
| GET /health/ready | Can it serve traffic? | yes |
| GET /health | — | yes, same as /ready |
The path is configurable, and replaces the default rather than adding to it:
HealthModule.forRoot({ path: 'internal/health' });
// -> /internal/health, /internal/health/live, /internal/health/ready@Controller() is evaluated when a class is defined, so the decorator is
applied to a fresh subclass of HealthControllerBase per registration —
createHealthController(path), exported if you want to mount it yourself. To
keep health under a wider prefix instead, leave path alone and use Nest's
RouterModule.register([{ path: 'internal', module: HealthModule }]).
Liveness deliberately checks nothing. A liveness probe answers "should this process be restarted", and restarting a healthy process because its database went down turns one outage into two — the restarts remove capacity exactly when the dependency recovers and the load arrives. Dependencies belong in readiness, which takes the instance out of the load balancer and puts it back afterwards.
Register indicators for readiness:
@Injectable()
class DatabaseHealth implements HealthIndicator {
readonly name = 'database';
constructor(private readonly db: DataSource) {}
async check(): Promise<HealthCheckResult> {
await this.db.query('select 1');
return { status: 'up' };
}
}
HealthModule.forRoot({
imports: [DatabaseModule],
indicators: [DatabaseHealth],
info: { service: 'akouo', version: process.env.APP_VERSION },
});Readiness answers 200 or 503 with the same body either way:
{ "status": "down", "uptime": 41,
"info": { "service": "akouo", "version": "1.2.3" },
"checks": { "database": { "status": "up" },
"cache": { "status": "down", "error": "connection refused" } } }Four things are deliberate:
- Every indicator is bounded by a timeout (3s by default). A check that hangs would hang the endpoint, and an endpoint that never answers reads as a liveness failure — so the process gets restarted for a fault in something it merely talks to.
- An indicator that throws is reported, not propagated. One broken check marks itself down and leaves the rest of the report intact.
- The report is returned, not thrown, so success and failure have the same
shape. It does not go through
ProblemExceptionFilter: a 503 from readiness is an expected operational signal rather than an error, and the report's ownstatusfield would collide with the problem document's. - The routes are
@Public(), so a global token guard does not apply — an orchestrator has no credentials, and a health endpoint behind authentication reports every instance as unhealthy.
Check details name the failing dependency, so keep these routes off the public
internet. Pair with the logger so the probes do not fill the log:
LoggerModule.forRoot({ ignorePaths: ['/health', '/health/live', '/health/ready'] }).
Logging
LoggerModule gives every request an id, logs a line per request, and makes
that id available to anything the request goes on to do.
import { JsonLogger, LoggerModule } from '@aether-zone/organon';
@Module({
imports: [
LoggerModule.forRoot({
base: { service: 'akouo' },
ignorePaths: ['/health'],
}),
],
})
export class AppModule {}
// The application logger has to be set before the app exists, so no module
// can do it. Give it the same options.
const app = await NestFactory.create(AppModule, {
logger: new JsonLogger({ base: { service: 'akouo' } }),
});{"service":"akouo","level":"log","time":"...","message":"deep inside a service",
"context":"DeepService","requestId":"c1653b88-…"}
{"service":"akouo","level":"log","time":"...","message":"GET /ok 200 0.6ms",
"context":"Request","requestId":"c1653b88-…"}The id is carried in an AsyncLocalStorage, so a log written inside a service
that knows nothing about it is still attributed to the request that caused it —
without threading an argument through every function that might one day log.
It pairs with ProblemExceptionFilter. A failure's problem document carries
the same requestId, and it is returned in the x-request-id response header:
{ "type": "about:blank", "title": "Internal Server Error", "status": 500,
"instance": "/boom", "requestId": "54098bd3-…" }An unexpected error deliberately tells the client nothing about what went wrong, so that id is the only way to get from "it failed" to the stack trace that says why. Searching the logs for it finds both the request line and the filter's record of the exception.
Four things are deliberate:
- Middleware, not an interceptor, so the context is open before guards run. An interceptor would leave a rejected authentication outside it.
- No request body, query string or headers are logged. Bodies carry
passwords and
Authorizationcarries the credential itself; the request line logs the path with the query string stripped. - The level follows the status — 5xx error, 4xx warn, otherwise log. A log where everything is one level cannot be filtered.
- An inbound
x-request-idis ignored by default. Behind a gateway that sets it, turn ontrustInboundRequestIdto make one id span services; exposed to the internet, leave it off — an id a caller picks is one they can repeat, colliding their requests with someone else's in your logs. When trusted it is still length-capped and character-checked before being written anywhere.
Ships ESM and CommonJS: a Nest application generated today is still CommonJS, so
an ESM-only build would be unusable by the most likely consumer. @nestjs/common,
@nestjs/core, reflect-metadata and rxjs are peer dependencies — the
library must run against the application's Nest, not a second copy of it.
