@o3co/create-auth-provider 0.10.0 → 0.12.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.
Files changed (28) hide show
  1. package/dist/index.d.mts.map +1 -1
  2. package/dist/index.mjs +11 -1
  3. package/package.json +2 -2
  4. package/templates/standalone/.env.example +19 -1
  5. package/templates/standalone/Dockerfile +9 -1
  6. package/templates/standalone/README.ja.md +24 -4
  7. package/templates/standalone/README.md +175 -21
  8. package/templates/standalone/config/application.conf +28 -0
  9. package/templates/standalone/config/clients.yaml.example +15 -2
  10. package/templates/standalone/config/production.conf +9 -3
  11. package/templates/standalone/docker-compose.production.yml +80 -5
  12. package/templates/standalone/docker-compose.yml +24 -0
  13. package/templates/standalone/gitignore +21 -0
  14. package/templates/standalone/package.json +5 -6
  15. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +100 -0
  16. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +71 -4
  17. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +179 -0
  18. package/templates/standalone/src/__tests__/replica-safety.test.mts +456 -0
  19. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +246 -0
  20. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +288 -0
  21. package/templates/standalone/src/__tests__/shutdown.test.mts +324 -0
  22. package/templates/standalone/src/__tests__/smoke.test.mts +36 -0
  23. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +12 -7
  24. package/templates/standalone/src/app.mts +16 -3
  25. package/templates/standalone/src/buildModules.mts +54 -16
  26. package/templates/standalone/src/modules.mts +128 -24
  27. package/templates/standalone/src/shutdown.mts +219 -0
  28. package/templates/versions.json +13 -11
