@o3co/create-auth-provider 0.10.0 → 0.11.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.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../src/index.mts"],"names":[],"mappings":"AAoCA,eAAO,MAAM,kBAAkB,GAAI,MAAM,MAAM,KAAG,OAIjD,CAAC;AAEF,eAAO,MAAM,cAAc,GAAI,MAAM,MAAM,KAAG,OAI7C,CAAC;AAEF,eAAO,MAAM,QAAQ,GAAI,WAAW,MAAM,EAAE,aAAa,MAAM,KAAG,IA8DjE,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;AAqBnD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,gBAAgB,GAAI,WAAW,MAAM,KAAG,cA6BpD,CAAC;AA0DF,eAAO,MAAM,IAAI,QAAO,IAmEvB,CAAC"}
1
+ {"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../src/index.mts"],"names":[],"mappings":"AAoCA,eAAO,MAAM,kBAAkB,GAAI,MAAM,MAAM,KAAG,OAIjD,CAAC;AAEF,eAAO,MAAM,cAAc,GAAI,MAAM,MAAM,KAAG,OAI7C,CAAC;AAEF,eAAO,MAAM,QAAQ,GAAI,WAAW,MAAM,EAAE,aAAa,MAAM,KAAG,IAyEjE,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;AAqBnD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,gBAAgB,GAAI,WAAW,MAAM,KAAG,cA6BpD,CAAC;AA0DF,eAAO,MAAM,IAAI,QAAO,IAmEvB,CAAC"}
package/dist/index.mjs CHANGED
@@ -14,7 +14,7 @@
14
14
  * limitations under the License.
15
15
  */
16
16
  import { spawnSync } from "node:child_process";
17
- import { cpSync, existsSync, readFileSync, writeFileSync } from "node:fs";
17
+ import { cpSync, existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
18
18
  import { dirname, resolve } from "node:path";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { shouldCopyTemplateEntry } from "./internal/template-filter.mjs";
@@ -53,6 +53,16 @@ export const scaffold = (targetDir, projectName) => {
53
53
  recursive: true,
54
54
  filter: (source) => shouldCopyTemplateEntry(source, TEMPLATES_DIR),
55
55
  });
56
+ // #407: npm drops a file literally named `.gitignore` from a published
57
+ // package, so `copy-templates.mjs` stages it dot-less and it is restored
58
+ // here. Without it the first `git add .` in a scaffolded project commits
59
+ // the `.env` and the signing key the README's own setup steps say to
60
+ // create — and it would have shipped that way silently, because the file
61
+ // is present in the repository and only missing from the tarball.
62
+ const stagedGitignore = resolve(targetDir, "gitignore");
63
+ if (existsSync(stagedGitignore)) {
64
+ renameSync(stagedGitignore, resolve(targetDir, ".gitignore"));
65
+ }
56
66
  // Rewrite package.json
57
67
  const pkgPath = resolve(targetDir, "package.json");
58
68
  const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/create-auth-provider",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Scaffold a new o3co/auth.provider project from the standalone template",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/node": "^26.2.0",
32
- "vitest": "^4.1.10"
32
+ "vitest": "^4.1.11"
33
33
  },
