{"_id":"@aether-zone/organon","_rev":"7-44a27f5486765e9c8349e806d84ff543","name":"@aether-zone/organon","dist-tags":{"latest":"0.5.0"},"versions":{"0.2.0":{"name":"@aether-zone/organon","version":"0.2.0","license":"MIT","_id":"@aether-zone/organon@0.2.0","maintainers":[{"name":"pascalwilbrink","email":"pascal.wilbrink@gmail.com"}],"homepage":"https://github.com/Aether-zone/organon#readme","bugs":{"url":"https://github.com/Aether-zone/organon/issues"},"dist":{"shasum":"fafbc137d332457fbe5fb39dcff2ef15ca0506cb","tarball":"https://registry.npmjs.org/@aether-zone/organon/-/organon-0.2.0.tgz","fileCount":9,"integrity":"sha512-AE5eKXPayoKpy22HdOci7cg2Tdk3HXw5ajzyZjaWPkkr+lr4ErSGXmaDpJ50D7AWz+cwBQdhYSYgE9o5JVth0Q==","signatures":[{"sig":"MEYCIQCe2Hqezw9ycfA2prcqLjoJAFJk64Ym4r6qsRYgaFoE7AIhAJJKbq3ywIvx4AH2b/Fm3NL3honNto9L/z76vSXv6OIO","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":502813},"main":"./dist/index.cjs","type":"module","_from":"file:aether-zone-organon-0.2.0.tgz","types":"./dist/index.d.ts","module":"./dist/index.js","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"},"./package.json":"./package.json"},"scripts":{"lint":"eslint .","test":"jest","build":"tsup","typecheck":"tsc -p tsconfig.lib.json --noEmit"},"_npmUser":{"name":"pascalwilbrink","email":"pascal.wilbrink@gmail.com"},"_resolved":"/tmp/dbb76123572469a5eeef933e833e7d7c/aether-zone-organon-0.2.0.tgz","_integrity":"sha512-AE5eKXPayoKpy22HdOci7cg2Tdk3HXw5ajzyZjaWPkkr+lr4ErSGXmaDpJ50D7AWz+cwBQdhYSYgE9o5JVth0Q==","repository":{"url":"git+https://github.com/Aether-zone/organon.git","type":"git","directory":"libs/organon"},"_npmVersion":"10.9.8","description":"Shared NestJS building blocks for aether-zone: RFC 9457 problem responses, and the pistis resource-server contract and guard.","directories":{},"_nodeVersion":"22.23.2","publishConfig":{"access":"public","registry":"https://registry.npmjs.org"},"_hasShrinkwrap":false,"devDependencies":{"zod":"^4.5.4","rxjs":"^7.8.1","passport":"^0.7.0","@swc/core":"^1.15.5","@nestjs/core":"^11.0.1","passport-jwt":"^4.0.1","@nestjs/common":"^11.0.1","@nestjs/config":"^4.0.2","@nestjs/testing":"^11.0.1","@nestjs/passport":"^11.0.5","reflect-metadata":"^0.2.2","@types/passport-jwt":"^4.0.1"},"peerDependencies":{"zod":"^4.0.0","rxjs":"^7.8.1","passport":"^0.7.0","@nestjs/core":"^11.0.1","passport-jwt":"^4.0.1","@nestjs/common":"^11.0.1","@nestjs/config":"^4.0.0","@nestjs/passport":"^11.0.0","reflect-metadata":"^0.2.2"},"_npmOperationalInternal":{"tmp":"tmp/organon_0.2.0_1788521861801_0.2395445628643833","host":"s3://npm-registry-packages-npm-production"}},"0.3.0":{"name":"@aether-zone/organon","version":"0.3.0","license":"MIT","_id":"@aether-zone/organon@0.3.0","maintainers":[{"name":"pascalwilbrink","email":"pascal.wilbrink@gmail.com"}],"homepage":"https://github.com/Aether-zone/organon#readme","bugs":{"url":"https://github.com/Aether-zone/organon/issues"},"dist":{"shasum":"fd0d9258c9a4a9e215097d9eb6776824e969121a","tarball":"https://registry.npmjs.org/@aether-zone/organon/-/organon-0.3.0.tgz","fileCount":8,"integrity":"sha512-FsCQsez1M2bEnHdgNmnj1DjwX8pHRBGg09CiVMhrxWiHNaR2Df1fHtkSvAPTFnF65UUy4Aq0HW3Z0cYaRSWQtw==","signatures":[{"sig":"MEQCIBRzo1muUzGjZ5CnjrAM4isA+BDxpQ/PJzE5p6m/ek3MAiB4AhxFBUm5iMQm6xTMOiUXA1FwHsl7LrxnTkLUkMSwaA==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":493800},"main":"./dist/index.cjs","type":"module","_from":"file:aether-zone-organon-0.3.0.tgz","types":"./dist/index.d.ts","module":"./dist/index.js","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"},"./package.json":"./package.json"},"scripts":{"lint":"eslint .","test":"jest","build":"tsup","typecheck":"tsc -p tsconfig.lib.json --noEmit"},"_npmUser":{"name":"pascalwilbrink","email":"pascal.wilbrink@gmail.com"},"_resolved":"/tmp/f9c719a08acf50cda0986ad5530d3907/aether-zone-organon-0.3.0.tgz","_integrity":"sha512-FsCQsez1M2bEnHdgNmnj1DjwX8pHRBGg09CiVMhrxWiHNaR2Df1fHtkSvAPTFnF65UUy4Aq0HW3Z0cYaRSWQtw==","repository":{"url":"git+https://github.com/Aether-zone/organon.git","type":"git","directory":"libs/organon"},"_npmVersion":"10.9.8","description":"Shared NestJS building blocks for aether-zone: RFC 9457 problem responses, and the pistis resource-server contract and guard.","directories":{},"_nodeVersion":"22.23.2","publishConfig":{"access":"public","registry":"https://registry.npmjs.org"},"_hasShrinkwrap":false,"devDependencies":{"zod":"^4.5.4","rxjs":"^7.8.1","passport":"^0.7.0","@swc/core":"^1.15.5","@nestjs/core":"^11.0.1","passport-jwt":"^4.0.1","@nestjs/common":"^11.0.1","@nestjs/config":"^4.0.2","@nestjs/testing":"^11.0.1","@nestjs/passport":"^11.0.5","reflect-metadata":"^0.2.2","@types/passport-jwt":"^4.0.1"},"peerDependencies":{"zod":"^4.0.0","rxjs":"^7.8.1","passport":"^0.7.0","@nestjs/core":"^11.0.1","passport-jwt":"^4.0.1","@nestjs/common":"^11.0.1","@nestjs/config":"^4.0.0","@nestjs/passport":"^11.0.0","reflect-metadata":"^0.2.2"},"_npmOperationalInternal":{"tmp":"tmp/organon_0.3.0_1788542314073_0.6166355074452281","host":"s3://npm-registry-packages-npm-production"}},"0.4.0":{"name":"@aether-zone/organon","version":"0.4.0","license":"MIT","_id":"@aether-zone/organon@0.4.0","maintainers":[{"name":"pascalwilbrink","email":"pascal.wilbrink@gmail.com"}],"homepage":"https://github.com/Aether-zone/organon#readme","bugs":{"url":"https://github.com/Aether-zone/organon/issues"},"dist":{"shasum":"6ddca7ba23bb92ab8fb450ebd4b986e5dc6474de","tarball":"https://registry.npmjs.org/@aether-zone/organon/-/organon-0.4.0.tgz","fileCount":8,"integrity":"sha512-eJHgQPbGqPFeILvMBTlrbhB019pyInPwDl9lyQE5xZxO0y+V/bg0gpqmnMhy95/nz6ZlItgh+dqijGqWFu/7Fw==","signatures":[{"sig":"MEUCIQDrGKDib2loO1epUbCTV/Glu6wiabbf67IQFVkp7ymL+QIgXztYUk0BxFH+D5HsFMBru60N7iwUVJmoPHTx5C8dpd0=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":549757},"main":"./dist/index.cjs","type":"module","_from":"file:aether-zone-organon-0.4.0.tgz","types":"./dist/index.d.ts","module":"./dist/index.js","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"},"./package.json":"./package.json"},"scripts":{"lint":"eslint .","test":"jest","build":"tsup","typecheck":"tsc -p tsconfig.lib.json --noEmit"},"_npmUser":{"name":"pascalwilbrink","email":"pascal.wilbrink@gmail.com"},"_resolved":"/tmp/c229060d53b8dae848b72dbd931036a8/aether-zone-organon-0.4.0.tgz","_integrity":"sha512-eJHgQPbGqPFeILvMBTlrbhB019pyInPwDl9lyQE5xZxO0y+V/bg0gpqmnMhy95/nz6ZlItgh+dqijGqWFu/7Fw==","repository":{"url":"git+https://github.com/Aether-zone/organon.git","type":"git","directory":"libs/organon"},"_npmVersion":"10.9.8","description":"Shared NestJS building blocks for aether-zone: RFC 9457 problem responses, the pistis resource-server contract and guard, and RabbitMQ events.","directories":{},"_nodeVersion":"22.23.2","publishConfig":{"access":"public","registry":"https://registry.npmjs.org"},"_hasShrinkwrap":false,"devDependencies":{"zod":"^4.5.4","rxjs":"^7.8.1","passport":"^0.7.0","@swc/core":"^1.15.5","@nestjs/core":"^11.0.1","passport-jwt":"^4.0.1","@nestjs/common":"^11.0.1","@nestjs/config":"^4.0.2","@nestjs/testing":"^11.0.1","@nestjs/passport":"^11.0.5","reflect-metadata":"^0.2.2","@types/passport-jwt":"^4.0.1","@golevelup/nestjs-rabbitmq":"^9.0.2"},"peerDependencies":{"zod":"^4.0.0","rxjs":"^7.8.1","passport":"^0.7.0","@nestjs/core":"^11.0.1","passport-jwt":"^4.0.1","@nestjs/common":"^11.0.1","@nestjs/config":"^4.0.0","@nestjs/passport":"^11.0.0","reflect-metadata":"^0.2.2","@golevelup/nestjs-rabbitmq":"^9.0.0"},"_npmOperationalInternal":{"tmp":"tmp/organon_0.4.0_1788559230120_0.5309421694030425","host":"s3://npm-registry-packages-npm-production"}},"0.5.0":{"name":"@aether-zone/organon","version":"0.5.0","description":"Shared NestJS building blocks for aether-zone: RFC 9457 problem responses, the pistis resource-server contract and guard, and RabbitMQ events.","license":"MIT","type":"module","main":"./dist/index.cjs","module":"./dist/index.js","types":"./dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","require":"./dist/index.cjs"},"./package.json":"./package.json"},"publishConfig":{"registry":"https://registry.npmjs.org","access":"public"},"repository":{"type":"git","url":"git+https://github.com/Aether-zone/organon.git","directory":"libs/organon"},"peerDependencies":{"@golevelup/nestjs-rabbitmq":"^9.0.0","@nestjs/common":"^11.0.1","@nestjs/config":"^4.0.0","@nestjs/core":"^11.0.1","@nestjs/passport":"^11.0.0","passport":"^0.7.0","passport-jwt":"^4.0.1","reflect-metadata":"^0.2.2","rxjs":"^7.8.1","zod":"^4.0.0"},"devDependencies":{"@golevelup/nestjs-rabbitmq":"^9.0.2","@nestjs/common":"^11.0.1","@nestjs/config":"^4.0.2","@nestjs/core":"^11.0.1","@nestjs/passport":"^11.0.5","@nestjs/testing":"^11.0.1","@swc/core":"^1.15.5","@types/passport-jwt":"^4.0.1","passport":"^0.7.0","passport-jwt":"^4.0.1","reflect-metadata":"^0.2.2","rxjs":"^7.8.1","zod":"^4.5.4"},"scripts":{"build":"tsup","typecheck":"tsc -p tsconfig.lib.json --noEmit","lint":"eslint .","test":"jest"},"_id":"@aether-zone/organon@0.5.0","bugs":{"url":"https://github.com/Aether-zone/organon/issues"},"homepage":"https://github.com/Aether-zone/organon#readme","_integrity":"sha512-cxcyYADaE8WVjC+hMZRHhcQ+JCZMopHo0p+SPz8xwwk9YkV8GjvH9MYFVl/OEcxt0Uru7MZTDZqF//PeKSILww==","_resolved":"/tmp/972056365c4904df665406c1063f1d9c/aether-zone-organon-0.5.0.tgz","_from":"file:aether-zone-organon-0.5.0.tgz","_nodeVersion":"22.23.2","_npmVersion":"10.9.8","dist":{"integrity":"sha512-cxcyYADaE8WVjC+hMZRHhcQ+JCZMopHo0p+SPz8xwwk9YkV8GjvH9MYFVl/OEcxt0Uru7MZTDZqF//PeKSILww==","shasum":"7dbbfc543abb172aeaeb8cb105e65ab475fb5d73","tarball":"https://registry.npmjs.org/@aether-zone/organon/-/organon-0.5.0.tgz","fileCount":8,"unpackedSize":573321,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQCg0uMzz+S62jMXc4C/GXMhZMnoFsI8p1rdFgoOiVOY7wIhAMJum5w3OO4aZpx+jn9ddAts9OI7gpwLmoOaqvfMBgA/"}]},"_npmUser":{"name":"pascalwilbrink","email":"pascal.wilbrink@gmail.com"},"directories":{},"maintainers":[{"name":"pascalwilbrink","email":"pascal.wilbrink@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/organon_0.5.0_1788982831658_0.011805321417251102"},"_hasShrinkwrap":false}},"time":{"created":"2026-09-04T11:37:41.621Z","modified":"2026-09-09T19:40:32.067Z","0.1.0":"2026-09-04T11:10:50.296Z","0.2.0":"2026-09-04T11:37:41.943Z","0.3.0":"2026-09-04T17:18:34.222Z","0.4.0":"2026-09-04T22:00:30.277Z","0.5.0":"2026-09-09T19:40:31.798Z"},"bugs":{"url":"https://github.com/Aether-zone/organon/issues"},"license":"MIT","homepage":"https://github.com/Aether-zone/organon#readme","repository":{"type":"git","url":"git+https://github.com/Aether-zone/organon.git","directory":"libs/organon"},"description":"Shared NestJS building blocks for aether-zone: RFC 9457 problem responses, the pistis resource-server contract and guard, and RabbitMQ events.","maintainers":[{"name":"pascalwilbrink","email":"pascal.wilbrink@gmail.com"}],"readme":"# @aether-zone/organon\n\nA NestJS library.\n\n```sh\npnpm add @aether-zone/organon\n```\n\n```ts\nimport { Module } from '@nestjs/common';\nimport { OrganonModule } from '@aether-zone/organon';\n\n@Module({ imports: [OrganonModule] })\nexport class AppModule {}\n```\n\n## Errors as Problem JSON\n\n`ProblemException` is an error that already knows how it renders;\n`ProblemExceptionFilter` renders every failure — not just that one — as an\n[RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem document, served as\n`application/problem+json`.\n\n```ts\nimport { APP_FILTER } from '@nestjs/core';\nimport { ProblemExceptionFilter } from '@aether-zone/organon';\n\n@Module({\n  providers: [{ provide: APP_FILTER, useClass: ProblemExceptionFilter }],\n})\nexport class AppModule {}\n```\n\n```ts\nthrow new ProblemException({\n  status: HttpStatus.CONFLICT,\n  type: 'https://example.com/probs/slug-taken',\n  title: 'That slug is already in use',\n  detail: `\"${slug}\" belongs to another organization.`,\n  extensions: { slug },\n});\n```\n\n```json\n{\n  \"type\": \"https://example.com/probs/slug-taken\",\n  \"title\": \"That slug is already in use\",\n  \"status\": 409,\n  \"detail\": \"\\\"acme\\\" belongs to another organization.\",\n  \"instance\": \"/organizations/acme\",\n  \"slug\": \"acme\"\n}\n```\n\nFour behaviours are deliberate:\n\n- **An unexpected error never reaches the client.** Anything that is not an\n  `HttpException` becomes a bare 500 whose message is dropped — those messages\n  name queries, paths and drivers. The stack is logged instead, so dropping it\n  does not mean losing it.\n- **`ValidationPipe`'s messages become an `errors` extension**, not a joined\n  sentence, because a client marking up form fields needs them apart.\n- **A blank `type` gets the status reason phrase as its `title`**, which is what\n  RFC 9457 §4.2.1 asks for.\n- **An extension may not shadow a standard member.** `extensions: { status }`\n  throws rather than silently producing a document whose `status` disagrees\n  with the response code.\n\n## One import\n\n`OrganonModule.forRoot()` wires configuration, logging, health and the problem\nfilter together.\n\n```ts\n@Module({\n  imports: [\n    OrganonModule.forRoot({\n      config: { schema: envSchema },\n      logging: { base: { service: 'akouo' } },\n      health: { indicators: [DatabaseHealth] },\n    }),\n  ],\n})\nexport class AppModule {}\n```\n\nEach part is registrable on its own, and this changes none of their behaviour.\nIt exists because two pairs of them only work properly when they know about\neach other:\n\n- **The problem filter reports the request id the logger issued.** A 500\n  deliberately tells the client nothing, so that id is the only route from a\n  reported failure to the stack trace explaining it.\n- **The health probes are excluded from the request log**, derived from the\n  health path so it stays right when the path is moved. An orchestrator polls\n  them every few seconds; left in, they are most of the log.\n\n`config` is omitted by default — there is no schema a library could supply.\n`health`, `logging` and `problem` are on; pass `false` to any of them to leave\nthat part out.\n\n```ts\nOrganonModule.forRoot({ problem: false });   // keep your own error rendering\nOrganonModule.forRoot({ health: false });    // no probes; nothing is excluded\n                                             // from the log either\n```\n\n## Configuration\n\n`AppConfigModule` loads the environment and validates it against a schema **the\napplication supplies**.\n\n```ts\nimport { AppConfigModule, ENV, baseEnvSchema, booleanFromString } from '@aether-zone/organon';\n\nexport const envSchema = baseEnvSchema.extend({\n  DATABASE_URL: z.string().min(1),\n  DEBUG_MODE: booleanFromString.default(false),\n});\nexport type Env = z.infer<typeof envSchema>;\n\n@Module({ imports: [AppConfigModule.forRoot({ schema: envSchema })] })\nexport class AppModule {}\n```\n\nThe schema is a parameter, not something this module owns. A library cannot\nknow what an application's environment looks like, and a schema fixed here\nwould name variables that mean nothing to most consumers — worse, *require*\nthem, which is a boot failure for everyone who does not happen to set them.\n`baseEnvSchema` is deliberately small — `NODE_ENV`, `PORT` and `LOG_LEVEL`,\nthe three every service has; extend it with your own.\n\n`LOG_LEVEL` is validated against the levels the logger knows, so a typo fails\nthe boot rather than being ignored. It is **optional rather than defaulted**:\n`JsonLogger` already falls back to `log` under `NODE_ENV=production` and\n`debug` elsewhere, and a default here would either restate that rule or quietly\noverride it — silencing debug output in development because a variable was\nunset. Pass it straight through:\n\n```ts\nconst app = await NestFactory.create(AppModule, {\n  logger: new JsonLogger({ level: env.LOG_LEVEL, base: { service: 'akouo' } }),\n});\n```\n\nCase and surrounding space are forgiven (`LOG_LEVEL=DEBUG` works), but the\nnames are Nest's, so the middle one is `log` — `info` is refused, with the\nvalid options named.\n\nInject the whole validated environment rather than fishing keys out of\n`ConfigService`:\n\n```ts\nconstructor(@Inject(ENV) private readonly env: Env) {}\n```\n\n`ENV` is the parsed object, so defaults and coercions are already applied —\n`env.PORT` is a number, and `DEBUG_MODE=false` is `false` rather than a truthy\nstring. `EnvService<Env>` still narrows `ConfigService` where you want it, but\nnote it is a type alias and so not a DI token: name `ConfigService` in the\n`@Inject` as well.\n\nAn invalid environment fails the boot, listing every problem:\n\n```\nInvalid environment configuration:\n  - NODE_ENV: Invalid option: expected one of \"development\"|\"test\"|\"production\"\n  - PORT: Invalid input: expected number, received NaN\n```\n\n`NestFactory` defaults to `abortOnError: true`, which logs that and exits; with\n`{ logger: false }` there is nothing left to print it and you get a silent exit\n1. Pass `abortOnError: false` to handle the rejection yourself.\n\n## Accepting pistis tokens\n\n`PistisAuthModule` makes a service a resource server for the pistis\nauthorization server: it accepts the bearer tokens pistis minted, and does\nnothing else.\n\n```ts\nimport { PistisAuthModule, jwksUriFor } from '@aether-zone/organon';\n\nconst issuer = 'https://pistis.example.com';\n\n@Module({\n  imports: [\n    PistisAuthModule.register({ issuer, audience: issuer, jwksUri: jwksUriFor(issuer) }),\n  ],\n})\nexport class AppModule {}\n```\n\n`registerAsync` takes the same options from a factory, which is what reading\nthem out of the validated environment needs:\n\n```ts\nPistisAuthModule.registerAsync({\n  inject: [ConfigService],\n  useFactory: (config: EnvService<Env>) => {\n    const issuer = config.get('OAUTH_ISSUER', { infer: true });\n\n    return {\n      issuer,\n      audience: config.get('OAUTH_AUDIENCE', { infer: true }) ?? issuer,\n      jwksUri: config.get('OAUTH_JWKS_URI', { infer: true }) ?? jwksUriFor(issuer),\n    };\n  },\n});\n```\n\n| Option | |\n| --- | --- |\n| `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. |\n| `audience` | The `aud` every token must carry. pistis defaults this to its issuer. |\n| `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. |\n| `tokenType` | The `typ` the token header must carry. Defaults to `at+jwt`. |\n\n**It is not part of `OrganonModule.forRoot()`.** Health, logging and problem\nrendering all have a default worth having; an issuer does not, and a service\nthat is not a resource server should not be made to name one.\n\n**There is nothing to sign in *to*.** A resource server issues no tokens, stores\nno passwords and keeps no user table. Signing in happens in whatever web app\nruns the authorization code flow against pistis; what arrives here is the\nresult. The only identity worth keeping is the token's `sub` — names and email\naddresses live in pistis.\n\nEvery route requires a token, because the module registers its guard as an\n`APP_GUARD`. `@Public()` opts one out:\n\n```ts\n@Public()\n@Get('version')\nversion() {\n  return { version: process.env.APP_VERSION };\n}\n```\n\n`@CurrentUser()` hands the handler what the token resolved to:\n\n```ts\n@Get('me')\nme(@CurrentUser() principal: Principal) {\n  return { id: principal.id, scopes: principal.scopes };\n}\n```\n\n```ts\ninterface Principal {\n  id: string;                    // the token's `sub`\n  clientId: string;              // which registered client it was issued to\n  scopes: string[];              // already split out of the space-delimited claim\n  organizations: Record<string, OrganizationMembershipClaim>;\n}\n```\n\n`hasScopes(principal, 'meetings:write')` answers the scope question;\n`parseScope` and `formatScope` are there for anything that has to read or write\nthe claim itself.\n\nFive things are deliberate:\n\n- **Validation is offline.** The signature is checked against pistis's published\n  JWKS and nothing else is asked of it, so a request costs no round trip to the\n  authorization server. The cost is that a revoked token stays good until it\n  expires — pistis keeps a row per `jti` precisely so it *can* answer that,\n  through `/oauth/introspect`, if revocation ever needs to take effect sooner.\n- **`RS256` is pinned rather than read from the token's own `alg`.** That is what\n  closes `alg: none` and the RSA-to-HMAC confusion attack, and it is the same\n  rule pistis applies when verifying.\n- **The header's `typ` is checked too.** A pistis *session* token is signed by\n  the same key, so the signature alone does not tell the two apart. Refusing\n  anything but `at+jwt` means a widened `audience` cannot quietly turn a session\n  into an access token.\n- **The default is closed.** A new controller is authenticated because nobody\n  did anything, which is the only default worth having.\n- **Signing keys are cached by `kid` and refetched when a token names an unknown\n  one**, which makes key rotation a non-event: the first token signed by a new\n  key misses, triggers one fetch, and every later token hits. An unknown `kid`\n  cannot be used to hammer pistis — there is a floor between refetches.\n\n### Acting in an organization\n\nA token carries the subject's memberships in its `orgs` claim, so deciding\nwhether a request may act in the organization it names takes no query and no\ncall back to pistis.\n\n```ts\n@Controller('organizations/:organizationId/meetings')\n@UseGuards(OrganizationGuard)\nexport class MeetingController {\n\n  @Get()\n  list(@CurrentActor() actor: Actor) {\n    return this.meetings.list(actor);\n  }\n\n  @Delete(':id')\n  @RequireRole('admin')\n  remove(@CurrentActor() actor: Actor, @Param('id') id: string) {\n    return this.meetings.remove(actor, id);\n  }\n}\n```\n\n`OrganizationGuard` reads the organization out of the path, refuses a caller who\nmay not act in it, and leaves an `Actor` for `@CurrentActor()`. `@RequireRole()`\nraises the bar from plain membership; absent, membership is what is required.\n\n```ts\ninterface Actor extends Principal {\n  organizationId: string;      // the organization this request named\n  role: MembershipRole;        // 'owner' | 'admin' | 'member'\n  organizationName: string;    // display only, and as stale as the token\n}\n```\n\n`Principal` says who the caller is and everywhere they *could* act; an `Actor`\nnarrows that to the one organization at hand. A service that takes an `Actor`\nrather than a `Principal` and an id cannot filter a query by the wrong\norganization: there is only one to reach for.\n\nThe guard injects nothing but `Reflector`, so a module declaring an\norganization-scoped controller imports nothing to use it —\n`@UseGuards(OrganizationGuard)` is the whole of the wiring.\n\n`OrganizationGuard` expects `:organizationId`. For a service that names it\nsomething else, `organizationGuardFor` builds the same guard around another\nparameter — once, at module scope, because Nest caches guard instances per\nclass and calling it inside `@UseGuards()` would make a new one per controller:\n\n```ts\nconst TenantGuard = organizationGuardFor('tenantId');\n```\n\nUnderneath is `actorIn(principal, organizationId, atLeast?)`, which is the whole\ndecision without the request: it answers `null` when the caller may not act\nthere, and an `Actor` when they may. Reach for it directly outside a controller\n— resolving an organization from a message body rather than a path, say. Where\nthe organization id *comes from* is deliberately the caller's problem, which is\nwhat lets the guard above be the only piece that knows about URLs.\n\nThree things are deliberate:\n\n- **An unknown organization and someone else's give the same 403.** A 404 for one\n  and a 403 for the other would answer \"does this organization exist\" for anyone\n  who cared to ask — and for the same reason, `actorIn` returns the same `null`\n  whether the caller is not a member or merely not senior enough. Use\n  `membershipIn` and `roleIn` where you genuinely need to tell them apart.\n- **The claim is as stale as the token.** Someone removed from an organization\n  keeps access until their client refreshes. pistis re-resolves the map on every\n  issue, refreshes included, so a refresh is what catches a client up. That is\n  the price of not asking pistis on every request, and it is worth naming rather\n  than discovering.\n- **`organizationName` is display only.** A rename in pistis is invisible to an\n  already-issued token, while `role` is the fact every access decision turns on.\n  Never key anything on the name.\n\n## Peer dependencies\n\nAll required, none optional: `@nestjs/common`, `@nestjs/core`, `@nestjs/config`,\n`@nestjs/passport`, `passport`, `passport-jwt`, `@golevelup/nestjs-rabbitmq`,\n`reflect-metadata`, `rxjs` and `zod`.\n\nThe passport three and the RabbitMQ client are required for the same reason:\nthe root barrel re-exports `auth/` and `messaging/`, which import them as\nvalues, so requiring this package requires them — even for a consumer that only\nwants a problem filter, and even for one that never touches a queue. Splitting\nthe entry points is what would buy that back, and the cost grows with each part\nthat has a client library behind it.\n\n`@golevelup/nestjs-rabbitmq` asks for `@nestjs/common` and `@nestjs/core`\n`^11.1.21` where this package asks for `^11.0.1`. A consumer on an earlier 11.x\nwill see an unmet-peer warning from it.\n\n## Events over RabbitMQ\n\n`RabbitMqModule` wraps [`@golevelup/nestjs-rabbitmq`][golevelup] rather than\nreplacing it: `@RabbitSubscribe`, `AmqpConnection` and the rest are that\npackage's and are used directly.\n\n[golevelup]: https://www.npmjs.com/package/@golevelup/nestjs-rabbitmq\n\n```ts\nRabbitMqModule.registerAsync({\n  inject: [ENV],\n  useFactory: (env: Env) => ({ uri: env.RABBITMQ_URI }),\n});\n```\n\n| Option | |\n| --- | --- |\n| `uri` | `amqp://user:pass@host:5672`, or a vhost URL. |\n| `exchange` | The topic exchange events go to. `aether-zone` by default. |\n| `prefetch` | Messages a consumer holds unacknowledged at once. `1`. |\n| `connectTimeoutMs` | Wait this long for the broker before finishing the boot; `false` to start anyway and connect in the background. |\n\nPublish with the routing key and the caller's token:\n\n```ts\nawait this.events.publish('recording.stored', { recordingId }, accessToken);\n```\n\nSubscribe with the package's own decorator:\n\n```ts\n@RabbitSubscribe({\n  exchange: 'aether-zone',\n  routingKey: 'recording.*',\n  queue: 'transcription.recordings',\n})\nasync onRecording(event: RecordingStoredEvent) {}\n```\n\nName the queue. An anonymous one is exclusive and vanishes with the process, so\na restart loses whatever arrived meanwhile.\n\n### The envelope\n\nEvery event carries `id`, `occurredAt` and an `accessToken`, filled in by\n`EventPublisher` so no publisher has to remember them:\n\n```ts\ninterface RabbitEvent {\n  id: string;          // unique per publish; survives a redelivery\n  occurredAt: string;  // when it happened, not when it was delivered\n  accessToken: string; // whoever caused it\n}\n```\n\nThe token is there because work that starts from an event has no request to\nborrow one from. Without it a consumer calling another service must act as\nitself, which loses which person the work was for and needs an authority of its\nown for something a person asked for.\n\nThree things follow, and none are theoretical:\n\n- **A token in a message is a credential in a queue.** It is written to the\n  broker's disk for a durable queue, readable by anything that can read the\n  queue, and lands in the dead-letter queue if the consumer keeps failing.\n  Broker access is token access; grant it accordingly.\n- **It expires.** A message that waits — a backlog, a retry, an outage — can be\n  delivered with a token no longer worth presenting. `isExpired(event)` answers\n  that, reading `exp` without verifying the signature, which is all that is\n  needed to decide whether presenting it is worth trying. A consumer that finds\n  one should fall back to its own credentials or give up, not retry forever.\n- **It is not proof of anything by itself.** A consumer acting on its claims\n  must verify it, exactly as it would a token from an HTTP header.\n\n`persistent` is set on every publish, so an event outlives a broker restart —\nthe point of sending it rather than doing the work inline. Delivery is\nat-least-once: `id` is what a consumer deduplicates on.\n\n## Health\n\n```ts\n@Module({ imports: [HealthModule.forRoot()] })\nexport class AppModule {}\n```\n\n| Route | Question | Checks dependencies |\n| --- | --- | --- |\n| `GET /health/live` | Is the process running? | no |\n| `GET /health/ready` | Can it serve traffic? | yes |\n| `GET /health` | — | yes, same as `/ready` |\n\nThe path is configurable, and **replaces** the default rather than adding to it:\n\n```ts\nHealthModule.forRoot({ path: 'internal/health' });\n// -> /internal/health, /internal/health/live, /internal/health/ready\n```\n\n`@Controller()` is evaluated when a class is defined, so the decorator is\napplied to a fresh subclass of `HealthControllerBase` per registration —\n`createHealthController(path)`, exported if you want to mount it yourself. To\nkeep `health` *under* a wider prefix instead, leave `path` alone and use Nest's\n`RouterModule.register([{ path: 'internal', module: HealthModule }])`.\n\n**Liveness deliberately checks nothing.** A liveness probe answers \"should this\nprocess be restarted\", and restarting a healthy process because its database\nwent down turns one outage into two — the restarts remove capacity exactly when\nthe dependency recovers and the load arrives. Dependencies belong in readiness,\nwhich takes the instance out of the load balancer and puts it back afterwards.\n\nRegister indicators for readiness:\n\n```ts\n@Injectable()\nclass DatabaseHealth implements HealthIndicator {\n  readonly name = 'database';\n  constructor(private readonly db: DataSource) {}\n\n  async check(): Promise<HealthCheckResult> {\n    await this.db.query('select 1');\n    return { status: 'up' };\n  }\n}\n\nHealthModule.forRoot({\n  imports: [DatabaseModule],\n  indicators: [DatabaseHealth],\n  info: { service: 'akouo', version: process.env.APP_VERSION },\n});\n```\n\nReadiness answers 200 or 503 with the same body either way:\n\n```json\n{ \"status\": \"down\", \"uptime\": 41,\n  \"info\": { \"service\": \"akouo\", \"version\": \"1.2.3\" },\n  \"checks\": { \"database\": { \"status\": \"up\" },\n              \"cache\": { \"status\": \"down\", \"error\": \"connection refused\" } } }\n```\n\nFour things are deliberate:\n\n- **Every indicator is bounded by a timeout** (3s by default). A check that\n  hangs would hang the endpoint, and an endpoint that never answers reads as a\n  *liveness* failure — so the process gets restarted for a fault in something\n  it merely talks to.\n- **An indicator that throws is reported, not propagated.** One broken check\n  marks itself down and leaves the rest of the report intact.\n- **The report is returned, not thrown**, so success and failure have the same\n  shape. It does not go through `ProblemExceptionFilter`: a 503 from readiness\n  is an expected operational signal rather than an error, and the report's own\n  `status` field would collide with the problem document's.\n- **The routes are `@Public()`**, so a global token guard does not apply — an\n  orchestrator has no credentials, and a health endpoint behind authentication\n  reports every instance as unhealthy.\n\nCheck details name the failing dependency, so keep these routes off the public\ninternet. Pair with the logger so the probes do not fill the log:\n`LoggerModule.forRoot({ ignorePaths: ['/health', '/health/live', '/health/ready'] })`.\n\n## Logging\n\n`LoggerModule` gives every request an id, logs a line per request, and makes\nthat id available to anything the request goes on to do.\n\n```ts\nimport { JsonLogger, LoggerModule } from '@aether-zone/organon';\n\n@Module({\n  imports: [\n    LoggerModule.forRoot({\n      base: { service: 'akouo' },\n      ignorePaths: ['/health'],\n    }),\n  ],\n})\nexport class AppModule {}\n\n// The application logger has to be set before the app exists, so no module\n// can do it. Give it the same options.\nconst app = await NestFactory.create(AppModule, {\n  logger: new JsonLogger({ base: { service: 'akouo' } }),\n});\n```\n\n```\n{\"service\":\"akouo\",\"level\":\"log\",\"time\":\"...\",\"message\":\"deep inside a service\",\n \"context\":\"DeepService\",\"requestId\":\"c1653b88-…\"}\n{\"service\":\"akouo\",\"level\":\"log\",\"time\":\"...\",\"message\":\"GET /ok 200 0.6ms\",\n \"context\":\"Request\",\"requestId\":\"c1653b88-…\"}\n```\n\nThe id is carried in an `AsyncLocalStorage`, so a log written inside a service\nthat knows nothing about it is still attributed to the request that caused it —\nwithout threading an argument through every function that might one day log.\n\n**It pairs with `ProblemExceptionFilter`.** A failure's problem document carries\nthe same `requestId`, and it is returned in the `x-request-id` response header:\n\n```json\n{ \"type\": \"about:blank\", \"title\": \"Internal Server Error\", \"status\": 500,\n  \"instance\": \"/boom\", \"requestId\": \"54098bd3-…\" }\n```\n\nAn unexpected error deliberately tells the client nothing about what went\nwrong, so that id is the only way to get from \"it failed\" to the stack trace\nthat says why. Searching the logs for it finds both the request line and the\nfilter's record of the exception.\n\nFour things are deliberate:\n\n- **Middleware, not an interceptor**, so the context is open before guards run.\n  An interceptor would leave a rejected authentication outside it.\n- **No request body, query string or headers are logged.** Bodies carry\n  passwords and `Authorization` carries the credential itself; the request line\n  logs the path with the query string stripped.\n- **The level follows the status** — 5xx error, 4xx warn, otherwise log. A log\n  where everything is one level cannot be filtered.\n- **An inbound `x-request-id` is ignored by default.** Behind a gateway that\n  sets it, turn on `trustInboundRequestId` to make one id span services; exposed\n  to the internet, leave it off — an id a caller picks is one they can repeat,\n  colliding their requests with someone else's in your logs. When trusted it is\n  still length-capped and character-checked before being written anywhere.\n\nShips ESM and CommonJS: a Nest application generated today is still CommonJS, so\nan ESM-only build would be unusable by the most likely consumer. `@nestjs/common`,\n`@nestjs/core`, `reflect-metadata` and `rxjs` are **peer** dependencies — the\nlibrary must run against the application's Nest, not a second copy of it.\n","readmeFilename":"README.md"}