@@ -0,0 +1,324 @@
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 { deferExit, 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("bounds cleanup so a hanging dispose cannot wedge the process", async () => {
238
+ // The guarantees above say cleanup "never wedges the process", but `finish`
239
+ // awaited it with no deadline — and the drain deadline is already cleared
240
+ // by then, so a dispose that never settles meant `exit` was never reached.
241
+ // The same defect was found in auth.proxy#81 and auth.policy-verifier#210,
242
+ // both of which took this file as their starting point.
243
+ vi.useFakeTimers();
244
+ try {
245
+ const { signals, finishDraining, exit, logger } = install({
246
+ cleanup: () => new Promise<void>(() => {}),
247
+ drainTimeoutMs: 5_000,
248
+ });
249
+ signals.get("SIGTERM")?.();
250
+ finishDraining();
251
+ await vi.advanceTimersByTimeAsync(5_000);
252
+ expect(logger.error).toHaveBeenCalledWith(
253
+ expect.objectContaining({ cleanupTimeoutMs: 5_000 }),
254
+ expect.stringContaining("cleanup timed out"),
255
+ );
256
+ expect(exit).toHaveBeenCalledWith(1);
257
+ } finally {
258
+ vi.useRealTimers();
259
+ }
260
+ });
261
+
262
+ it("does not penalise a cleanup that finishes inside its budget", async () => {
263
+ vi.useFakeTimers();
264
+ try {
265
+ const { signals, finishDraining, exit } = install({
266
+ cleanup: () => Promise.resolve(),
267
+ drainTimeoutMs: 5_000,
268
+ });
269
+ signals.get("SIGTERM")?.();
270
+ finishDraining();
271
+ await vi.advanceTimersByTimeAsync(10_000);
272
+ expect(exit).toHaveBeenCalledExactlyOnceWith(0);
273
+ } finally {
274
+ vi.useRealTimers();
275
+ }
276
+ });
277
+
278
+ it("defers the real exit a turn so a buffered log destination can flush", async () => {
279
+ // The scaffolded app logs NDJSON through pino, whose default destination is
280
+ // not synchronous: exiting in the same tick as the last `logger.error` can
281
+ // drop exactly the `cleanup failed` line an operator would look for.
282
+ const exitProcess = vi.fn();
283
+ deferExit(3, exitProcess);
284
+ expect(exitProcess).not.toHaveBeenCalled();
285
+ await new Promise((resolve) => setImmediate(resolve));
286
+ expect(exitProcess).toHaveBeenCalledWith(3);
287
+ });
288
+
289
+ it("reports the cleanup outcome as the reason, not the drain that preceded it", async () => {
290
+ // `exitCode` became 1 while `reason` still said "drained", so the one line
291
+ // an operator alerts on contradicted itself. The drain outcome is still
292
+ // carried, under its own key, so neither fact is lost.
293
+ const { signals, finishDraining, logger, exit } = install({
294
+ cleanup: () => Promise.reject(new Error("teardown failed")),
295
+ });
296
+ signals.get("SIGTERM")?.();
297
+ finishDraining();
298
+ await new Promise((resolve) => setImmediate(resolve));
299
+ expect(logger.info).toHaveBeenCalledWith(
300
+ { reason: "cleanup-failed", drain: "drained", exitCode: 1 },
301
+ "graceful shutdown: complete",
302
+ );
303
+ expect(exit).toHaveBeenCalledWith(1);
304
+ });
305
+
306
+ it("keeps reason and drain identical when cleanup succeeds", async () => {
307
+ const { signals, finishDraining, logger } = install({ cleanup: () => Promise.resolve() });
308
+ signals.get("SIGTERM")?.();
309
+ finishDraining();
310
+ await new Promise((resolve) => setImmediate(resolve));
311
+ expect(logger.info).toHaveBeenCalledWith(
312
+ { reason: "drained", drain: "drained", exitCode: 0 },
313
+ "graceful shutdown: complete",
314
+ );
315
+ });
316
+
317
+ it("removes its own signal listeners once shutting down", async () => {
318
+ // Otherwise a repeated signal keeps re-entering a handler that has
319
+ // already handed the process over to `close`.
320
+ const { signals } = install();
321
+ signals.get("SIGTERM")?.();
322
+ expect(signals.size).toBe(0);
323
+ });
324
+ });
@@ -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
@@ -193,18 +193,23 @@ describe("three-tier HOCON resolution (env → application.conf → reference.co
193
193
  // every in-memory shared store, so the denylist this change adds has to
194
194
  // come out of that environment as the Redis one — from the template's
195
195
  // own config, since the compose file names no denylist variable.
196
- // Asserted against the guard's own list rather than a module name, so a
197
- // rename cannot quietly invalidate it.
198
- const { REPLICA_UNSAFE_MODULES } = await import("@o3co/auth-provider-core");
196
+ // Asked of each manifest, the way the guard does (#455), so a rename
197
+ // cannot quietly invalidate it and the template's own memory modules
198
+ // — which no name list in core covers — count too.
199
+ const { replicaUnsafeReason } = await import("@o3co/auth-provider-core");
199
200
  const config = buildResolvedConfig("production", {
200
201
  USER_SESSION_STORES_ADAPTER: "redis",
201
202
  RATE_LIMITER_ADAPTER: "redis",
202
203
  OAUTH_CODE_ADAPTER: "redis",
204
+ // #456: the federation token store defaults to memory, and the
205
+ // memory module declares itself replica-unsafe (#455).
206
+ FEDERATION_TOKEN_STORE_TYPE: "redis",
207
+ REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
203
208
  });
204
- const names = buildModules(config).map((m) => m.name);
205
- expect(names).toContain("redis-access-token-denylist");
206
- for (const name of names) {
207
- expect(REPLICA_UNSAFE_MODULES).not.toContain(name);
209
+ const modules = buildModules(config);
210
+ expect(modules.map((m) => m.name)).toContain("redis-access-token-denylist");
211
+ for (const module of modules) {
212
+ expect(replicaUnsafeReason(module), module.name).toBeUndefined();
208
213
  }
209
214
  });
210
215
 
