@o3co/create-auth-policy-verifier 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -1,12 +1,10 @@
1
1
  /**
2
2
  * Decides whether one entry of the template tree is copied into a new project.
3
3
  *
4
- * The exclusion is judged on the path RELATIVE to the template root, which is
5
- * the whole point: the scaffolder ships inside `node_modules` whenever it is
6
- * run the documented way (`npm create`, `npx`), so every absolute source path
7
- * it copies from contains a `node_modules` segment. Matching against the
8
- * absolute path therefore rejected the entire template and produced an empty
9
- * project directory — the scaffolder only ever worked from a source checkout.
4
+ * The exclusion is judged on the path relative to the template root. Run the
5
+ * documented way (`npm create`, `npx`), the scaffolder sits inside
6
+ * `node_modules`, so every absolute source path contains a `node_modules`
7
+ * segment and matching on it would exclude the whole template.
10
8
  */
11
9
  export declare const isTemplateEntryIncluded: (templatesDir: string, source: string) => boolean;
12
10
  export declare const isValidProjectName: (name: string) => boolean;
@@ -29,18 +27,14 @@ export type LockfileResult = {
29
27
  readonly reason: string;
30
28
  };
31
29
  /**
32
- * Generates `pnpm-lock.yaml` inside an already-scaffolded `targetDir`.
30
+ * Generates `pnpm-lock.yaml` inside an already-scaffolded `targetDir`, for the
31
+ * template's `pnpm install --frozen-lockfile` build. The lockfile cannot ship
32
+ * with the template: its dependency set exists only once `scaffold` has
33
+ * replaced every `workspace:*` with a published version.
33
34
  *
34
- * This is what makes the template's `pnpm install --frozen-lockfile` build
35
- * possible: the lockfile cannot be shipped with the template, because the
36
- * template's dependency set does not exist until `scaffold` has replaced every
37
- * `workspace:*` with a published version. So it is resolved here, once, against
38
- * the rewritten `package.json`, and the project commits the result.
39
- *
40
- * Best-effort by design: it needs a package manager and a reachable registry,
41
- * and neither is guaranteed on the machine running the scaffolder. Failure is
42
- * reported, never thrown — the generated project is perfectly usable without
43
- * it, the operator just has to run `pnpm install` before `docker build`.
35
+ * Best-effort: it needs a package manager and a reachable registry. Failure is
36
+ * reported, never thrown; the project is usable without the lockfile once the
37
+ * operator runs `pnpm install` before `docker build`.
44
38
  */
45
39
  export declare const generateLockfile: (targetDir: string) => LockfileResult;
46
40
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../src/index.mts"],"names":[],"mappings":"AA2BA;;;;;;;;;GASG;AACH,eAAO,MAAM,uBAAuB,GAAI,cAAc,MAAM,EAAE,QAAQ,MAAM,KAAG,OAI9E,CAAC;AAMF,eAAO,MAAM,kBAAkB,GAAI,MAAM,MAAM,KAAG,OAIjD,CAAC;AAEF,eAAO,MAAM,cAAc,GAAI,MAAM,MAAM,KAAG,OAI7C,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,QAAQ,GAAI,WAAW,MAAM,EAAE,aAAa,MAAM,KAAG,IA0CjE,CAAC;AAEF,wDAAwD;AACxD,MAAM,MAAM,cAAc,GACvB;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAC/C;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAyBnD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,gBAAgB,GAAI,WAAW,MAAM,KAAG,cA6BpD,CAAC;AAyDF;;;;GAIG;AACH,eAAO,MAAM,IAAI,QAAO,IAgEvB,CAAC"}
1
+ {"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../src/index.mts"],"names":[],"mappings":"AA2BA;;;;;;;GAOG;AACH,eAAO,MAAM,uBAAuB,GAAI,cAAc,MAAM,EAAE,QAAQ,MAAM,KAAG,OAI9E,CAAC;AAMF,eAAO,MAAM,kBAAkB,GAAI,MAAM,MAAM,KAAG,OAIjD,CAAC;AAEF,eAAO,MAAM,cAAc,GAAI,MAAM,MAAM,KAAG,OAI7C,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,QAAQ,GAAI,WAAW,MAAM,EAAE,aAAa,MAAM,KAAG,IAwCjE,CAAC;AAEF,wDAAwD;AACxD,MAAM,MAAM,cAAc,GACvB;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAC/C;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAsBnD;;;;;;;;;GASG;AACH,eAAO,MAAM,gBAAgB,GAAI,WAAW,MAAM,KAAG,cA6BpD,CAAC;AAyDF;;;;GAIG;AACH,eAAO,MAAM,IAAI,QAAO,IAgEvB,CAAC"}
package/dist/index.mjs CHANGED
@@ -23,12 +23,10 @@ const EXCLUDED_DIRS = new Set(["node_modules", "dist"]);
23
23
  /**
24
24
  * Decides whether one entry of the template tree is copied into a new project.
25
25
  *
26
- * The exclusion is judged on the path RELATIVE to the template root, which is
27
- * the whole point: the scaffolder ships inside `node_modules` whenever it is
28
- * run the documented way (`npm create`, `npx`), so every absolute source path
29
- * it copies from contains a `node_modules` segment. Matching against the
30
- * absolute path therefore rejected the entire template and produced an empty
31
- * project directory — the scaffolder only ever worked from a source checkout.
26
+ * The exclusion is judged on the path relative to the template root. Run the
27
+ * documented way (`npm create`, `npx`), the scaffolder sits inside
28
+ * `node_modules`, so every absolute source path contains a `node_modules`
29
+ * segment and matching on it would exclude the whole template.
32
30
  */