34
34
  "scripts": {
35
35
  "build": "tsc",
@@ -40,10 +40,15 @@ SESSION_SECRET=
40
40
  CLIENT_USER_AUTHENTICATE_URL=
41
41
  CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL=
42
42
 
43
- # Redis (code repository + session storage)
43
+ # Redis (code repository + session storage + the shared client socket)
44
44
  # Use redis://redis:6379 when running via docker compose
45
45
  CLIENT_CODE_ENDPOINT_URI=redis://redis:6379
46
46
  SESSION_STORAGE_REDIS_URL=redis://redis:6379
47
+ # #407: this one backs the SHARED ioredis socket -- the refresh-token family
48
+ # store, the access-token denylist and the rate limiter all ride on it. It
49
+ # defaults to redis://localhost:6379, which inside the container is the
50
+ # container, so leaving it unset makes the compose boot dial nothing.
51
+ REFRESH_TOKEN_FAMILY_STORE_REDIS_URL=redis://redis:6379
47
52
 
48
53
  # Development (override for local HTTP).
49
54
  # The default cookie name carries the `__Host-` prefix, which browsers only
@@ -24,7 +24,15 @@ FROM node-base AS deps
24
24
  # it at scaffold time and the project commits it. Without it this COPY fails,
25
25
  # which is the intended outcome — a build that silently re-resolves every
26
26
  # dependency range is the thing this file stopped doing (#289).
27
- COPY --chown=node:node package.json pnpm-lock.yaml ./
27
+ #
28
+ # pnpm-workspace.yaml is required for the same reason and was missing (#407):
29
+ # the scaffolder generates it to carry bcrypt's `onlyBuiltDependencies`
30
+ # allowlist, and pnpm >= 10.29 reads that setting ONLY from this file, in
31
+ # single-package projects too (#360). Installing without it runs
32
+ # allowlist-less, so pnpm silently skips bcrypt's build hook — which costs
33
+ # nothing while an alpine prebuild exists, and re-creates #360 the day one
34
+ # is missing and the native addon has to compile.
35
+ COPY --chown=node:node package.json pnpm-lock.yaml pnpm-workspace.yaml ./
28
36
 
29
37
  USER node
30
38
 
@@ -42,7 +42,7 @@ overlay の値は `application.conf` を上書きします。scaffold には
42
42
  | 変数 | デフォルト | 説明 |
43
43
  |---|---|---|
44
44
  | `HTTP_PORT` | `3000` | サーバーがリッスンするポート |
45
- | `HTTP_TRUST_PROXY` | `false` | リバースプロキシの背後で動作する場合は `true` に設定。**ロードバランサ配下では必須** — 未設定だと `req.ip` が LB のアドレスになり、IP をキーとする全ての rate limit が全ユーザーで 1 バケットを共有する |
45
+ | `HTTP_TRUST_PROXY` | `false` | Express の `trust proxy`。`false` / アドレス・CIDR のリスト (`10.0.0.0/8,loopback`) / ホップ数 (`1`) / `true` のいずれか。**ロードバランサ配下では必須** — 未設定だと `req.ip` が LB のアドレスになり、IP をキーとする全ての rate limit が全ユーザーで 1 バケットを共有する。`true` ではなく**実際のプロキシを名指しする**こと (#292): `true` は「接続してきた相手が誰であれ、その X-Forwarded-For の先頭を信じる」という意味で、プロセスに直接到達できる相手が自分で `req.ip` を選べてしまう。詳細は [Multi-replica deployments](#multi-replica-deployments) |
46
46
 
47
47
  ### OAuth JWT
48
48
 
@@ -114,7 +114,7 @@ openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
114
114
 
115
115
  設定上の注意が 2 点:
116
116
 
117
- - TLS 終端プロキシの背後では `HTTP_TRUST_PROXY=true` を設定する。設定しないと `req.protocol` は `http` のままでブラウザは `Origin: https://…` を送るため、origin 側の判定が全リクエストを拒否する。
117
+ - TLS 終端プロキシの背後では `HTTP_TRUST_PROXY` にそのプロキシのアドレス/CIDR を設定する (例: `HTTP_TRUST_PROXY=10.0.0.0/8`)。設定しないと `req.protocol` は `http` のままでブラウザは `Origin: https://…` を送るため、origin 側の判定が全リクエストを拒否する。`true` は「プロキシ以外が絶対にプロセスへ到達できない」と言い切れる場合にのみ正しい (#292) — 英語版 README も同じ指針。
118
118
  - ログイン UI をプロバイダーと**別 origin** で配信している場合は、その origin を HOCON の `session.csrf.trustedOrigins` に列挙する。`cors.allowedOrigins` は CSRF 信頼を与えなくなった。
119
119
 
120
120
  ### Google フェデレーション
@@ -271,9 +271,21 @@ const handle = await createApp({
271
271
 
272
272
  app.use(handle.router);
273
273
  const server = app.listen(config.http.port);
274
- gracefulShutdown(server, () => handle.dispose());
274
+ installGracefulShutdown(server, { logger, cleanup: () => handle.dispose() });
275
275
  ```
276
276
 
277
+ ### シャットダウンの保証
278
+
279
+ `src/shutdown.mts` は依存パッケージではなく scaffold 側に置いてある (#290)。全ユーザーセッションを終端するコンポーネントについて「SIGTERM は in-flight リクエストを待つのか、待つなら何秒か」は、デプロイするコード自体から答えられる必要があるため。
280
+
281
+ 1. **`SIGTERM` / `SIGINT`** のどちらでも開始する。2 回目のシグナルは無視する(1 回目の dispose に重ねて 2 回目を走らせない)。
282
+ 2. **新規接続は即座に停止**し、idle な keep-alive ソケットは解放する。リクエストを持たないまま server を開いたままにするソケットなので、解放しないと閑散時に deadline を丸ごと待つことになる。
283
+ 3. **in-flight リクエストには `drainTimeoutMs`**(既定 **10 秒**)を与える。
284
+ 4. **deadline を超えたら残接続を切り、プロセスは非ゼロ終了する。** 常に `0` しか見えない orchestrator では、正常な drain と時間切れの強制切断を区別できない。
285
+ 5. **`cleanup` は drain 後・exit 前**に走る(`handle.dispose()` = 逆トポロジカルなコンポーネント cleanup + Redis/タイマーの drain)。失敗はこのサービス自身の logger(他の行と同じ NDJSON)に出し、exit code にも反映する。dispose が throw してもプロセスは終了する。
286
+
287
+ **`drainTimeoutMs` は orchestrator の kill grace period より短く設定すること** — Kubernetes の `terminationGracePeriodSeconds`、compose の `stop_grace_period` はいずれも既定 30 秒。他人の都合の `SIGKILL` が来る前に、自分の都合で閉じるのが目的。
288
+
277
289
  ## npm スクリプト
278
290
 
279
291
  | スクリプト | 説明 |
@@ -460,9 +460,21 @@ const handle = await createApp({
460
460
 
461
461
  app.use(handle.router);
462
462
  const server = app.listen(config.http.port);
463
- gracefulShutdown(server, () => handle.dispose());
463
+ installGracefulShutdown(server, { logger, cleanup: () => handle.dispose() });
464
464
  ```
465
465
 
466
+ ### Shutdown guarantees
467
+
468
+ `src/shutdown.mts` is deliberately part of the scaffold rather than a dependency (#290): for the component that terminates every user session, *"does SIGTERM wait for in-flight requests, and for how long?"* has to be answerable from the code you deploy.
469
+
470
+ 1. **`SIGTERM` and `SIGINT`** both start it. A second signal is ignored rather than starting a second dispose over the first one's stores.
471
+ 2. **New connections stop immediately**, and idle keep-alive sockets are released — they hold the server open with no request behind them, so a quiet server would otherwise wait out the whole deadline for nothing.
472
+ 3. **In-flight requests get `drainTimeoutMs`** (default **10s**) to finish.
473
+ 4. **Past the deadline the remaining connections are cut and the process exits non-zero.** An orchestrator that only ever sees `0` cannot tell a clean drain from one that ran out of time.
474
+ 5. **`cleanup` runs after draining, before exit** — `handle.dispose()`, i.e. reverse-topological component cleanup plus the Redis/timer drain. A failure there is logged through this service's own logger (NDJSON, like every other line) and reflected in the exit code. A dispose that throws still exits; it never wedges the process.
475
+
476
+ **Size `drainTimeoutMs` below your orchestrator's kill grace period** — Kubernetes `terminationGracePeriodSeconds` and compose `stop_grace_period` are both 30s by default. The point is to close on your terms before `SIGKILL` arrives on someone else's.
477
+
466
478
  ## npm Scripts
467
479
 
468
480
  | Script | Description |
@@ -149,6 +149,17 @@ session {
149
149
  sameSite = ${?SESSION_SAME_SITE}
150
150
  domain = null
151
151
  domain = ${?SESSION_DOMAIN}
152
+ # #405: every URL `POST /session/login` may accept as `redirect_to`, matched
153
+ # EXACTLY -- scheme, host, port, path and query all significant. Empty (the
154
+ # default) refuses every `redirect_to`, so leave it alone unless your login
155
+ # page actually posts one.
156
+ #
157
+ # redirectAllowlist = ["https://app.example.com/welcome"]
158
+ #
159
+ # `http://` is accepted for loopback hosts only. With `domain` set above, an
160
+ # entry outside it fails boot rather than being silently ignored. The
161
+ # federation flow has a separate list per provider (see `federations` below).
162
+ redirectAllowlist = []
152
163
  storage {
153
164
  type = "redis"
154
165
  type = ${?SESSION_STORAGE_TYPE}
@@ -7,8 +7,21 @@
7
7
  # - "none": public client — no clientSecret; PKCE/S256 mandatory at /authorize
8
8
  #
9
9
  # Secret format (only for confidential clients — basic / post):
10
- # - Plain text: "my-secret"
11
- # - Bcrypt hash: "$2b$10$..." (generate with: node -e "require('bcrypt').hash('secret',10).then(console.log)")
10
+ # - Bcrypt hash: "$2b$10$..." <-- use this
11
+ # - Plain text: "my-secret" <-- accepted, for local development only
12
+ #
13
+ # Generate a real one:
14
+ # SECRET=$(openssl rand -base64 32)
15
+ # node -e "require('bcrypt').hash(process.argv[1],10).then(console.log)" "$SECRET"
16
+ # Give $SECRET to the client; put the hash in this file.
17
+ #
18
+ # #407: the schema accepts any non-empty string here, unlike `session.secret`
19
+ # and the JWT signing secret, which have carried a 256-bit floor since #282.
20
+ # That is deliberate — this field also holds a bcrypt hash, whose shape those
21
+ # floors would reject — but it means NOTHING stops you from shipping
22
+ # `clientSecret: "changeme"`. A client secret authenticates a confidential
23
+ # client at the token endpoint; treat it as you would any other credential
24
+ # and give it 256 bits of randomness.
12
25
  #
13
26
  # `firstParty` (#267): REQUIRED to be `true` for a client to use `/authorize`.
14
27
  # `/authorize` mints a code as soon as the session is authenticated, with no
@@ -2,9 +2,15 @@
2
2
  # Loaded when CONFIG_ENV (or NODE_ENV) is "production".
3
3
  # Values here override application.conf.
4
4
  #
5
- # Most production-sensitive values (OAUTH_JWT_SECRET, SESSION_SECRET, REDIS URLs,
6
- # federation client secrets, etc.) should be supplied via environment variables —
7
- # see the ${?VAR} substitutions in application.conf.
5
+ # Most production-sensitive values (SESSION_SECRET, the JWT signing key, REDIS
6
+ # URLs, federation client secrets, etc.) should be supplied via environment
7
+ # variables — see the ${?VAR} substitutions in application.conf.
8
+ #
9
+ # #407: this used to name OAUTH_JWT_SECRET as the typical example. That is the
10
+ # HMAC secret, and it stopped being the default when EdDSA became one (#282) —
11
+ # the shipped path is now a key pair delivered as files
12
+ # (OAUTH_JWT_PRIVATE_KEY_PATH / OAUTH_JWT_PUBLIC_KEY_PATH), which
13
+ # docker-compose.production.yml mounts as compose secrets.
8
14
  #
9
15
  # Override entries here only when a value must be fixed per-environment rather
10
16
  # than per-deployment (e.g. feature flags turned on only in production).
@@ -38,7 +38,16 @@ services:
38
38
  target: runtime
39
39
  restart: unless-stopped
40
40
  ports:
41
- - "3000:3000"
41
+ # Loopback-bound on purpose (#407). This file assumes TLS is terminated
42
+ # in front, and the README says to keep `/metrics` off the public
43
+ # listener — a bare "3000:3000" publishes on every interface and
44
+ # contradicts both. A reverse proxy on the same host reaches it here.
45
+ #
46
+ # If your load balancer is on another host, publish it instead
47
+ # (`- "3000:3000"`) and put the access control somewhere real: a
48
+ # security group, a firewall rule, or the proxy's own network. Do not
49
+ # widen this line and call it done.
50
+ - "127.0.0.1:3000:3000"
42
51
  depends_on:
43
52
  redis:
44
53
  condition: service_healthy
@@ -88,6 +97,13 @@ services:
88
97
  RATE_LIMITER_ADAPTER: redis
89
98
 
90
99
  redis:
100
+ # Tag-pinned, not digest-pinned. The Dockerfile argues for digests (#289)
101
+ # and that argument holds here too — a tag is mutable, so two `up`s a month
102
+ # apart can run different Redis builds. Pin the digest for a production
103
+ # deployment you need to reproduce:
104
+ # image: redis:7.2-alpine@sha256:<digest>
105
+ # Left as a tag in the template because a digest goes stale the week it is
106
+ # written and a scaffold that ships a stale one teaches nothing (#407).
91
107
  image: redis:7.2-alpine
92
108
  restart: unless-stopped
93
109
  # No `ports:`, deliberately — the app reaches Redis over the compose
@@ -16,6 +16,13 @@ services:
16
16
  required: false
17
17
 
18
18
  redis:
19
+ # Tag-pinned, not digest-pinned. The Dockerfile argues for digests (#289)
20
+ # and that argument holds here too — a tag is mutable, so two `up`s a month
21
+ # apart can run different Redis builds. Pin the digest for a production
22
+ # deployment you need to reproduce:
23
+ # image: redis:7.2-alpine@sha256:<digest>
24
+ # Left as a tag in the template because a digest goes stale the week it is
25
+ # written and a scaffold that ships a stale one teaches nothing (#407).
19
26
  image: redis:7.2-alpine
20
27
  ports:
21
28
  - "127.0.0.1:6379:6379"
@@ -0,0 +1,21 @@
1
+ # #407: without this file, the first `git add .` in a scaffolded project
2
+ # commits the two things the README's own setup steps tell you to create right
3
+ # here — the `.env` holding SESSION_SECRET, and the signing key pair.
4
+
5
+ # Secrets and per-machine configuration
6
+ .env
7
+ .env.*
8
+ !.env.example
9
+ *.pem
10
+ *.key
11
+ config/clients.yaml
12
+ config/*.local.conf
13
+
14
+ # Build output and dependencies
15
+ node_modules/
16
+ dist/
17
+ coverage/
18
+
19
+ # Editor / OS noise
20
+ .DS_Store
21
+ *.log
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/auth-provider-standalone",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -27,8 +27,7 @@
27
27
  "@o3co/auth-provider-oauth": "workspace:*",
28
28
  "@o3co/auth-provider-redis": "workspace:*",
29
29
  "@o3co/auth-provider-session": "workspace:*",
30
- "@o3co/auth.utils": "^0.0.4",
31
- "@o3co/ts.hocon": "^1.12.0",
30
+ "@o3co/ts.hocon": "^1.13.0",
32
31
  "connect-redis": "^10.0.0",
33
32
  "express": "^5.2.1",
34
33
  "express-session": "^1.19.0",
@@ -36,7 +35,7 @@
36
35
  "ioredis": "^6.0.0",
37
36
  "pino": "^10.3.1",
38
37
  "prom-client": "^15.1.3",
39
- "redis": "^6.2.0"
38
+ "redis": "^6.2.1"
40
39
  },
41
40
  "devDependencies": {
42
41
  "@types/express": "^5.0.6",
@@ -45,7 +44,7 @@
45
44
  "@types/supertest": "^7.2.1",
46
45
  "supertest": "^7.1.0",
47
46
  "tsx": "^4.23.10",
48
- "typescript": "^5.9.3",
49
- "vitest": "^4.1.10"
47
+ "typescript": "^6.0.3",
48
+ "vitest": "^4.1.11"
50
49
  }
51
50
  }
@@ -88,11 +88,15 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
88
88
  OAUTH_OIDC_MODE: "dual",
89
89
  OAUTH_CODE_ADAPTER: "redis",
90
90
  OAUTH_REVOCATION_ACCESS_TOKEN: "denylist",
91
+ OAUTH_REVOCATION_SUBJECT: "unsupported",
91
92
  OAUTH_REQUIRE_EMAIL_VERIFIED: "true",
93
+ OAUTH_REQUIRE_GRANT_TYPE_ALLOWLIST: "true",
94
+ OAUTH_GRANTS_JWT_BEARER_ENABLED: "false",
92
95
  OAUTH_NONCE_MAX_LENGTH: "256",
93
96
  OAUTH_TOKEN_EXCHANGE_MAX_ACTOR_CHAIN_DEPTH: "3",
94
97
  OAUTH_RESOURCE_INDICATOR_ENABLED: "true",
95
98
  OAUTH_TOKEN_BINDING_DISPATCH_POLICY: "intent-explicit",
99
+ OAUTH_TOKEN_BINDING_BIND_CONFIDENTIAL_CLIENT_REFRESH_TOKENS: "true",
96
100
 
97
101
  // --- oauth.grants -------------------------------------------------
98
102
  OAUTH_GRANTS_SESSION_ENABLED: "false",
@@ -0,0 +1,117 @@
1
+ /*
2
+ * Copyright 2026 1o1 Co. Ltd.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /**
18
+ * Issue #407 — scaffold hardening from the v0.10.0 release-cut audit.
19
+ *
20
+ * These pin the parts of the scaffold that are checkable from inside the
21
+ * repository. The scaffold is the artifact an operator actually deploys, and
22
+ * every one of these was invisible until someone read the file: the container
23
+ * install losing an allowlist, the dev compose dialling a Redis that is not
24
+ * there, and one `git add .` committing the signing key.
25
+ */
26
+
27
+ import { existsSync, readFileSync } from "node:fs";
28
+ import { fileURLToPath } from "node:url";
29
+ import { describe, expect, it } from "vitest";
30
+
31
+ const standaloneDir = fileURLToPath(new URL("../..", import.meta.url));
32
+ const read = (rel: string): string => readFileSync(`${standaloneDir}${rel}`, "utf8");
33
+
34
+ describe("#407 — the Dockerfile installs with everything pnpm needs", () => {
35
+ it("copies pnpm-workspace.yaml into the deps stage", () => {
36
+ // `create-auth-provider` generates it to carry the bcrypt
37
+ // `onlyBuiltDependencies` allowlist, because pnpm >= 10.29 reads that
38
+ // setting ONLY from this file — in single-package projects too (#360).
39
+ // A deps stage that copies `package.json` and the lockfile but not this
40
+ // runs allowlist-less, which re-creates #360 the day an alpine prebuild
41
+ // is missing and bcrypt has to compile.
42
+ const dockerfile = read("/Dockerfile");
43
+ const depsStage = dockerfile.slice(
44
+ dockerfile.indexOf("FROM node-base AS deps"),
45
+ dockerfile.indexOf("FROM", dockerfile.indexOf("FROM node-base AS deps") + 1),
46
+ );
47
+ // Matched as a COPY instruction, not as a substring of the stage: the
48
+ // comment above that COPY names the file too, so a substring check
49
+ // would keep passing if the instruction regressed and the comment
50
+ // stayed — which is the failure this test exists to catch.
51
+ const copyLines = depsStage
52
+ .split("\n")
53
+ .filter((line) => /^COPY\b/.test(line.trim()) && !line.trim().startsWith("#"));
54
+ expect(copyLines.some((line) => /\bpnpm-workspace\.yaml\b/.test(line))).toBe(true);
55
+ });
56
+ });
57
+
58
+ describe("#407 — the dev compose can reach every Redis it configures", () => {
59
+ it("sets every *_REDIS_URL the template config reads", () => {
60
+ // The template's application.conf substitutes several Redis URLs, each
61
+ // defaulting to `redis://localhost:6379` — which inside the container is
62
+ // the container itself. `.env.example` is what the compose file loads,
63
+ // so a URL missing from it is a boot that dials nothing.
64
+ const conf = read("/config/application.conf");
65
+ const declared = [...conf.matchAll(/\$\{\?([A-Z0-9_]*REDIS_URL)\}/g)].map((m) => m[1]);
66
+ expect(declared.length).toBeGreaterThan(0);
67
+
68
+ const env = read("/.env.example");
69
+ const missing = [...new Set(declared)].filter(
70
+ (name) => !new RegExp(`^${name}=`, "m").test(env),
71
+ );
72
+ expect(missing).toEqual([]);
73
+ });
74
+ });
75
+
76
+ describe("#407 — the scaffold does not invite committing its own secrets", () => {
77
+ it("ships a .gitignore", () => {
78
+ // Without one, the first `git add .` in a scaffolded project commits
79
+ // `.env` and `jwt-private.pem` — both of which the README tells the
80
+ // operator to create right there.
81
+ expect(existsSync(`${standaloneDir}/.gitignore`)).toBe(true);
82
+ });
83
+
84
+ it("ignores the files the scaffold's own setup steps create", () => {
85
+ const ignored = read("/.gitignore");
86
+ for (const pattern of [".env", "*.pem", "node_modules", "dist"]) {
87
+ expect(ignored).toContain(pattern);
88
+ }
89
+ });
90
+
91
+ it("keeps .env.example tracked — it is the documentation", () => {
92
+ // A bare `.env*` would take the example with it, which is the mistake
93
+ // this pins against.
94
+ expect(read("/.gitignore")).toMatch(/^!\.env\.example$/m);
95
+ });
96
+ });
97
+
98
+ describe("#407 — the production compose matches the topology it documents", () => {
99
+ it("does not publish the app port on every interface", () => {
100
+ // The README says to keep `/metrics` off the public listener and the
101
+ // file assumes TLS is terminated in front, so a bare "3000:3000"
102
+ // contradicts both — it binds 0.0.0.0 on the host.
103
+ const compose = read("/docker-compose.production.yml");
104
+ expect(compose).not.toMatch(/^\s*-\s*"3000:3000"\s*$/m);
105
+ expect(compose).toMatch(/127\.0\.0\.1:3000:3000/);
106
+ });
107
+ });
108
+
109
+ describe("#407 — the two READMEs agree on security advice", () => {
110
+ it("does not recommend HTTP_TRUST_PROXY=true in the Japanese README", () => {
111
+ // #292 made `trust proxy` a CIDR/hop policy precisely because `true`
112
+ // means "believe the leftmost forwarded entry from whoever opened the
113
+ // connection". The English README warns against it; the Japanese one
114
+ // still recommended it, so the two disagreed on which is safe.
115
+ expect(read("/README.ja.md")).not.toMatch(/HTTP_TRUST_PROXY=true/);
116
+ });
117
+ });
@@ -0,0 +1,244 @@
1
+ /*
2
+ * Copyright 2026 1o1 Co. Ltd.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /**
18
+ * Issue #290 — shutdown was delegated to `@o3co/auth.utils@0.0.4`, whose
19
+ * guarantees were not pinned by any contract this repository could check.
20
+ *
21
+ * Reading the 22 lines answered the question the issue asked, and the answer
22
+ * was worth knowing: **there was no force-close deadline**. `server.close()`
23
+ * waits for in-flight requests indefinitely, so one stuck request meant the
24
+ * process never exited on its own and the orchestrator's SIGKILL took it down
25
+ * mid-flight — the opposite of a graceful shutdown, and invisible until it
26
+ * happened. The cleanup-failure path also wrote to `console.error`, a bare
27
+ * line in a service whose every other line is NDJSON.
28
+ *
29
+ * So the behaviour lives here now, with a deadline and the app's own logger,
30
+ * and these tests are the contract the issue said was missing.
31
+ */
32
+
33
+ import type { Server } from "node:http";
34
+ import type { Logger } from "@o3co/auth-provider-core";
35
+ import { describe, expect, it, vi } from "vitest";
36
+ import { installGracefulShutdown } from "../shutdown.mjs";
37
+
38
+ /** A `Server` double whose `close` callback fires only when we say so. */
39
+ function makeServer() {
40
+ let closeCallback: ((err?: Error) => void) | undefined;
41
+ const server = {
42
+ close: vi.fn((cb?: (err?: Error) => void) => {
43
+ closeCallback = cb;
44
+ return server;
45
+ }),
46
+ closeIdleConnections: vi.fn(),
47
+ closeAllConnections: vi.fn(),
48
+ };
49
+ return {
50
+ server: server as unknown as Server,
51
+ spies: server,
52
+ /** Simulate the last in-flight request finishing. */
53
+ finishDraining: () => closeCallback?.(),
54
+ /** Simulate `close` reporting a failure through its callback. */
55
+ failClose: (err: Error) => closeCallback?.(err),
56
+ get drained() {
57
+ return closeCallback !== undefined;
58
+ },
59
+ };
60
+ }
61
+
62
+ /**
63
+ * A `Logger`-shaped spy. Typed rather than cast: `as never` would hide a real
64
+ * mismatch the day the port gains a method, which is the whole reason the
65
+ * shutdown path logs through the app logger instead of `console`.
66
+ */
67
+ const makeLogger = () => {
68
+ const spy = {
69
+ trace: vi.fn(),
70
+ debug: vi.fn(),
71
+ info: vi.fn(),
72
+ warn: vi.fn(),
73
+ error: vi.fn(),
74
+ fatal: vi.fn(),
75
+ // `child` returns a Logger; returning this one keeps a child's calls
76
+ // visible on the same spies, which is what a test wants.
77
+ child: vi.fn(() => spy as unknown as Logger),
78
+ };
79
+ return spy satisfies Logger;
80
+ };
81
+
82
+ /** Drive one shutdown without touching the real `process` or exiting. */
83
+ function install(opts: { cleanup?: () => void | Promise<void>; drainTimeoutMs?: number } = {}) {
84
+ const { server, spies, finishDraining, failClose } = makeServer();
85
+ const logger = makeLogger();
86
+ const exit = vi.fn();
87
+ const signals = new Map<string, () => void>();
88
+
89
+ installGracefulShutdown(server, {
90
+ logger,
91
+ cleanup: opts.cleanup ?? (() => {}),
92
+ ...(opts.drainTimeoutMs === undefined ? {} : { drainTimeoutMs: opts.drainTimeoutMs }),
93
+ exit,
94
+ onSignal: (name, handler) => signals.set(name, handler),
95
+ offSignal: (name) => signals.delete(name),
96
+ });
97
+
98
+ return { spies, logger, exit, signals, finishDraining, failClose };
99
+ }
100
+
101
+ describe("installGracefulShutdown (#290)", () => {
102
+ it("listens for both SIGTERM and SIGINT", async () => {
103
+ const { signals } = install();
104
+ expect([...signals.keys()].sort()).toEqual(["SIGINT", "SIGTERM"]);
105
+ });
106
+
107
+ it("stops accepting connections and releases idle keep-alive sockets", async () => {
108
+ const { spies, signals } = install();
109
+ signals.get("SIGTERM")?.();
110
+ expect(spies.close).toHaveBeenCalled();
111
+ // Idle keep-alive sockets hold the server open with no request behind
112
+ // them; releasing them is what lets a quiet server exit promptly.
113
+ expect(spies.closeIdleConnections).toHaveBeenCalled();
114
+ });
115
+
116
+ it("runs cleanup once draining completes, then exits zero", async () => {
117
+ const cleanup = vi.fn();
118
+ const { exit, signals, finishDraining } = install({ cleanup });
119
+ signals.get("SIGTERM")?.();
120
+ expect(cleanup).not.toHaveBeenCalled();
121
+ finishDraining();
122
+ await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(0));
123
+ expect(cleanup).toHaveBeenCalledTimes(1);
124
+ });
125
+
126
+ it("ignores a second signal instead of running cleanup twice", async () => {
127
+ // An operator pressing ^C twice, or a SIGINT arriving after SIGTERM,
128
+ // must not start a second dispose over the first one's stores.
129
+ const cleanup = vi.fn();
130
+ const { signals, finishDraining, exit } = install({ cleanup });
131
+ signals.get("SIGTERM")?.();
132
+ signals.get("SIGINT")?.();
133
+ finishDraining();
134
+ await vi.waitFor(() => expect(exit).toHaveBeenCalled());
135
+ expect(cleanup).toHaveBeenCalledTimes(1);
136
+ });
137
+
138
+ // The gap the audit was pointing at.
139
+ it("forces the remaining connections closed when draining outruns the deadline", async () => {
140
+ vi.useFakeTimers();
141
+ try {
142
+ const { spies, signals, exit } = install({ drainTimeoutMs: 5_000 });
143
+ signals.get("SIGTERM")?.();
144
+ expect(spies.closeAllConnections).not.toHaveBeenCalled();
145
+ await vi.advanceTimersByTimeAsync(5_000);
146
+ expect(spies.closeAllConnections).toHaveBeenCalled();
147
+ expect(exit).toHaveBeenCalled();
148
+ } finally {
149
+ vi.useRealTimers();
150
+ }
151
+ });
152
+
153
+ it("exits non-zero on a forced close, so the drain outcome is visible", async () => {
154
+ // An orchestrator that only ever sees exit 0 cannot tell a clean drain
155
+ // from one that ran out of time and cut requests off.
156
+ vi.useFakeTimers();
157
+ try {
158
+ const { signals, exit, logger } = install({ drainTimeoutMs: 5_000 });
159
+ signals.get("SIGTERM")?.();
160
+ await vi.advanceTimersByTimeAsync(5_000);
161
+ expect(exit).toHaveBeenCalledWith(1);
162
+ expect(logger.error).toHaveBeenCalled();
163
+ } finally {
164
+ vi.useRealTimers();
165
+ }
166
+ });
167
+
168
+ it("does not force-close a drain that finished in time", async () => {
169
+ vi.useFakeTimers();
170
+ try {
171
+ const { spies, signals, finishDraining, exit } = install({ drainTimeoutMs: 5_000 });
172
+ signals.get("SIGTERM")?.();
173
+ finishDraining();
174
+ await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(0));
175
+ await vi.advanceTimersByTimeAsync(10_000);
176
+ expect(spies.closeAllConnections).not.toHaveBeenCalled();
177
+ expect(exit).toHaveBeenCalledTimes(1);
178
+ } finally {
179
+ vi.useRealTimers();
180
+ }
181
+ });
182
+
183
+ it("reports a cleanup failure through the app logger, not console", async () => {
184
+ // Every other line this service emits is NDJSON through pino; a bare
185
+ // console.error on the shutdown path is the one a log pipeline drops.
186
+ const { logger, exit, signals, finishDraining } = install({
187
+ cleanup: () => {
188
+ throw new Error("redis quit failed");
189
+ },
190
+ });
191
+ signals.get("SIGTERM")?.();
192
+ finishDraining();
193
+ await vi.waitFor(() => expect(exit).toHaveBeenCalled());
194
+ expect(logger.error).toHaveBeenCalledWith(
195
+ expect.objectContaining({ err: expect.any(Error) }),
196
+ expect.stringContaining("cleanup"),
197
+ );
198
+ });
199
+
200
+ it("still exits when cleanup throws — a failed dispose must not wedge the process", async () => {
201
+ const { exit, signals, finishDraining } = install({
202
+ cleanup: async () => {
203
+ throw new Error("dispose failed");
204
+ },
205
+ });
206
+ signals.get("SIGTERM")?.();
207
+ finishDraining();
208
+ await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
209
+ });
210
+
211
+ it("does not report a failed close as a clean drain", async () => {
212
+ // `server.close` reports through its callback -- "Server is not running"
213
+ // is the common one, but any listener teardown failure lands there.
214
+ // Exiting 0 on it would tell an orchestrator the listener came down
215
+ // when it did not.
216
+ const { exit, logger, signals, failClose } = install();
217
+ signals.get("SIGTERM")?.();
218
+ failClose(new Error("Server is not running"));
219
+ await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
220
+ expect(logger.error).toHaveBeenCalledWith(
221
+ expect.objectContaining({ err: expect.any(Error) }),
222
+ expect.stringContaining("close"),
223
+ );
224
+ });
225
+
226
+ it("still runs cleanup when close reports a failure", async () => {
227
+ // The listener failing to come down is no reason to leak the Redis
228
+ // connections behind it.
229
+ const cleanup = vi.fn();
230
+ const { exit, signals, failClose } = install({ cleanup });
231
+ signals.get("SIGTERM")?.();
232
+ failClose(new Error("teardown failed"));
233
+ await vi.waitFor(() => expect(exit).toHaveBeenCalled());
234
+ expect(cleanup).toHaveBeenCalledTimes(1);
235
+ });
236
+
237
+ it("removes its own signal listeners once shutting down", async () => {
238
+ // Otherwise a repeated signal keeps re-entering a handler that has
239
+ // already handed the process over to `close`.
240
+ const { signals } = install();
241
+ signals.get("SIGTERM")?.();
242
+ expect(signals.size).toBe(0);
243
+ });
244
+ });
@@ -469,6 +469,42 @@ describe("standalone smoke test", () => {
469
469
  expect(names).not.toContain("standalone:stores");
470
470
  expect(names).toContain("standalone:redis-clients");
471
471
  });
472
+
473
+ // #321's `redisSessionStoresModule` requires the two subject-level client
474
+ // slots (`subjectSessionIndexClient`, `subjectRevocationClient`) its
475
+ // adapters consume, and the scaffold's shared ioredis module did not
476
+ // provide them — so `userSessionStores.adapter = "redis"`, the
477
+ // template's own multi-replica path, failed stage-1 boot with
478
+ // `missing-required-component`. Nothing here booted that branch: the
479
+ // tests above check which modules are selected, not whether the
480
+ // selection is satisfiable. This pins the invariant the shared clients
481
+ // module exists for: every `*Client` slot any selected module requires
482
+ // is one it provides.
483
+ it("standalone:redis-clients provides every *Client slot the Redis branches require", () => {
484
+ const allRedisConfig = {
485
+ ...config,
486
+ userSessionStores: { adapter: "redis" as const },
487
+ rateLimiter: { adapter: "redis" as const },
488
+ accessTokenDenylist: { adapter: "redis" as const },
489
+ oauth: { ...config.oauth, code: { adapter: "redis" as const } },
490
+ };
491
+ // No `refreshTokenFamilyModules` override: the default is the Redis
492
+ // RT-family store, so `refreshTokenFamilyClient` is part of what the
493
+ // invariant covers. Still hermetic — buildModules opens no socket.
494
+ const modules = buildModules(allRedisConfig, {
495
+ keyStoreModule: testKeyStoreModule,
496
+ repositoriesModule: testRepositoriesModule,
497
+ });
498
+ const clients = modules.find((m) => m.name === "standalone:redis-clients");
499
+ expect(clients).toBeDefined();
500
+ const provided = new Set(Object.keys(clients?.provides ?? {}));
501
+ const requiredClientSlots = new Set(
502
+ modules.flatMap((m) => [...(m.requires ?? [])]).filter((key) => key.endsWith("Client")),
503
+ );
504
+ expect(requiredClientSlots.size).toBeGreaterThan(0);
505
+ const missing = [...requiredClientSlots].filter((key) => !provided.has(key));
506
+ expect(missing).toEqual([]);
507
+ });
472
508
  });
473
509
 
474
510
  // #277: the scaffold used to wire NO access-token denylist, so the
@@ -14,7 +14,6 @@
14
14
  * limitations under the License.
15
15
  */
16
16
  import { fileURLToPath } from "node:url";
17
- import { gracefulShutdown } from "@o3co/auth.utils";
18
17
  import {
19
18
  type AppConfig,
20
19
  AppConfigSchema,
@@ -30,6 +29,7 @@ import { buildModules } from "./buildModules.mjs";
30
29
  import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "./configPath.mjs";
31
30
  import { createAppLogger } from "./logger.mjs";
32
31
  import { createMetrics } from "./metrics.mjs";
32
+ import { installGracefulShutdown } from "./shutdown.mjs";
33
33
  import { createTerminalErrorHandler } from "./terminalError.mjs";
34
34
 
35
35
  // Step 1: Load and validate application config (HOCON → Zod schema).
@@ -154,5 +154,14 @@ await (async (): Promise<void> => {
154
154
  // Step 6: Graceful shutdown — handle.dispose() runs reverse-topological
155
155
  // per-component cleanup (per A2-β §8.1) plus D-5 LifecycleRegistrar drain
156
156
  // (Redis clients, interval timers).
157
- gracefulShutdown(server, () => handle.dispose());
157
+ //
158
+ // #290: in the template rather than a dependency, because "does SIGTERM
159
+ // wait for in-flight requests, and for how long?" has to be answerable
160
+ // from the code you deploy. See `shutdown.mts` for the stated guarantees
161
+ // and for the deadline the previous implementation did not have. Size
162
+ // `drainTimeoutMs` below your orchestrator's kill grace period.
163
+ installGracefulShutdown(server, {
164
+ logger,
165
+ cleanup: () => handle.dispose(),
166
+ });
158
167
  })();
@@ -22,6 +22,8 @@ import {
22
22
  createInMemorySessionFamilyIndex,
23
23
  createInMemorySessionFederationIndex,
24
24
  createInMemorySessionRPRegistry,
25
+ createInMemorySubjectRevocation,
26
+ createInMemorySubjectSessionIndex,
25
27
  createInMemoryUserSessionStore,
26
28
  createKeyStoreFactory,
27
29
  createRepositoryFactories,
@@ -198,6 +200,20 @@ export const inMemorySessionStoresModule: Module = defineModule({
198
200
  sessionRPRegistry: () => createInMemorySessionRPRegistry(),
199
201
  sessionFamilyIndex: () => createInMemorySessionFamilyIndex(),
200
202
  sessionFederationIndex: () => createInMemorySessionFederationIndex(),
203
+ // #406: the two subject-level revocation slots. They were missing here
204
+ // on both branches, so a scaffolded deployment got
205
+ // `subjectRevocation: undefined` — `verifyJwt` skipped the watermark,
206
+ // the #376 refresh gate was inert, and `revokeAllForSubject` reported
207
+ // `unavailable`. Nothing failed and nothing warned, which is what #406
208
+ // is about; the boot guard now refuses that state, and the scaffold's
209
+ // answer is to wire them rather than to declare the capability absent.
210
+ //
211
+ // Single-process only, like every other store on this branch. The
212
+ // redis branch swaps in `redisSessionStoresModule`, which has provided
213
+ // both since #321 — before that there was no distributed adapter to
214
+ // point at, which is why this gap outlived #296.
215
+ subjectSessionIndex: () => createInMemorySubjectSessionIndex(),
216
+ subjectRevocation: () => createInMemorySubjectRevocation(),
201
217
  },
202
218
  });
203
219
 
@@ -304,10 +320,11 @@ export const storesModule: Module = defineModule({
304
320
 
305
321
  /**
306
322
  * Shared ioredis clients module — opens ONE long-lived ioredis connection
307
- * per replica, wraps it via `makeIoredisClients()` (returns 11 typed
323
+ * per replica, wraps it via `makeIoredisClients()` (returns 13 typed
308
324
  * per-purpose clients), and exposes the slots consumed by the standalone's
309
325
  * Redis-backed adapters (refresh-token-family + 4 user-session stores +
310
- * rate limiter + code repository + access-token denylist).
326
+ * subject-level revocation index + watermark + rate limiter + code
327
+ * repository + access-token denylist).
311
328
  *
312
329
  * Per F4 PR1 (D-2 v2) + Wave 5d unification: the previous design opened a
313
330
  * separate ioredis socket per Redis-backed module (3+ sockets per replica).
@@ -407,6 +424,27 @@ export const standaloneRedisClientsModule: Module = defineModule({
407
424
  return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
408
425
  .accessTokenDenylistClient;
409
426
  },
427
+ // #321: the two subject-level revocation client slots, off the same
428
+ // shared socket. `redisSessionStoresModule` requires both — the index
429
+ // enumerates what a credential change cascades over and the watermark
430
+ // refuses what the cascade missed — so without them the scaffold's
431
+ // Redis branch had no provider for the slots and stage-1 boot refused
432
+ // the manifest with `missing-required-component`. Nothing in the
433
+ // standalone consumes these slots directly; they exist so the module
434
+ // that does can be selected.
435
+ subjectSessionIndexClient: async ({
436
+ config,
437
+ lifecycleRegistrar,
438
+ readinessRegistrar,
439
+ logger,
440
+ }) => {
441
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
442
+ .subjectSessionIndexClient;
443
+ },
444
+ subjectRevocationClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
445
+ return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
446
+ .subjectRevocationClient;
447
+ },
410
448
  },
411
449
  });
412
450
 
@@ -0,0 +1,162 @@
1
+ /*
2
+ * Copyright 2026 1o1 Co. Ltd.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import type { Server } from "node:http";
18
+ import type { Logger } from "@o3co/auth-provider-core";
19
+
20
+ /**
21
+ * Graceful shutdown for the scaffolded server (#290).
22
+ *
23
+ * ## Why this is in the template rather than a dependency
24
+ *
25
+ * It used to be `gracefulShutdown` from `@o3co/auth.utils@0.0.4`. The ops
26
+ * review's objection was not that the package is bad — it is that for the
27
+ * component which terminates every user session, "does SIGTERM wait for
28
+ * in-flight requests, and for how long?" has to be answerable from the code an
29
+ * operator deploys, and it was answerable only by reading a pre-1.0 package
30
+ * with no contract pinned here.
31
+ *
32
+ * Reading it answered the question, and the answer was the reason to move it:
33
+ * **there was no deadline**. `server.close()` waits for in-flight requests
34
+ * indefinitely, so a single stuck request meant the process never exited on
35
+ * its own and the orchestrator's SIGKILL took it down mid-flight — the
36
+ * opposite of a graceful shutdown, arriving only under the load that produces
37
+ * a stuck request. The cleanup-failure path also wrote to `console.error`, one
38
+ * bare line in a service whose every other line is NDJSON.
39
+ *
40
+ * Neither is fixed by documenting or pinning the dependency, which is why this
41
+ * is ~40 lines here with tests, rather than a version range and a README
42
+ * paragraph.
43
+ *
44
+ * ## The guarantees, stated
45
+ *
46
+ * 1. **SIGTERM and SIGINT** both start it; the second signal is ignored rather
47
+ * than starting a second dispose over the first one's stores.
48
+ * 2. **New connections stop immediately** (`server.close`), and idle keep-alive
49
+ * sockets are released (`closeIdleConnections`) — they hold the server open
50
+ * with no request behind them, so a quiet server would otherwise wait out
51
+ * the whole deadline for nothing.
52
+ * 3. **In-flight requests are given `drainTimeoutMs`** (default 10s) to finish.
53
+ * 4. **Past the deadline, remaining connections are cut** (`closeAllConnections`)
54
+ * and the process exits **non-zero** — an orchestrator that only ever sees
55
+ * `0` cannot tell a clean drain from one that ran out of time.
56
+ * 5. **`cleanup` runs after draining, before exit**, and its failure is logged
57
+ * through the app logger and reflected in the exit code. It never wedges the
58
+ * process: a dispose that throws still exits.
59
+ * 6. **A `close` that fails is not reported as a clean drain.** `server.close`
60
+ * reports through its callback, and treating that as success would tell an
61
+ * orchestrator the listener came down when it did not.
62
+ *
63
+ * Size `drainTimeoutMs` **below** the orchestrator's own kill grace period
64
+ * (Kubernetes `terminationGracePeriodSeconds`, compose `stop_grace_period`,
65
+ * both 30s by default) — the point is to close on our terms before SIGKILL
66
+ * arrives on someone else's.
67
+ */
68
+ export interface GracefulShutdownOptions {
69
+ readonly logger: Logger;
70
+ /** Reverse-topological component cleanup — normally `handle.dispose()`. */
71
+ readonly cleanup?: () => void | Promise<void>;
72
+ /** How long in-flight requests get before connections are cut. Default 10s. */
73
+ readonly drainTimeoutMs?: number;
74
+ /** Injected in tests; defaults to `process.exit`. */
75
+ readonly exit?: (code: number) => void;
76
+ /** Injected in tests; defaults to `process.on`. */
77
+ readonly onSignal?: (signal: NodeJS.Signals, handler: () => void) => void;
78
+ /** Injected in tests; defaults to `process.removeListener`. */
79
+ readonly offSignal?: (signal: NodeJS.Signals, handler: () => void) => void;
80
+ }
81
+
82
+ const DEFAULT_DRAIN_TIMEOUT_MS = 10_000;
83
+ const SIGNALS: readonly NodeJS.Signals[] = ["SIGTERM", "SIGINT"];
84
+
85
+ export function installGracefulShutdown(server: Server, options: GracefulShutdownOptions): void {
86
+ const {
87
+ logger,
88
+ cleanup,
89
+ drainTimeoutMs = DEFAULT_DRAIN_TIMEOUT_MS,
90
+ exit = (code: number): void => {
91
+ process.exit(code);
92
+ },
93
+ onSignal = (signal, handler): void => {
94
+ process.on(signal, handler);
95
+ },
96
+ offSignal = (signal, handler): void => {
97
+ process.removeListener(signal, handler);
98
+ },
99
+ } = options;
100
+
101
+ let shuttingDown = false;
102
+ let finished = false;
103
+
104
+ /** Run `cleanup` and exit. Called by whichever of drain / deadline wins. */
105
+ const finish = async (code: number, reason: string): Promise<void> => {
106
+ if (finished) return;
107
+ finished = true;
108
+ let exitCode = code;
109
+ try {
110
+ await cleanup?.();
111
+ } catch (err) {
112
+ // Through the app logger, not `console.error`: a shutdown that
113
+ // failed to release its Redis connections is exactly the line an
114
+ // operator needs to find later, and a bare write is the one their
115
+ // pipeline drops.
116
+ logger.error({ err }, "graceful shutdown: cleanup failed");
117
+ exitCode = 1;
118
+ }
119
+ logger.info({ reason, exitCode }, "graceful shutdown: complete");
120
+ exit(exitCode);
121
+ };
122
+
123
+ const handler = (): void => {
124
+ if (shuttingDown) return;
125
+ shuttingDown = true;
126
+ for (const signal of SIGNALS) offSignal(signal, handler);
127
+ logger.info({ drainTimeoutMs }, "graceful shutdown: draining");
128
+
129
+ const deadline = setTimeout(() => {
130
+ logger.error(
131
+ { drainTimeoutMs },
132
+ "graceful shutdown: drain deadline exceeded, closing remaining connections",
133
+ );
134
+ server.closeAllConnections();
135
+ void finish(1, "drain-timeout");
136
+ }, drainTimeoutMs);
137
+ // The deadline must not be what keeps the process alive once the drain
138
+ // has already finished.
139
+ deadline.unref?.();
140
+
141
+ server.close((err) => {
142
+ clearTimeout(deadline);
143
+ if (err) {
144
+ // `close` reports through its callback — "Server is not running"
145
+ // is the common one, but any listener teardown failure lands
146
+ // here. Reporting "drained" and exiting 0 on it would tell an
147
+ // orchestrator the shutdown went cleanly when the listener did
148
+ // not actually come down.
149
+ logger.error({ err }, "graceful shutdown: server close failed");
150
+ void finish(1, "close-failed");
151
+ return;
152
+ }
153
+ void finish(0, "drained");
154
+ });
155
+ // Idle keep-alive sockets have no request behind them, so nothing is
156
+ // lost by releasing them — and without this a quiet server waits out
157
+ // the whole deadline for connections that will never send anything.
158
+ server.closeIdleConnections();
159
+ };
160
+
161
+ for (const signal of SIGNALS) onSignal(signal, handler);
162
+ }
@@ -1,13 +1,14 @@
1
1
  {
2
- "@o3co/auth-provider-core": "0.10.0",
3
- "@o3co/auth-provider-dpop": "0.10.0",
4
- "@o3co/auth-provider-mtls": "0.10.0",
5
- "@o3co/auth-provider-federation-google": "0.10.0",
6
- "@o3co/auth-provider-federation-github": "0.10.0",
7
- "@o3co/auth-provider-oauth": "0.10.0",
8
- "@o3co/auth-provider-oauth-token-exchange": "0.10.0",
9
- "@o3co/auth-provider-session": "0.10.0",
10
- "@o3co/auth-provider-foundation": "0.10.0",
11
- "@o3co/auth-provider-redis": "0.10.0",
12
- "@o3co/auth-provider-webauthn": "0.10.0"
2
+ "@o3co/auth-provider-core": "0.11.0",
3
+ "@o3co/auth-provider-device-grant": "0.11.0",
4
+ "@o3co/auth-provider-dpop": "0.11.0",
5
+ "@o3co/auth-provider-mtls": "0.11.0",
6
+ "@o3co/auth-provider-federation-google": "0.11.0",
7
+ "@o3co/auth-provider-federation-github": "0.11.0",
8
+ "@o3co/auth-provider-oauth": "0.11.0",
9
+ "@o3co/auth-provider-oauth-token-exchange": "0.11.0",
10
+ "@o3co/auth-provider-session": "0.11.0",
11
+ "@o3co/auth-provider-foundation": "0.11.0",
12
+ "@o3co/auth-provider-redis": "0.11.0",
13
+ "@o3co/auth-provider-webauthn": "0.11.0"
13
14
  }