@@ -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).
@@ -99,8 +99,12 @@ await (async (): Promise<void> => {
99
99
  // `buildModules` is the single source of truth for the module list — it
100
100
  // gates federation modules on `config.federations.<name>.enabled`, which
101
101
  // the standalone scaffold defaults to false.
102
+ //
103
+ // `environment` is the name the config above was selected by (#473): the
104
+ // Redis federation-token store's plaintext guard reads it in addition to
105
+ // NODE_ENV, so `CONFIG_ENV=production` is production to the guard too.
102
106
  const handle = await createApp({
103
- modules: buildModules(config),
107
+ modules: buildModules(config, { environment: env }),
104
108
  bootstrapComponents: {
105
109
  config,
106
110
  pathResolver: import.meta.resolve,
@@ -154,5 +158,14 @@ await (async (): Promise<void> => {
154
158
  // Step 6: Graceful shutdown — handle.dispose() runs reverse-topological
155
159
  // per-component cleanup (per A2-β §8.1) plus D-5 LifecycleRegistrar drain
156
160
  // (Redis clients, interval timers).
157
- gracefulShutdown(server, () => handle.dispose());
161
+ //
162
+ // #290: in the template rather than a dependency, because "does SIGTERM
163
+ // wait for in-flight requests, and for how long?" has to be answerable
164
+ // from the code you deploy. See `shutdown.mts` for the stated guarantees
165
+ // and for the deadline the previous implementation did not have. Size
166
+ // `drainTimeoutMs` below your orchestrator's kill grace period.
167
+ installGracefulShutdown(server, {
168
+ logger,
169
+ cleanup: () => handle.dispose(),
170
+ });
158
171
  })();
@@ -31,16 +31,17 @@ import {
31
31
  import {
32
32
  redisAccessTokenDenylistModule,
33
33
  redisCodeRepositoryModule,
34
+ redisFederationTokenStoreModuleFor,
34
35
  redisRateLimiterModule,
35
36
  redisRefreshTokenFamilyStoreModule,
36
37
  redisSessionStoresModule,
37
38
  } from "@o3co/auth-provider-redis";
38
- import { sessionModule, sessionStoreModule } from "@o3co/auth-provider-session";
39
+ import { sessionModule, sessionStoreModuleFor } from "@o3co/auth-provider-session";
39
40
  import {
40
41
  auditSinkModule,
41
- federationTokenStoreModule,
42
42
  googleFederationConfigModule,
43
43
  inMemoryCodeRepositoryModule,
44
+ inMemoryFederationTokenStoreModule,
44
45
  inMemorySessionStoresModule,
45
46
  keyStoreModule,
46
47
  repositoriesModule,
@@ -48,11 +49,21 @@ import {
48
49
  } from "./modules.mjs";
49
50
 
50
51
  /**
51
- * Test-only overrides allowing the smoke test to substitute in-memory
52
- * implementations of the file-system-backed modules. Production callers
53
- * should not pass overrides; the defaults match the standalone scaffold.
52
+ * Overrides for the composition. All but `environment` are test-only: they let
53
+ * the smoke test substitute in-memory implementations of the file-system-backed
54
+ * modules, and production callers should not pass them — the defaults match the
55
+ * standalone scaffold.
54
56
  */
55
57
  export interface BuildModulesOverrides {
58
+ /**
59
+ * #473: the name this deployment selected its configuration by —
60
+ * `CONFIG_ENV || NODE_ENV`, computed once in `app.mts` and passed here so
61
+ * the Redis federation-token store's `allow-plaintext` guard reads the
62
+ * environment the config actually came from, not `NODE_ENV` alone. Omitted,
63
+ * the guard falls back to `NODE_ENV` (and `deployment.mode`, which it reads
64
+ * off the config either way).
65
+ */
66
+ readonly environment?: string;
56
67
  readonly keyStoreModule?: Module;
57
68
  readonly repositoriesModule?: Module;
58
69
  readonly storesModule?: Module;
@@ -117,6 +128,12 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
117
128
  // endpoint would answer 200 while the token kept working. The switch here is
118
129
  // only over WHICH denylist.
119
130
  const accessTokenDenylistAdapter = config.accessTokenDenylist?.adapter ?? "memory";
131
+ // #456: adapter switch for the federation token store. `"memory"` by
132
+ // default, the template's local-dev shape; `"redis"` mounts
133
+ // `redisFederationTokenStoreModule` off the shared socket — what the README
134
+ // promised and what the previous factory-based module never delivered: it
135
+ // built the Redis store without a client and failed at boot.
136
+ const federationTokenStoreAdapter = config.federationTokenStore?.type ?? "memory";
120
137
 
121
138
  // OR-9: effective code-repo adapter. `oauth.code.adapter` is the
122
139
  // authoritative switch; the legacy `repositories.code.type = "redis"`
@@ -154,12 +171,14 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
154
171
  rateLimiterAdapter === "redis" ||
155
172
  userSessionStoresAdapter === "redis" ||
156
173
  codeRepositoryAdapter === "redis" ||
157
- accessTokenDenylistAdapter === "redis";
174
+ accessTokenDenylistAdapter === "redis" ||
175
+ federationTokenStoreAdapter === "redis";
158
176
 
159
- // Federation-token store is always wired; only the 4 user-session
160
- // stores switch on the adapter. Pre-Wave-5d the redis branch dropped
161
- // the federation-token-store provider entirely (Copilot review on
162
- // PR #121); splitting `storesModule` fixes that boot failure.
177
+ // The four user-session stores switch on `userSessionStores.adapter`; the
178
+ // federation-token store is always wired and switches on its own key
179
+ // below (#456). Pre-Wave-5d the redis branch dropped the
180
+ // federation-token-store provider entirely (Copilot review on PR #121);
181
+ // splitting `storesModule` fixed that boot failure.
163
182
  const sessionStoresModules: Module[] =
164
183
  userSessionStoresAdapter === "redis"
165
184
  ? [redisSessionStoresModule]
@@ -188,13 +207,32 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
188
207
  ? [redisAccessTokenDenylistModule]
189
208
  : [memoryAccessTokenDenylistModule];
190
209
 
210
+ // #455 / #456: mutually-exclusive federation-token-store pair, the same
211
+ // shape as the session stores. One module per adapter, so the memory one
212
+ // can declare `replicaSafety` on its manifest and the Redis one can
213
+ // `require` its client slot. The previous single module chose the adapter
214
+ // at runtime under one name — the replica guard could not tell the two
215
+ // apart, and the Redis branch had no client to hand the builder.
216
+ //
217
+ // #473: the Redis module is built for this composition root so its
218
+ // plaintext guard knows which environment selected the config; it reads
219
+ // `deployment.mode` off the config itself.
220
+ const federationTokenStoreModules: Module[] =
221
+ federationTokenStoreAdapter === "redis"
222
+ ? [redisFederationTokenStoreModuleFor({ environment: overrides.environment })]
223
+ : [inMemoryFederationTokenStoreModule];
224
+
191
225
  return [
192
226
  // D-5: sessionStoreModule wires the express-session middleware into the
193
227
  // boot-planner-managed lifecycle. **Mount order is enforced by this
194
228
  // list position (declarationIndex tie-breaking)** — the module
195
229
  // intentionally has no `before`/`after` clause, so it MUST be listed
196
230
  // ahead of every session-consuming module here. Do not reorder.
197
- sessionStoreModule,
231
+ //
232
+ // #474: built from `config` rather than the static manifest, so that
233
+ // `session.storage.type = "memory"` declares itself replica-unsafe and
234
+ // `deployment.mode = "multi"` refuses it by name like the other stores.
235
+ sessionStoreModuleFor(config),
198
236
  oauthModule({ config }),
199
237
  oauthSessionModule({ config }),
200
238
  oauthAuthorizationModule({ config }),
@@ -220,11 +258,11 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
220
258
  // actually needs Redis. Memory-only deployments skip this so they
221
259
  // don't open an unused socket.
222
260
  ...(usingRedisAnywhere ? [standaloneRedisClientsModule] : []),
223
- // Federation-token store — always wired; independent of the
224
- // `userSessionStores.adapter` switch. Was bundled into `storesModule`
225
- // pre-Wave-5d; split so the redis session-stores branch doesn't
226
- // drop the provider.
227
- federationTokenStoreModule,
261
+ // Federation-token store: redis (multi-replica) or memory (dev). Always
262
+ // wired, independent of the `userSessionStores.adapter` switch — it was
263
+ // bundled into `storesModule` pre-Wave-5d, and the redis session-stores
264
+ // branch dropped the provider.
265
+ ...federationTokenStoreModules,
228
266
  // User-session-store family: redis (multi-replica) or memory (dev).
229
267
  ...sessionStoresModules,
230
268
  // OAuth-endpoint rate limiter: redis (shared counters) or memory.