33
31
  export const isTemplateEntryIncluded = (templatesDir, source) => {
34
32
  const rel = relative(templatesDir, source);
@@ -74,11 +72,9 @@ export const scaffold = (targetDir, projectName) => {
74
72
  const pkgPath = resolve(targetDir, "package.json");
75
73
  const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
76
74
  pkg.name = projectName;
77
- // `"private": true` is deliberately KEPT (#126 item 4). The scaffold is an
78
- // authorization service — policy code and config — and deleting the field
79
- // made an accidental `npm publish` succeed by default. Publishing a
80
- // scaffolded service is the rare intent; the operator who has it states it
81
- // by removing the field.
75
+ // `"private": true` is kept: the scaffold is an authorization service —
76
+ // policy code and config — and the field makes an accidental `npm publish`
77
+ // fail. An operator who means to publish removes it.
82
78
  // Replace all workspace:* references with per-package published versions
83
79
  const versions = getPackageVersions();
84
80
  for (const section of ["dependencies", "devDependencies", "peerDependencies"]) {
@@ -109,28 +105,21 @@ const LOCKFILE_ARGS = ["install", "--lockfile-only", "--ignore-workspace"];
109
105
  * covers a machine that only has Node, and honours the `packageManager` field
110
106
  * the template ships.
111
107
  *
112
- * The fallback is narrow on purpose: it applies to `ENOENT` and nothing else,
113
- * because `ENOENT` is the one failure that means "this binary is not here" and
114
- * is therefore the only one a different launcher can answer. Any other launch
115
- * error (`EACCES`, `EPERM`, …) says the binary IS there and could not be run,
116
- * and a package manager that ran and exited non-zero has already reported that
117
- * resolution failed — retrying either would hide the real cause behind
118
- * "package manager missing" and hand the operator the wrong instruction.
108
+ * Only `ENOENT` (the binary is not here) moves on to the next launcher. Any
109
+ * other launch error (`EACCES`, `EPERM`, …) means the binary is there and could
110
+ * not be run, and a non-zero exit means resolution failed; retrying either
111
+ * would hide the real cause behind "package manager missing".
119
112
  */
120
113
  const LOCKFILE_LAUNCHERS = [["pnpm"], ["corepack", "pnpm"]];
121
114
  /**
122
- * Generates `pnpm-lock.yaml` inside an already-scaffolded `targetDir`.
115
+ * Generates `pnpm-lock.yaml` inside an already-scaffolded `targetDir`, for the
116
+ * template's `pnpm install --frozen-lockfile` build. The lockfile cannot ship
117
+ * with the template: its dependency set exists only once `scaffold` has
118
+ * replaced every `workspace:*` with a published version.
123
119
  *
124
- * This is what makes the template's `pnpm install --frozen-lockfile` build
125
- * possible: the lockfile cannot be shipped with the template, because the
126
- * template's dependency set does not exist until `scaffold` has replaced every
127
- * `workspace:*` with a published version. So it is resolved here, once, against
128
- * the rewritten `package.json`, and the project commits the result.
129
- *
130
- * Best-effort by design: it needs a package manager and a reachable registry,
131
- * and neither is guaranteed on the machine running the scaffolder. Failure is
132
- * reported, never thrown — the generated project is perfectly usable without
133
- * it, the operator just has to run `pnpm install` before `docker build`.
120
+ * Best-effort: it needs a package manager and a reachable registry. Failure is
121
+ * reported, never thrown; the project is usable without the lockfile once the
122
+ * operator runs `pnpm install` before `docker build`.
134
123
  */
135
124
  export const generateLockfile = (targetDir) => {
136
125
  const attempts = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/create-auth-policy-verifier",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Scaffold a new o3co/auth.policy-verifier project from the standalone template",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -28,8 +28,8 @@
28
28
  "templates"
29
29
  ],
30
30
  "devDependencies": {
31
- "@types/node": "^26.6.2",
32
- "vitest": "^5.0.1"
31
+ "@types/node": "^26.6.3",
32
+ "vitest": "^5.0.2"
33
33
  },
34
34
  "scripts": {
35
35
  "build": "tsc",
@@ -1,14 +1,13 @@
1
- # The base image is pinned by digest, not just by tag: `node:24-alpine` is a
2
- # moving pointer, so the same source produced a different image on every
3
- # rebuild. The digest is the multi-arch index, so amd64 and arm64 both resolve
1
+ # The base image is pinned by digest, not just by tag: a tag is a moving
2
+ # pointer, so the same source would build a different image on each rebuild. The digest is the multi-arch index, so amd64 and arm64 both resolve
4
3
  # from it. Dependabot's `docker` ecosystem keeps the pair in step — bump the
5
4
  # tag and the digest together, never the tag alone.
6
- FROM node:26-alpine@sha256:dbaa92e5758cbbcf85d65d5403fdb530fe3442cbe8c6dbfb7ef23365450d5070 AS node-base
5
+ FROM node:26-alpine@sha256:0b36e8c136b94cd4fcf02188228e76c31ad5872eef3fec8cbd2eee500cfd9e80 AS node-base
7
6
 
8
7
  ENV HOME=/home/node
9
8
 
10
- # corepack is pinned too. `npm install -g corepack --force` resolved whatever
11
- # was latest at build time, which is a mutable dependency of every build. The
9
+ # corepack is pinned too. An unpinned `npm install -g corepack` resolves
10
+ # whatever is latest at build time, a mutable dependency of every build. The
12
11
  # pnpm version itself comes from the `packageManager` field in package.json,
13
12
  # which corepack reads.
14
13
  RUN apk add --no-cache tini \
@@ -23,7 +22,7 @@ FROM node-base AS deps
23
22
  # pnpm-lock.yaml is a required build input: `create-auth-policy-verifier`
24
23
  # generates it at scaffold time and the project commits it. Without it this
25
24
  # COPY fails, which is the intended outcome — a build that silently re-resolves
26
- # every dependency range is the thing this file stopped doing.
25
+ # every dependency range is what requiring it prevents.
27
26
  COPY --chown=node:node package.json pnpm-lock.yaml ./
28
27
 
29
28
  USER node
@@ -76,7 +75,7 @@ EXPOSE ${HTTP_PORT}
76
75
 
77
76
  # Probes the container's own routable address, NOT 127.0.0.1 — deliberately.
78
77
  #
79
- # `application.conf` binds loopback by default (#108), because /verify answers
78
+ # `application.conf` binds loopback by default, because /verify answers
80
79
  # with an authorization decision and a reachable port is a decision oracle. A
81
80
  # container that keeps that default is reachable from nothing: the published
82
81
  # port never connects. A probe against 127.0.0.1 passes anyway — inside the
@@ -1,6 +1,6 @@
1
1
  # @o3co/auth-policy-verifier-standalone
2
2
 
3
- 最終更新: 2026-09-23
3
+ 最終更新: 2026-10-01
4
4
 
5
5
  auth.policy-verifier のデプロイ可能なサーバーテンプレートです。このパッケージはコンポジションルートとして機能し、設定の読み込み・モジュールのロード・Express サーバーの起動を担います。`@o3co/create-auth-policy-verifier` によって生成されます。
6
6
 
@@ -77,10 +77,10 @@ OAUTH_JWT_SECRET=$(openssl rand -hex 32) \
77
77
  | `OAUTH_JWT_AUDIENCE_CLAIM` | `aud` | audience を読む claim。`azp`(Clerk)、`client_id`(Cognito)。`aud` 以外を指定すると `aud` は一切見ない |
78
78
  | `OAUTH_AUTHENTICATOR` | `jwt` | subject を確立する token authenticator。他の名前は `main.mts` で module が登録したものでなければならない |
79
79
  | `OAUTH_JWT_MODE` | `verify` | `verify` はトークンを完全検証。明示的な `insecure-decode`(テスト専用)は署名検証なしでデコードする — `exp`/`nbf` は引き続き強制される |
80
- | `RULE_ON_EMPTY_RULE_SET` | `deny` | ルールが 1 つも集まらなかったときの決定(`deny` \| `allow`) |
80
+ | `RULE_ON_EMPTY_RULE_SET` | `deny` | 許可するルールが 1 つも集まらなかったときの決定(`deny` \| `allow`) |
81
81
  | `VERIFY_MAX_BATCH_SIZE` | `50` | `POST /verify/batch` の件数上限 |
82
82
  | `VERIFY_CREDENTIAL_TO_COLLECTORS` | `never` | `expose` は生の資格情報を `context.credential` として collector に渡す — subject として下流 API を呼ぶ collector のためだけの設定。既定では collector は検証済みクレームだけを扱う。資格情報はリプレイ可能であり、context がログに出ればそれが漏れる |
83
- | `VERIFY_EVALUATION_IN_RESPONSE` | `omit` | `include` にすると、policy を背後に持つ各 Rule の `evaluation`(status と、評価した policy revision。#244)を決定の response に載せる。どの revision が操作を許可したかを記録する呼び出し側サービス向け。`decision` ログイベントにはどちらの設定でも載る。受理される token の保持者全員に「policy set がいつ変わったか」を伝えることになるので、それが問題になる環境では `HTTP_CALLER_AUTH_TOKEN` と併用する |
83
+ | `VERIFY_EVALUATION_IN_RESPONSE` | `omit` | `include` にすると、policy を背後に持つ各 Rule の `evaluation`(status、評価した policy revision(#244)、完了した answer ではそれを決めた policy(#199))を決定の response に載せる。どの revision が操作を許可したかを記録する呼び出し側サービス向け。`decision` ログイベントにはどちらの設定でも載る。受理される token の保持者全員に「policy set がいつ変わったか」「各 answer をどの policy が決めたか」を伝えることになるので、それが問題になる環境では `HTTP_CALLER_AUTH_TOKEN` と併用する |
84
84
  | `LOG_LEVEL` | `info` | 出力する最低レベル: `trace`\|`debug`\|`info`\|`warn`\|`error`\|`fatal`\|`silent`。decision ログのスイッチも兼ねる — [可観測性](#可観測性)を参照 |
85
85
 
86
86
  ## HS256 シークレットの強度
@@ -157,7 +157,7 @@ Authorization: Bearer <jwt>
157
157
  | `resource` / `action` | 呼び出し元が送ったそのままの値 |
158
158
  | `decision` | `allow` または `deny` |
159
159
  | `code` | deny のみ — 呼び出し元がワイヤ上で受け取ったコードと同じ |
160
- | `satisfiedBy` | allow のみ — 各グループを満たしたルールの `{ruleType, code}` |
160
+ | `satisfiedBy` | allow のみ — 許可する各グループを満たしたルールの `{ruleType, code}`。`onEmptyRuleSet = "allow"` が通したときは空 |
161
161
  | `deniedBy` | deny のみ — 最初に失敗したグループと、そこで拒否した全代替ルール |
162
162
  | `requestId` | 呼び出し元が送った `x-request-id`(サーバーが受け入れる形のもの。[失敗イベント](#失敗イベント) を参照)。無い場合は省略される |
163
163
  | `durationMs` | Collector パイプラインと evaluator に費やした時間。HTTP の往復時間ではない |
@@ -193,11 +193,11 @@ Authorization: Bearer <jwt>
193
193
  | `collector_threw` | コレクターが reject / throw した |
194
194
  | `attribute_conflict` | 2 つの attribute コレクターが同じスカラーキーに異なる値を書いた |
195
195
  | `rule_timeout` | 非同期ルールが予算を、またはルールフェーズがデッドラインを超えた |
196
- | `rule_threw` | ルールの `verify` が throw した、または `decide` が reject した |
196
+ | `rule_threw` | ルールの `verify` が throw した、`decide` が reject した、またはその `ruleType` のグループに絞るルールと許可するルールが混在した |
197
197
  | `body_rejected` | JSON body parser が、deny エンベロープが 4xx に対応付けていない形で失敗した |
198
198
  | `internal` | decision 自身の collect や評価から出てきたのではないもの — throw した resource parser や authenticator(何を throw したかによらず) |
199
199
 
200
- この集合は閉じています: アラートとフィルタは `err.message` ではなく `category` の等値に対して張ってください。`collector` は `config/application.conf` の `attribute.collectors` / `rule.collectors` 内のエントリ位置とクラス名です。JWKS に到達できないことは category ではありません — `401` で応答され、`jwt_verification_unavailable` としてログに出ます(後述)。
200
+ この集合は閉じています: アラートとフィルタは `err.message` ではなく `category` の等値に対して張ってください。`collector` は `config/application.conf` の `attribute.collectors` / `rule.collectors` 内のエントリ位置とクラス名です。JWKS に到達できないことは category ではありません — 判定の前に `503 verification_unavailable` で応答され、`jwt_verification_unavailable` としてログに出ます(後述)。
201
201
 
202
202
  サーバーがこれらの行に加えるものは、トークン・クレーム・`context` のいずれも含みません。collector はその decision についてコレクターのランナーが記録したもので、エラーから読んだ名前ではなく、何も記録されていないタイムアウトは `collector: "unattributed"` です。ルールの `ruleType` と `code` はルールコレクターがリクエストごとにルールを組み立てうるため、識別子の形(英字で始まり、英数字・`_`・`.`・`-` が続く 64 文字以内)の場合だけ載り、それ以外は `redacted` になります。リクエスト ID は検証済みです。`err` は throw されたエラーそのものですが、例外が 1 つあります: core が定義する 3 つの deny エラー(`CollectorTimeoutError`、`RuleTimeoutError`、`AttributeConflictError`)はメッセージと自身のフィールドにコレクター・ルール・属性キーを含むため、分類されたコレクターやルール、同じ識別子の形に制限した属性キー、ヘッダ行だけの stack で組み立て直したものを記録します。それ以外のエラーのメッセージは、その作者の責任です。
203
203
 
@@ -251,7 +251,7 @@ scrape_configs:
251
251
 
252
252
  `HTTP_PATH_PREFIX` は他のエンドポイントと同様にこのパスも動かします。`HTTP_PATH_PREFIX=/pdp` なら `/pdp/metrics` になり、scrape config の `metrics_path` も合わせる必要があります。
253
253
 
254
- **まだ公開していないもの:** 依存先ごとの `up` ゲージ(auth.provider はバックエンドストア向けに持っています)。ここでの相当物は JWKS エンドポイントですが、サンプリング対象になる readiness probe のレジストリがありません。依存先の一覧を別途手で持つゲージは、auth.provider が probe をサンプリングすることで避けた drift そのものです。そのレジストリができるまで、JWKS 障害は `jwt_verification_unavailable` ログイベントとして見えます — アラートを張れるようにこれは `error` で出力されています。
254
+ **まだ公開していないもの:** 依存先ごとの `up` ゲージ(auth.provider はバックエンドストア向けに持っています)。ここでの相当物は JWKS エンドポイントですが、サンプリング対象になる readiness probe のレジストリがありません。依存先の一覧を別途手で持つゲージは、auth.provider が probe をサンプリングすることで避けた drift そのものです。そのレジストリができるまで、JWKS 障害は `jwt_verification_unavailable` ログイベントとして見えます — アラートを張れるようにこれは `error` で出力されています。HTTP リクエストのヒストグラムにも `503` の応答として現れます。
255
255
 
256
256
  ## デフォルトコレクター
257
257
 
@@ -289,7 +289,7 @@ resource parser を 1 つ(`DotNotationResourceParser`)登録します。各
289
289
  リクエスト対象の resource と action に対して `<action>:<resourceType>` を bearer
290
290
  トークンが持っているときにちょうど許可されます。スコープを発行する IdP があれば
291
291
  認可ストアを別途立てなくてもそのまま機能し、かつ fail-closed です — スコープを
292
- 持たないトークンや誤ったスコープは拒否され、ルールが 1 つも集まらなかった
292
+ 持たないトークンや誤ったスコープは拒否され、許可するルールが 1 つも集まらなかった
293
293
  リクエストも拒否されます(`rule.onEmptyRuleSet = "deny"`)。
294
294
 
295
295
  ルールは種類ごとにグループ化され、**すべてのグループが通る必要があります**。
@@ -1,6 +1,6 @@
1
1
  # @o3co/auth-policy-verifier-standalone
2
2
 
3
- Last updated: 2026-09-23
3
+ Last updated: 2026-10-01
4
4
 
5
5
  Deployable server template for auth.policy-verifier. This is the composition root — it reads config, loads modules, and starts the Express server. Generated by `@o3co/create-auth-policy-verifier`.
6
6
 
@@ -79,10 +79,10 @@ Individual values can also be overridden with environment variables.
79
79
  | `OAUTH_JWT_AUDIENCE_CLAIM` | `aud` | Claim the audience is read from: `azp` (Clerk), `client_id` (Cognito). Anything but `aud` means `aud` is not consulted at all |
80
80
  | `OAUTH_AUTHENTICATOR` | `jwt` | Which token authenticator establishes the subject; another name must be registered by a module in `main.mts` |
81
81
  | `OAUTH_JWT_MODE` | `verify` | `verify` fully verifies tokens; the explicit `insecure-decode` (test-only) decodes without signature verification — `exp`/`nbf` are still enforced |
82
- | `RULE_ON_EMPTY_RULE_SET` | `deny` | Decision when no rule is collected (`deny` \| `allow`) |
82
+ | `RULE_ON_EMPTY_RULE_SET` | `deny` | Decision when no granting rule is collected (`deny` \| `allow`) |
83
83
  | `VERIFY_MAX_BATCH_SIZE` | `50` | Cap on `POST /verify/batch` entries |
84
84
  | `VERIFY_CREDENTIAL_TO_COLLECTORS` | `never` | `expose` hands collectors the raw credential as `context.credential` — only for a collector that calls a downstream API as the subject. The default keeps collectors on verified claims; the credential is replayable and a logged context would leak it |
85
- | `VERIFY_EVALUATION_IN_RESPONSE` | `omit` | `include` puts each policy-backed rule's `evaluation` — its status and the policy revision it evaluated (#244) — on the decision response, for a calling service that records which revision authorized an operation. The `decision` log event carries it either way. It tells any holder of an accepted token when the policy set changed, so pair it with `HTTP_CALLER_AUTH_TOKEN` where that matters |
85
+ | `VERIFY_EVALUATION_IN_RESPONSE` | `omit` | `include` puts each policy-backed rule's `evaluation` — its status, the policy revision it evaluated (#244) and, for a completed answer, the policies that determined it (#199) — on the decision response, for a calling service that records which revision authorized an operation. The `decision` log event carries it either way. It tells any holder of an accepted token when the policy set changed and which policies determined each answer, so pair it with `HTTP_CALLER_AUTH_TOKEN` where that matters |
86
86
  | `LOG_LEVEL` | `info` | Minimum level emitted: `trace`\|`debug`\|`info`\|`warn`\|`error`\|`fatal`\|`silent`. Also the switch for the decision log — see [Observability](#observability) |
87
87
 
88
88
  ## HS256 secret strength
@@ -159,7 +159,7 @@ Every decision emits one structured event named `decision`, at `info`:
159
159
  | `resource` / `action` | Exactly as the caller sent them |
160
160
  | `decision` | `allow` or `deny` |
161
161
  | `code` | Deny only — the same code the caller got on the wire |
162
- | `satisfiedBy` | Allow only — the `{ruleType, code}` of the rule that satisfied each group |
162
+ | `satisfiedBy` | Allow only — the `{ruleType, code}` of the rule that satisfied each granting group; empty when `onEmptyRuleSet = "allow"` let the request through |
163
163
  | `deniedBy` | Deny only — the first failing group, and every alternative in it that refused |
164
164
  | `requestId` | `x-request-id` as sent, when the caller sent one the server carries (see [Failure events](#failure-events)). Omitted otherwise |
165
165
  | `durationMs` | Time in the collector pipelines and the evaluator — not the HTTP round trip |
@@ -195,11 +195,11 @@ A decision that could not be made is logged at `error` (level 50), and the line
195
195
  | `collector_threw` | A collector rejected or threw |
196
196
  | `attribute_conflict` | Two attribute collectors wrote different values to one scalar key |
197
197
  | `rule_timeout` | An asynchronous rule overran its budget, or the rule phase its deadline |
198
- | `rule_threw` | A rule's `verify` threw or its `decide` rejected |
198
+ | `rule_threw` | A rule's `verify` threw or its `decide` rejected, or its `ruleType` group mixed restricting and granting rules |
199
199
  | `body_rejected` | The JSON body parser failed in a way the deny envelope does not map to a 4xx |
200
200
  | `internal` | Anything that did not come out of a decision's own collect or evaluation — a resource parser or authenticator that threw, whatever it threw |
201
201
 
202
- The set is closed: alert and filter on `category` by equality, never on `err.message`. `collector` is the entry's position in `attribute.collectors` / `rule.collectors` in `config/application.conf`, plus its class. An unreachable JWKS is not a category — it is answered `401` and logged as `jwt_verification_unavailable` (see below).
202
+ The set is closed: alert and filter on `category` by equality, never on `err.message`. `collector` is the entry's position in `attribute.collectors` / `rule.collectors` in `config/application.conf`, plus its class. An unreachable JWKS is not a category — it is answered `503 verification_unavailable` before any decision, and logged as `jwt_verification_unavailable` (see below).
203
203
 
204
204
  Nothing the server adds to these lines carries the token, the claims or `context`. The collector is the one the collector runner recorded for that decision, never a name read off an error, and a timeout nothing recorded is `collector: "unattributed"`. A rule's `ruleType` and `code` are carried only when identifier-shaped (a letter, then letters, digits, `_`, `.` or `-`, at most 64 characters) and are `redacted` otherwise, because a rule collector may build rules per request. The request id is validated. `err` is the error as thrown, with one exception: the three deny errors core defines (`CollectorTimeoutError`, `RuleTimeoutError`, `AttributeConflictError`) name a collector, a rule or an attribute key in their message and their own fields, so each is logged rebuilt — the classified collector or rule, the key held to the same identifier shape, and a stack of the header line only. Any other error's message is its author's responsibility.
205
205
 
@@ -253,7 +253,7 @@ scrape_configs:
253
253
 
254
254
  `HTTP_PATH_PREFIX` moves the endpoint with everything else: with `HTTP_PATH_PREFIX=/pdp` the path is `/pdp/metrics`, and `metrics_path` in the scrape config has to match.
255
255
 
256
- **Not published yet:** a per-dependency `up` gauge (auth.provider has one for its backing stores). The equivalent here is the JWKS endpoint, and there is no readiness-probe registry to sample — a gauge built from a second, hand-maintained list of dependencies is exactly the drift auth.provider avoided by sampling its probes. Until such a registry exists, a JWKS outage is visible as the `jwt_verification_unavailable` log event, which is emitted at `error` precisely so it can be alerted on.
256
+ **Not published yet:** a per-dependency `up` gauge (auth.provider has one for its backing stores). The equivalent here is the JWKS endpoint, and there is no readiness-probe registry to sample — a gauge built from a second, hand-maintained list of dependencies is exactly the drift auth.provider avoided by sampling its probes. Until such a registry exists, a JWKS outage is visible as the `jwt_verification_unavailable` log event, which is emitted at `error` precisely so it can be alerted on, and as `503` responses on the HTTP request histogram.
257
257
 
258
258
  ## Default Collectors
259
259
 
@@ -293,7 +293,7 @@ else: a request is allowed exactly when the bearer token carries
293
293
  `<action>:<resourceType>` for the resource and action it asks about. It is
294
294
  functional against any issuer that mints scopes, with no authorization store to
295
295
  stand up first, and it is fail-closed — a token with no scope, or the wrong one,
296
- is denied, and so is a request that collects no rule at all
296
+ is denied, and so is a request that collects no granting rule
297
297
  (`rule.onEmptyRuleSet = "deny"`).
298
298
 
299
299
  Rules are grouped by kind and **every group must pass**. A rule collector enabled
@@ -199,7 +199,9 @@ attribute {
199
199
  collectors = [
200
200
  # The token's `scope` claim, split into the list the scope rule reads.
201
201
  # `claim = "scp"` reads Okta's scope array instead; pair it with the same
202
- # `claim` on ResourceActionScopeRuleCollector below (#219).
202
+ # `claim` on ResourceActionScopeRuleCollector below, which reads that claim
203
+ # to tell whether the token asserted scopes at all — nothing checks that
204
+ # the two names agree.
203
205
  { collector = "PayloadScopeCollector" }
204
206
  # The token's `sub`, which becomes the `subject` on the decision response
205
207
  # and the `sub` field on the audit log line.
@@ -207,13 +209,14 @@ attribute {
207
209
  ]
208
210
  # Deliberately absent: anything supplying permissions or roles. This
209
211
  # deployment authorizes from the token's scopes alone. Adding a permission
210
- # rule means adding its supplier here in the same edit (#113).
212
+ # rule means adding its supplier here in the same edit.
211
213
  }
212
214
 
213
215
  rule {
214
- # Decision for a request that collects no rule at all. "deny" (the default)
215
- # keeps the verifier fail-closed; "allow" is an explicit opt-out and makes
216
- # any request that matches no rule succeed.
216
+ # Decision for a request that collects no granting rule. "deny" (the
217
+ # default) keeps the verifier fail-closed; "allow" is an explicit opt-out and
218
+ # makes any request that collects no granting rule succeed, provided it
219
+ # passes the restricting rules it collected (a delegated token's range, say).
217
220
  onEmptyRuleSet = "deny"
218
221
  onEmptyRuleSet = ${?RULE_ON_EMPTY_RULE_SET}
219
222
 
@@ -221,12 +224,12 @@ rule {
221
224
  # request is allowed exactly when the token carries "<action>:<resourceType>".
222
225
  # It works against any issuer that mints scopes without an authorization store
223
226
  # to stand up first, and it stays fail-closed — a token carrying no scope, or
224
- # the wrong one, is denied, as is a request that collects no rule at all.
227
+ # the wrong one, is denied, as is a request that collects no granting rule.
225
228
  #
226
229
  # Rules are grouped by kind and every GROUP must pass, so one group nothing can
227
230
  # satisfy denies every request the deployment ever sees. That is what enabling
228
231
  # a rule collector without the attribute collector feeding it does, and it is
229
- # why the permission rule below is off rather than merely unconfigured (#113).
232
+ # why the permission rule below is off rather than merely unconfigured.
230
233
  collectors = [
231
234
  # Scope matching is exact and case-sensitive (RFC 6749 §3.3): the token must
232
235
  # carry "<action>:<resourceType>" verbatim. If your issuer emits bare resource
@@ -328,7 +331,7 @@ verify {
328
331
  # the concurrency cap does not spend it.
329
332
  collectorTimeoutMs = 2000
330
333
  collectorTimeoutMs = ${?VERIFY_COLLECTOR_TIMEOUT_MS}
331
- # How long one asynchronous rule (an out-of-process policy engine, #225) may
334
+ # How long one asynchronous rule (an out-of-process policy engine) may
332
335
  # take to answer. Same budget and ceiling as one collector; overrun = deny.
333
336
  ruleTimeoutMs = 2000
334
337
  ruleTimeoutMs = ${?VERIFY_RULE_TIMEOUT_MS}
@@ -361,8 +364,8 @@ verify {
361
364
  batchConcurrency = 8
362
365
  batchConcurrency = ${?VERIFY_BATCH_CONCURRENCY}
363
366
 
364
- # Whether collectors receive the raw credential as `context.credential`
365
- # (#175). "never" (the default): collectors get verified claims only — the
367
+ # Whether collectors receive the raw credential as `context.credential`.
368
+ # "never" (the default): collectors get verified claims only — the
366
369
  # credential is replayable, and a collector that logs its context would leak
367
370
  # a live token. Set "expose" ONLY when a project-side collector calls a
368
371
  # downstream API as the subject (token forwarding / exchange); this line is
@@ -371,13 +374,16 @@ verify {
371
374
  credentialToCollectors = ${?VERIFY_CREDENTIAL_TO_COLLECTORS}
372
375
 
373
376
  # Whether a decision RESPONSE carries what each policy-backed rule reported
374
- # about the evaluation behind its answer — its status, and the policy revision
375
- # it evaluated (#244). The `decision` log event always carries it; this is
376
- # only about the wire. "omit" (the default): the response is key-for-key what
377
- # it was. Set "include" when the calling service records which policy
378
- # revision authorized an operation. It tells any holder of an accepted token
379
- # when the policy set changed and whether a denial was a policy's or the
380
- # engine failing, so pair it with `http.callerAuth` where that matters.
377
+ # about the evaluation behind its answer — its status, the policy revision it
378
+ # evaluated and, for a completed answer, the policies that determined it.
379
+ # The `decision` log event always carries it; this is only about the wire.
380
+ # "omit" (the default): no rule outcome in the response carries an
381
+ # `evaluation` key.
382
+ # Set "include" when the calling service records which policy revision
383
+ # authorized an operation. It tells any holder of an accepted token when the
384
+ # policy set changed, whether a denial was a policy's or the engine failing,
385
+ # and which policies determined each answer, so pair it with
386
+ # `http.callerAuth` where that matters.
381
387
  evaluationInResponse = "omit"
382
388
  evaluationInResponse = ${?VERIFY_EVALUATION_IN_RESPONSE}
383
389
  }
@@ -15,8 +15,9 @@ services:
15
15
  # and it is why HTTP_CALLER_AUTH_TOKEN is worth setting in .env: past the
16
16
  # container boundary, anyone who can reach the port can ask for decisions.
17
17
  - HTTP_HOSTNAME=0.0.0.0
18
- # Where the Cedar http engine finds the `cedar-engine` profile below — read
19
- # only when the app composes cedarPolicyModule without the wasm package.
18
+ # Where the Cedar http engine finds the `cedar-engine` service below
19
+ # (profile `cedar`) — read only when the app composes cedarPolicyModule
20
+ # without the wasm package.
20
21
  # The engine has no default address (a missing one refuses to start,
21
22
  # naming this variable and the wasm package), so it is stated here, as an
22
23
  # interpolation default: an `environment` entry outranks `env_file`, so a
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/auth-policy-verifier-standalone",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "packageManager": "pnpm@10.29.3",
@@ -24,12 +24,12 @@
24
24
  "pino": "^10.3.1"
25
25
  },
26
26
  "devDependencies": {
27
- "@types/node": "^26.6.2",
27
+ "@types/node": "^26.6.3",
28
28
  "@types/supertest": "^7.2.1",
29
29
  "jose": "^6.2.12",
30
- "supertest": "^7.2.2",
30
+ "supertest": "^7.3.0",
31
31
  "tsx": "^4.23.15",
32
32
  "typescript": "^5.9.3",
33
- "vitest": "^5.0.1"
33
+ "vitest": "^5.0.2"
34
34
  }
35
35
  }
@@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest";
6
6
 
7
7
  const compose = readFileSync(new URL("../../docker-compose.yml", import.meta.url), "utf8");
8
8
 
9
- describe("docker-compose.yml — the cedar profile's agent is authenticated (v0.10.0 audit)", () => {
9
+ describe("docker-compose.yml — the cedar profile's agent is authenticated", () => {
10
10
  it("starts the agent with the token the app sends", () => {
11
11
  // An unauthenticated cedar-agent is a write oracle over the policy set:
12
12
  // anything that reaches its port can PUT `permit(principal, action,
@@ -14,8 +14,7 @@ describe("docker-compose.yml — the cedar profile's agent is authenticated (v0.
14
14
  // reads, so the two cannot disagree.
15
15
  expect(compose).toMatch(/- CEDAR_AGENT_AUTHENTICATION=\$\{CEDAR_AUTHENTICATION:-\}/);
16
16
  // And the app from the same interpolation, not only through `env_file`:
17
- // a shell variable or `--env-file` would otherwise reach the agent alone
18
- // (review).
17
+ // a shell variable or `--env-file` would otherwise reach the agent alone.
19
18
  expect(compose).toMatch(/- CEDAR_AUTHENTICATION=\$\{CEDAR_AUTHENTICATION:-\}/);
20
19
  });
21
20
 
@@ -7,9 +7,9 @@
7
7
  * These read the real files under templates/standalone/config/, so they cover
8
8
  * the shipped layering rather than a hand-built object: an env overlay that
9
9
  * contains only comments is an empty HOCON document, and it must fall back to
10
- * application.conf instead of failing to parse. Nothing else in the suite
11
- * exercises parseFile, so a HOCON parser regression is otherwise invisible
12
- * until the container fails to start.
10
+ * application.conf instead of failing to parse. The smoke test boots from the
11
+ * development overlay too; only this file asserts what each shipped overlay
12
+ * resolves to.
13
13
  */
14
14
  import { mkdtempSync, writeFileSync } from "node:fs";
15
15
  import { tmpdir } from "node:os";
@@ -22,7 +22,7 @@ const configDirPath = fileURLToPath(new URL("../../config/", import.meta.url));
22
22
 
23
23
  /**
24
24
  * 64 hex characters — 32 decoded bytes, the entropy floor the schema enforces
25
- * on every HS256 secret (#114). A short value now fails to load at all.
25
+ * on every HS256 secret. A shorter value fails to load.
26
26
  */
27
27
  const TEST_SECRET = "11".repeat(32);
28
28
 
@@ -43,10 +43,7 @@ const envKeys = [
43
43
  "OAUTH_JWT_CLOCK_TOLERANCE_SECONDS",
44
44
  "VERIFY_MAX_BATCH_SIZE",
45
45
  // Every VERIFY_* the cases below set has to be listed, or it leaks into the
46
- // tests after it. Only `VERIFY_MAX_BATCH_SIZE` was here while the #118 cases
47
- // set five more, and the leak was invisible for as long as no later case
48
- // loaded the shipped config and asserted on `verify` — the first one that
49
- // did got a boot failure from an env var a test three screens up had set.
46
+ // tests after it.
50
47
  "VERIFY_MAX_BODY_BYTES",
51
48
  "VERIFY_MAX_RESOURCE_LENGTH",
52
49
  "VERIFY_MAX_ACTION_LENGTH",
@@ -82,7 +79,7 @@ describe("loadAppConfig", () => {
82
79
 
83
80
  const config = loadAppConfig(configDirPath, env);
84
81
 
85
- // Loopback by default (#108): the shipped config is a sidecar config,
82
+ // Loopback by default: the shipped config is a sidecar config,
86
83
  // and reaching the port from another host is an explicit opt-in. The
87
84
  // callerAuth block exists but carries no token, which means the gate is off.
88
85
  expect(config.http).toEqual({
@@ -110,7 +107,7 @@ describe("loadAppConfig", () => {
110
107
  "PayloadScopeCollector",
111
108
  "PayloadSubjectIdCollector",
112
109
  ]);
113
- // Scope-only, and the two lists are one decision (#113). Rule groups are
110
+ // Scope-only, and the two lists are one decision. Rule groups are
114
111
  // ANDed, so a rule reading an attribute no collector above produces is a
115
112
  // group nothing satisfies — which is a verifier that denies every request.
116
113
  // A permission rule may only join this list together with its supplier.
@@ -137,7 +134,7 @@ describe("loadAppConfig", () => {
137
134
  });
138
135
  });
139
136
 
140
- it("enables caller authentication from the environment (#108)", () => {
137
+ it("enables caller authentication from the environment", () => {
141
138
  setRequiredEnv();
142
139
  process.env.HTTP_CALLER_AUTH_TOKEN = "caller-secret";
143
140
 
@@ -165,7 +162,7 @@ describe("loadAppConfig", () => {
165
162
  expect(() => loadAppConfig(configDirPath, "development")).toThrow(/callerAuth[.,\s\S]*token/);
166
163
  });
167
164
 
168
- it("selects insecure-decode mode via OAUTH_JWT_MODE, requiring no key material (#134)", () => {
165
+ it("selects insecure-decode mode via OAUTH_JWT_MODE, requiring no key material", () => {
169
166
  // The value is the consent: only the literal string "insecure-decode"
170
167
  // selects the decode-only path, so a stray boolean-ish env value cannot.
171
168
  process.env.OAUTH_JWT_MODE = "insecure-decode";
@@ -175,7 +172,7 @@ describe("loadAppConfig", () => {
175
172
  expect(config.oauth.jwt?.mode).toBe("insecure-decode");
176
173
  });
177
174
 
178
- it("rejects a boolean-ish OAUTH_JWT_MODE left over from the removed OAUTH_JWT_VALIDATE", () => {
175
+ it('rejects a boolean-ish OAUTH_JWT_MODE such as "false"', () => {
179
176
  setRequiredEnv();
180
177
  process.env.OAUTH_JWT_MODE = "false";
181
178
 
@@ -190,9 +187,9 @@ describe("loadAppConfig", () => {
190
187
 
191
188
  it.each([
192
189
  ["a one-character OAUTH_JWT_SECRET", "s"],
193
- ["the README's old example value", "your-secret"],
190
+ ["a placeholder value", "your-secret"],
194
191
  ["32 hex characters — 16 decoded bytes", "ab".repeat(16)],
195
- ])("refuses to load %s (#114)", (_label, secret) => {
192
+ ])("refuses to load %s", (_label, secret) => {
196
193
  // The floor applies to the shipped config as loaded, not only to
197
194
  // hand-built objects: `secret = ${?OAUTH_JWT_SECRET}` is where a weak
198
195
  // value actually enters a deployment.
@@ -209,7 +206,7 @@ describe("loadAppConfig", () => {
209
206
  });
210
207
  });
211
208
 
212
- describe("loadAppConfig — RFC 9068 requirements (#105)", () => {
209
+ describe("loadAppConfig — RFC 9068 requirements", () => {
213
210
  it("fails to load when the issuer is not supplied", () => {
214
211
  process.env.OAUTH_JWT_SECRET = TEST_SECRET;
215
212
  process.env.OAUTH_JWT_AUDIENCE = "https://api.test";
@@ -225,13 +222,12 @@ describe("loadAppConfig — RFC 9068 requirements (#105)", () => {
225
222
  });
226
223
  });
227
224
 
228
- describe("loadAppConfig — numeric knobs through the real 3-tier resolution (#157)", () => {
229
- // The knobs are read by `resolveBound` at both boundaries now, and this is the
230
- // path that proves the schema half still works where it actually runs: HOCON
225
+ describe("loadAppConfig — numeric knobs through the real 3-tier resolution", () => {
226
+ // The knobs are read by `resolveBound` at both boundaries, and this is the
227
+ // path that proves the schema half works where it actually runs: HOCON
231
228
  // substitutes `${?VAR}` as a STRING, and the ts.hocon zod adapter's coercion
232
229
  // walk only rewrites fields it recognises as numbers. A knob it cannot see
233
- // through must therefore coerce the string itself — which is exactly what
234
- // `resolveBound` does, and what a hand-built config already relied on.
230
+ // through must coerce the string itself, which `resolveBound` does.
235
231
 
236
232
  it("takes the numbers application.conf states when nothing overrides them", () => {
237
233
  setRequiredEnv();
@@ -245,7 +241,7 @@ describe("loadAppConfig — numeric knobs through the real 3-tier resolution (#1
245
241
  expect(config.oauth.jwt?.maxTokenAgeSeconds).toBe(86_400);
246
242
  expect(config.oauth.jwt?.clockToleranceSeconds).toBe(0);
247
243
  expect(config.verify.maxBatchSize).toBe(50);
248
- // The request limits (#118) travel the same path.
244
+ // The request limits travel the same path.
249
245
  expect(config.verify.maxBodyBytes).toBe(65_536);
250
246
  expect(config.verify.maxResourceLength).toBe(512);
251
247
  expect(config.verify.maxActionLength).toBe(64);
@@ -341,21 +337,18 @@ describe("loadAppConfig — numeric knobs through the real 3-tier resolution (#1
341
337
  });
342
338
  });
343
339
 
344
- describe("loadAppConfig — a config with no verify block at all (#115, #118)", () => {
340
+ describe("loadAppConfig — a config with no verify block at all", () => {
345
341
  /*
346
342
  * The shipped `application.conf` writes out every `verify` knob, so loading
347
343
  * it exercises the schema's per-key defaults and never the block-level one.
348
- * The block-level default is what serves the OTHER shape, and it is the
349
- * common one: an overlay config repeats only the sections it changes, and a
350
- * deployment that has no opinion about batch sizes or collector deadlines
351
- * simply never writes a `verify` block.
344
+ * The block-level default serves the common shape: an overlay config that
345
+ * repeats only the sections it changes and never writes a `verify` block.
352
346
  *
353
347
  * zod takes `.default(() => ({…}))` verbatim rather than parsing it back
354
- * through the shape, so a knob missing from that literal is `undefined` for
355
- * exactly this shape — silently, with nothing failing. That is why it is
356
- * proved here through the real loader (`parseFile` → `withFallback` →
357
- * `validate`) rather than inferred from the schema unit tests, which all
358
- * write a `verify` block and would go on passing.
348
+ * through the shape, so a knob missing from that literal is silently
349
+ * `undefined` for exactly this shape. The schema unit tests all write a
350
+ * `verify` block, so it is proved here through the real loader
351
+ * (`parseFile` → `withFallback` → `validate`).
359
352
  */
360
353
  const withoutVerify = (): string => {
361
354
  const dir = mkdtempSync(path.join(tmpdir(), "policy-verifier-config-"));
@@ -2,14 +2,11 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
4
  /*
5
- * The composition root's logger (#107).
6
- *
7
- * The template previously created its logger through `@o3co/auth.utils`, whose
8
- * shape cannot satisfy the `Logger` port `createApp` accepts — so nothing the
9
- * template configured ever reached the server's failure events. `createAppLogger`
10
- * returns a pino instance (newline-delimited JSON on stdout, the shape every
11
- * log aggregator ingests without a parser), which satisfies the port
12
- * structurally and honours `logging.level` from the application config.
5
+ * The composition root's logger. `createAppLogger` returns a pino instance
6
+ * (newline-delimited JSON on stdout, which log aggregators ingest without a
7
+ * parser) that satisfies the `Logger` port `createApp` accepts structurally,
8
+ * so the server's failure events reach it, and that honours `logging.level`
9
+ * from the application config.
13
10
  */
14
11
  import type { Logger } from "@o3co/auth.policy-verifier.core";
15
12
  import { AppConfigSchema } from "@o3co/auth.policy-verifier.server";
@@ -2,20 +2,10 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
4
  /**
5
- * Shutdown was delegated to `@o3co/auth.utils@0.0.4`, whose guarantees no
6
- * contract in this repository pinned.
7
- *
8
- * Reading its 22 lines answered the question and the answer was the reason to
9
- * move it: **there was no force-close deadline**. `server.close()` waits for
10
- * in-flight requests indefinitely, so one stuck decision meant the process
11
- * never exited on its own and the orchestrator's SIGKILL took it down
12
- * mid-flight. Its cleanup-failure path also wrote to `console.error`, a bare
13
- * line in a composition root whose every other line is NDJSON (#107), and
14
- * every exit was zero.
15
- *
16
- * The behaviour lives in the template now, with a deadline and the app's own
17
- * logger, and these tests are the contract that was missing. `auth.provider`'s
18
- * standalone template made the same move in its issue #290.
5
+ * The contract of the template's graceful shutdown (`shutdown.ts`): a
6
+ * force-close deadline, since `server.close()` waits for in-flight requests
7
+ * indefinitely; failures logged through the app's own logger; and a non-zero
8
+ * exit whenever the shutdown was not clean.
19
9
  */
20
10
  import type { Server } from "node:http";
21
11
  import type { Logger } from "@o3co/auth.policy-verifier.core";
@@ -190,9 +180,9 @@ describe("installGracefulShutdown", () => {
190
180
  });
191
181
 
192
182
  it("bounds cleanup so a hanging dispose cannot wedge the process", async () => {
193
- // `finish` awaited cleanup with no deadline, and the drain deadline was
194
- // already cleared by then, so a dispose that never settled meant `exit`
195
- // was never reached — the wedge the deadline was added to remove.
183
+ // The drain deadline is already cleared when cleanup runs, so only the
184
+ // cleanup budget stands between a dispose that never settles and an
185
+ // `exit` that is never reached.
196
186
  vi.useFakeTimers();
197
187
  try {
198
188
  const { signals, finishDraining, exit, logger } = install({
@@ -228,7 +218,7 @@ describe("installGracefulShutdown", () => {
228
218
  }
229
219
  });
230
220
 
231
- it("defers the real exit a turn so pino's buffered destination can flush (#210 review)", async () => {
221
+ it("defers the real exit a turn so pino's buffered destination can flush", async () => {
232
222
  // `createAppLogger` uses pino's default (non-synchronous) destination, so
233
223
  // exiting in the same tick as the last `logger.error` can drop exactly the
234
224
  // `cleanup failed` line an operator would go looking for.
@@ -240,9 +230,9 @@ describe("installGracefulShutdown", () => {
240
230
  });
241
231
 
242
232
  it("reports the cleanup outcome as the reason, not the drain that preceded it", async () => {
243
- // `exitCode` became 1 while `reason` still said "drained", so the one line
244
- // an operator alerts on contradicted itself. The drain outcome is still
245
- // carried, under its own key, so neither fact is lost.
233
+ // `reason` names what decided the exit code, so the line an operator
234
+ // alerts on never says "drained" beside a non-zero code. The drain
235
+ // outcome is carried under its own key.
246
236
  const { signals, finishDraining, logger, exit } = install({
247
237
  cleanup: () => Promise.reject(new Error("teardown failed")),
248
238
  });
@@ -6,18 +6,12 @@
6
6
  *
7
7
  * These tests boot the app from `config/application.conf` — the file the image
8
8
  * ships and an operator deploys — read through `loadAppConfig`, the same
9
- * function `main.mts` calls. Nothing here restates the policy: the thing under
10
- * test is the thing that ships.
11
- *
12
- * That is the point of #113. The suite used to assemble its own config, minus
13
- * the one collector that made the shipped file deny every request, and stayed
14
- * green while the product was non-functional. A config the tests hand-write can
15
- * agree with `application.conf` only by vigilance, and it stopped agreeing.
9
+ * function `main.mts` calls. Nothing here restates the policy: a config the
10
+ * tests hand-wrote could disagree with the shipped file and stay green.
16
11
  *
17
12
  * The three values the file deliberately does not carry — the HS256 secret, the
18
13
  * issuer and the audience — come from the environment here exactly as they do
19
- * in a deployment. A credential must never be written in a config file, so
20
- * supplying them is loading the shipped config, not editing it.
14
+ * in a deployment: a credential is never written in a config file.
21
15
  */
22
16
  import { fileURLToPath } from "node:url";
23
17
  import { builtinCollectorsModule } from "@o3co/auth.policy-verifier.builtins";
@@ -29,7 +23,7 @@ import request from "supertest";
29
23
  import { describe, expect, it } from "vitest";
30
24
  import { loadAppConfig } from "../loadConfig.js";
31
25
 
32
- /** 64 hex characters — 32 decoded bytes, the entropy floor #114 enforces. */
26
+ /** 64 hex characters — 32 decoded bytes, the entropy floor the config schema enforces. */
33
27
  const JWT_SECRET = "11".repeat(32);
34
28
  const secretKey = new TextEncoder().encode(JWT_SECRET);
35
29
  const ISSUER = "https://issuer.test";
@@ -42,7 +36,7 @@ async function signToken(payload: Record<string, unknown>): Promise<string> {
42
36
  new SignJWT(payload)
43
37
  .setProtectedHeader({ alg: "HS256", typ: "at+jwt" })
44
38
  .setIssuedAt()
45
- // iat and exp are both mandatory now (#110).
39
+ // iat and exp are both mandatory.
46
40
  .setExpirationTime("1h")
47
41
  .setIssuer(ISSUER)
48
42
  .setAudience(AUDIENCE)
@@ -62,17 +56,12 @@ const DEPLOYMENT_ENV = {
62
56
  * overlay resolved against `config/application.conf`, with the deployment's
63
57
  * three values in the environment.
64
58
  *
65
- * `process.env` is process-wide, and a test file does not own it. So each
66
- * variable's prior value is **restored**, not deleted — and "was absent" is
67
- * restored as absent rather than as `""`, which is a different state this very
68
- * config distinguishes (an exported-empty credential is refused rather than read
69
- * as "unset"). Deleting unconditionally would hand any file sharing the worker a
70
- * value this one happened to clear.
71
- *
72
- * The window is nil as well as tidy: every step here is synchronous —
73
- * `loadAppConfig` parses and validates without awaiting — and this runs at module
74
- * scope, so no other file's code can be scheduled between the assignment and the
75
- * restore.
59
+ * `process.env` is process-wide, so each variable's prior value is
60
+ * **restored**, not deleted — and "was absent" is restored as absent rather
61
+ * than as `""`, a different state this config distinguishes (an exported-empty
62
+ * credential is refused rather than read as "unset"). Every step is synchronous
63
+ * and this runs at module scope, so no other file's code can run between the
64
+ * assignment and the restore.
76
65
  */
77
66
  function loadShippedConfig(): AppConfig {
78
67
  const saved: Array<[key: string, previous: string | undefined]> = Object.keys(DEPLOYMENT_ENV).map(
@@ -103,7 +92,7 @@ function createShippedApp(config: AppConfig = shippedConfig) {
103
92
  describe("standalone smoke", () => {
104
93
  it("GET /_healthcheck returns 200", async () => {
105
94
  // The liveness path the Dockerfile's HEALTHCHECK probes, and the one every
106
- // component of the stack answers on (o3co/auth.provider#293).
95
+ // component of the stack answers on.
107
96
  const app = await createShippedApp();
108
97
 
109
98
  const res = await request(app).get("/_healthcheck");
@@ -111,8 +100,7 @@ describe("standalone smoke", () => {
111
100
  });
112
101
 
113
102
  it("GET /healthcheck returns 200 as a compatibility alias", async () => {
114
- // The path this image probed before; a probe config that still names it
115
- // must keep working.
103
+ // A probe config that names this path must keep working.
116
104
  const app = await createShippedApp();
117
105
 
118
106
  const res = await request(app).get("/healthcheck");
@@ -225,9 +213,9 @@ describe("standalone smoke", () => {
225
213
  it("POST /verify allows a bare scope once allowBareScopeRewrite is configured", async () => {
226
214
  // The one config in this file that is deliberately NOT the shipped one:
227
215
  // it exercises the opt-in application.conf documents in a comment beside
228
- // the collector, which is the only path a deployment has to re-enable the
229
- // old rewrite. It is derived from the shipped config rather than written
230
- // out, so the single key under test is the single thing that differs.
216
+ // the collector, the only path a deployment has to enable the bare-scope
217
+ // rewrite. It is derived from the shipped config rather than written out,
218
+ // so the single key under test is the single thing that differs.
231
219
  const rewritingConfig: AppConfig = {
232
220
  ...shippedConfig,
233
221
  rule: {
@@ -265,8 +253,8 @@ describe("standalone smoke", () => {
265
253
  });
266
254
 
267
255
  /*
268
- * #111. The template is the deployable shape, so these assert what an operator
269
- * of *this* composition actually gets — not what the library can be wired to do.
256
+ * The template is the deployable shape, so these assert what an operator of
257
+ * *this* composition actually gets — not what the library can be wired to do.
270
258
  */
271
259
  describe("standalone observability", () => {
272
260
  it("GET /metrics serves the Prometheus text format with the decision counters", async () => {
@@ -5,20 +5,18 @@ import type { AppConfig } from "@o3co/auth.policy-verifier.server";
5
5
  import { pino, stdSerializers } from "pino";
6
6
 
7
7
  /**
8
- * The composition root's logger, injected into `createApp` so the verify
9
- * router's failure events (`jwt_token_rejected`, `jwt_verification_unavailable`,
10
- * `verify_internal_error`) reach an aggregator-ready sink (#107).
8
+ * The composition root's logger, injected into `createApp` so the failure
9
+ * events of the verify router (`verify_internal_error` and the
10
+ * `collector_timeout` / `rule_timeout` / `attribute_conflict` denies) and of
11
+ * the JWT authenticator `createApp` builds (`jwt_token_rejected`,
12
+ * `jwt_verification_unavailable`) reach an aggregator-ready sink.
11
13
  *
12
- * pino, emitting newline-delimited JSON on stdout — the shape every log
13
- * aggregator ingests without a parser, and the reason the `Logger` port carries
14
- * pino's two-overload call signature. A pino instance therefore satisfies the
15
- * port structurally, with no adapter. pino drops sub-threshold calls before
16
- * formatting, so `logging.level` is honoured at zero cost for the levels it
17
- * excludes.
18
- *
19
- * The same wiring exists in the auth.provider standalone template; keeping the
20
- * two composition roots symmetric is what lets one aggregator pipeline serve
21
- * the whole stack.
14
+ * pino, emitting newline-delimited JSON on stdout, which log aggregators ingest
15
+ * without a parser. The `Logger` port carries pino's two-overload call
16
+ * signature, so a pino instance satisfies it with no adapter. pino drops
17
+ * sub-threshold calls before formatting, so `logging.level` costs nothing for
18
+ * the levels it excludes. The auth.provider standalone template wires the
19
+ * same, so one aggregator pipeline serves the whole stack.
22
20
  */
23
21
  export function createAppLogger(config: AppConfig) {
24
22
  return pino({
@@ -5,27 +5,13 @@ import type { Server } from "node:http";
5
5
  import type { Logger } from "@o3co/auth.policy-verifier.core";
6
6
 
7
7
  /**
8
- * Graceful shutdown for the scaffolded server.
8
+ * Graceful shutdown for the scaffolded server. It lives in the template rather
9
+ * than a dependency so that "does SIGTERM wait for in-flight decisions, and for
10
+ * how long?" is answerable from the code an operator deploys. auth.provider's
11
+ * standalone template carries the same; keeping the two composition roots
12
+ * symmetric lets one aggregator pipeline serve the whole stack.
9
13
  *
10
- * ## Why this is in the template rather than a dependency
11
- *
12
- * It used to be `gracefulShutdown` from `@o3co/auth.utils@0.0.4`. For a
13
- * component every protected call waits on, "does SIGTERM wait for in-flight
14
- * decisions, and for how long?" has to be answerable from the code an operator
15
- * deploys — and it was answerable only by reading a pre-1.0 package no
16
- * contract here pinned. Reading it answered the question, and the answer was
17
- * the reason to move it: **there was no deadline**. `server.close()` waits
18
- * indefinitely, so one stuck decision meant the process never exited on its
19
- * own and the orchestrator's SIGKILL took it down mid-flight — the opposite of
20
- * a graceful shutdown, arriving only under the load that produces a stuck
21
- * request. The cleanup-failure path also wrote to `console.error`, one bare
22
- * line in a composition root that went to the trouble of wiring NDJSON (#107).
23
- *
24
- * `auth.provider`'s standalone template made the same move for the same
25
- * reasons (o3co/auth.provider#290); keeping the two composition roots
26
- * symmetric is what lets one aggregator pipeline serve the whole stack.
27
- *
28
- * ## The guarantees, stated
14
+ * The guarantees:
29
15
  *
30
16
  * 1. **SIGTERM and SIGINT** both start it; a second signal is ignored rather
31
17
  * than starting a second cleanup over the first one's work.
@@ -33,21 +19,20 @@ import type { Logger } from "@o3co/auth.policy-verifier.core";
33
19
  * sockets are released (`closeIdleConnections`) — they hold the server open
34
20
  * with no request behind them, so a quiet server would otherwise wait out
35
21
  * the whole deadline for nothing.
36
- * 3. **In-flight requests get `drainTimeoutMs`** (default 10s) to finish.
22
+ * 3. **In-flight requests get `drainTimeoutMs`** (default 10s) to finish;
23
+ * `server.close()` alone waits indefinitely.
37
24
  * 4. **Past the deadline, remaining connections are cut**
38
- * (`closeAllConnections`) and the process exits **non-zero** — an
39
- * orchestrator that only ever sees `0` cannot tell a clean drain from one
40
- * that ran out of time.
25
+ * (`closeAllConnections`) and the process exits **non-zero**, so an
26
+ * orchestrator can tell a clean drain from one that ran out of time.
41
27
  * 5. **`cleanup` runs after draining, before exit**, logged through the app
42
28
  * logger and reflected in the exit code. It is bounded by
43
29
  * `cleanupTimeoutMs`, so a dispose that never settles cannot wedge the
44
- * process the way an unbounded `await` did.
30
+ * process.
45
31
  * 6. **A `close` that fails is not reported as a clean drain.**
46
32
  *
47
33
  * Size `drainTimeoutMs` **below** the orchestrator's own kill grace period
48
34
  * (Kubernetes `terminationGracePeriodSeconds`, compose `stop_grace_period`,
49
- * both 30s by default) — the point is to close on our terms before SIGKILL
50
- * arrives on someone else's.
35
+ * both 30s by default), so the process closes on its own terms before SIGKILL.
51
36
  */
52
37
  export interface GracefulShutdownOptions {
53
38
  readonly logger: Logger;
@@ -1,5 +1,5 @@
1
1
  {
2
- "@o3co/auth.policy-verifier.core": "0.14.0",
3
- "@o3co/auth.policy-verifier.builtins": "0.14.0",
4
- "@o3co/auth.policy-verifier.server": "0.14.0"
2
+ "@o3co/auth.policy-verifier.core": "0.16.0",
3
+ "@o3co/auth.policy-verifier.builtins": "0.16.0",
4
+ "@o3co/auth.policy-verifier.server": "0.16.0"
5
5
  }