@o3co/create-auth-provider 0.5.1 → 0.5.3

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.
@@ -14,6 +14,7 @@
14
14
  * limitations under the License.
15
15
  */
16
16
 
17
+ import { readFileSync } from "node:fs";
17
18
  import {
18
19
  type AppConfig,
19
20
  createApp,
@@ -21,15 +22,17 @@ import {
21
22
  defineModule,
22
23
  generateToken,
23
24
  InMemoryClientRepository,
24
- InMemoryCodeRepository,
25
25
  InMemoryUserRepository,
26
+ memoryRefreshTokenFamilyStoreModule,
26
27
  registerBuiltinKeyStores,
27
28
  } from "@o3co/auth-provider-core";
28
29
  import express from "express";
29
30
  import request from "supertest";
30
- import { afterEach, describe, expect, it } from "vitest";
31
+ import { afterEach, describe, expect, it, vi } from "vitest";
31
32
  import { buildModules } from "../buildModules.mjs";
32
33
 
34
+ const dockerfile = readFileSync(new URL("../../Dockerfile", import.meta.url), "utf8");
35
+
33
36
  const config: AppConfig = {
34
37
  http: { port: 0, trustProxy: false },
35
38
  oauth: {
@@ -40,16 +43,27 @@ const config: AppConfig = {
40
43
  algorithm: "HS256",
41
44
  secret: "test-secret-for-smoke-test",
42
45
  kid: "v0",
43
- previousKeys: [],
46
+ previousSecrets: [],
44
47
  },
45
48
  },
46
49
  },
47
50
  accessToken: { expiresIn: 3600 },
48
- refreshToken: { expiresIn: 86400 },
51
+ refreshToken: {
52
+ expiresIn: 86400,
53
+ unknownFamilyPolicy: "reject" as const,
54
+ legacyRtPolicy: "reject" as const,
55
+ },
49
56
  grants: {},
57
+ oidcMode: "oidc-required",
58
+ // OR-9: explicit memory adapter for the smoke test. The legacy
59
+ // `repositories.code.type` fallback would also pick memory here,
60
+ // but the explicit setting documents the intent and avoids
61
+ // console.warn deprecation noise in the test output.
62
+ code: { adapter: "memory" as const },
50
63
  },
51
64
  session: {
52
65
  secret: "test-session-secret",
66
+ name: "auth.sid",
53
67
  maxAge: 3600000,
54
68
  secure: false,
55
69
  sameSite: "lax",
@@ -58,6 +72,7 @@ const config: AppConfig = {
58
72
  },
59
73
  rateLimit: {
60
74
  login: { windowMs: 60000, limit: 10 },
75
+ failMode: "open",
61
76
  },
62
77
  federations: {
63
78
  google: { enabled: false },
@@ -69,8 +84,6 @@ const config: AppConfig = {
69
84
  },
70
85
  endpoints: {
71
86
  login: { url: "/login" },
72
- client: { url: undefined },
73
- authCallback: { url: undefined },
74
87
  },
75
88
  cors: { allowedOrigins: [] },
76
89
  };
@@ -79,13 +92,50 @@ const config: AppConfig = {
79
92
  * Test-only repository module — bypasses the file-system-backed repositories
80
93
  * that `repositoriesModule` provides in production. Smoke tests use in-memory
81
94
  * empty repositories to verify the boot pipeline shape, not the data layer.
95
+ *
96
+ * Post-OR-9 (Wave 5d): `codeRepository` is no longer provided here — it is
97
+ * wired by `inMemoryCodeRepositoryModule` (or `redisCodeRepositoryModule`)
98
+ * via the `oauth.code.adapter` switch in `buildModules`. The smoke test
99
+ * config sets `oauth.code.adapter = "memory"`, so `inMemoryCodeRepositoryModule`
100
+ * is added automatically and provides the slot.
101
+ *
102
+ * D-6 (v0.5.1): a single confidential client is registered so the
103
+ * `clientAuthMw` middleware in front of `/oauth/token` has a record to
104
+ * authenticate against. Smoke tests that hit `/oauth/token` send
105
+ * `Authorization: ${SMOKE_BASIC_AUTH}`.
82
106
  */
107
+ const SMOKE_CLIENT_ID = "smoke-client";
108
+ const SMOKE_CLIENT_SECRET = "smoke-secret";
109
+ const SMOKE_BASIC_AUTH = `Basic ${Buffer.from(`${SMOKE_CLIENT_ID}:${SMOKE_CLIENT_SECRET}`).toString("base64")}`;
110
+
83
111
  const testRepositoriesModule = defineModule({
84
112
  name: "test:repositories",
85
113
  provides: {
86
- clientRepository: () => new InMemoryClientRepository(new Map()),
114
+ // `InMemoryClientRepository` constructor parameter `Map<string, ClientEntry>`
115
+ // uses `ClientEntry = z.infer<typeof ClientEntrySchema>` (the schema's
116
+ // OUTPUT type) — fields with `.default(...)` are non-optional in the
117
+ // output even though they are optional on input. The fixture supplies
118
+ // every field explicitly so the literal satisfies the output shape
119
+ // without requiring a cast.
120
+ clientRepository: () =>
121
+ new InMemoryClientRepository(
122
+ new Map([
123
+ [
124
+ SMOKE_CLIENT_ID,
125
+ {
126
+ tokenEndpointAuthMethod: "client_secret_basic",
127
+ clientSecret: SMOKE_CLIENT_SECRET,
128
+ allowedRedirectUris: [],
129
+ allowedScopes: [],
130
+ allowedAudiences: [],
131
+ backchannelLogoutSessionRequired: true,
132
+ frontchannelLogoutSessionRequired: true,
133
+ allowedAzpForFederationToken: false,
134
+ },
135
+ ],
136
+ ]),
137
+ ),
87
138
  userRepository: () => new InMemoryUserRepository(new Map()),
88
- codeRepository: () => new InMemoryCodeRepository(),
89
139
  },
90
140
  });
91
141
 
@@ -112,6 +162,12 @@ describe("standalone smoke test", () => {
112
162
  modules: buildModules(config, {
113
163
  keyStoreModule: testKeyStoreModule,
114
164
  repositoriesModule: testRepositoriesModule,
165
+ // D-2 v2: smoke tests stay in-memory for the RT family store.
166
+ // Production composition root pairs `refreshTokenFamilyClientModule`
167
+ // with `redisRefreshTokenFamilyStoreModule`; this override replaces
168
+ // the pair entirely with a single memory-backed store module so no
169
+ // real ioredis connection is opened during CI unit tests.
170
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
115
171
  }),
116
172
  bootstrapComponents: { config, pathResolver: (s) => s },
117
173
  });
@@ -129,6 +185,36 @@ describe("standalone smoke test", () => {
129
185
  handleRef = undefined;
130
186
  });
131
187
 
188
+ it("Dockerfile declares runtime metadata and runs install/build steps as node", () => {
189
+ // Default port is 3000 but the HEALTHCHECK and EXPOSE must read it
190
+ // from ${HTTP_PORT} so an operator overriding the env var keeps the
191
+ // app, EXPOSE metadata, and healthcheck in sync. Assert the ENV /
192
+ // EXPOSE / HEALTHCHECK structure rather than the literal port so the
193
+ // test is not brittle when operators or downstream forks change the
194
+ // default.
195
+ // The literal `${HTTP_PORT}` token from the Dockerfile is split across
196
+ // concatenations to sidestep biome's `noTemplateCurlyInString` rule
197
+ // (it cannot statically tell that this string is intentionally NOT
198
+ // a JS template placeholder — it is a Dockerfile env-substitution).
199
+ const HTTP_PORT_VAR = `$${"{"}HTTP_PORT}`;
200
+ expect(dockerfile).toContain("ENV HTTP_PORT=3000");
201
+ expect(dockerfile).toContain(`EXPOSE ${HTTP_PORT_VAR}`);
202
+ expect(dockerfile).toMatch(
203
+ /HEALTHCHECK\s+--interval=30s\s+--timeout=3s\s+--start-period=10s\s+--retries=3/,
204
+ );
205
+ // Match the literal `${HTTP_PORT}` token inside the HEALTHCHECK CMD.
206
+ // `[$]` (character class) sidesteps biome's `noTemplateCurlyInString`
207
+ // false-positive without changing what we actually accept.
208
+ expect(dockerfile).toMatch(
209
+ /CMD\s+wget\s+-q\s+-O\s+\/dev\/null\s+"http:\/\/localhost:[$]\{HTTP_PORT\}\/_healthcheck"\s+\|\|\s+exit\s+1/,
210
+ );
211
+ expect(dockerfile).toMatch(/FROM node-base AS deps[\s\S]*USER node[\s\S]*RUN pnpm install/);
212
+ expect(dockerfile).toMatch(/FROM deps AS builder[\s\S]*USER node[\s\S]*RUN pnpm run build/);
213
+ expect(dockerfile).toMatch(
214
+ /FROM node-base AS runtime[\s\S]*USER node[\s\S]*RUN pnpm install --prod/,
215
+ );
216
+ });
217
+
132
218
  it("GET /_healthcheck returns 200", async () => {
133
219
  const { app, handle } = await buildApp();
134
220
  handleRef = handle;
@@ -141,6 +227,7 @@ describe("standalone smoke test", () => {
141
227
  handleRef = handle;
142
228
  const res = await request(app)
143
229
  .post("/oauth/token")
230
+ .set("Authorization", SMOKE_BASIC_AUTH)
144
231
  .type("form")
145
232
  .send({ grant_type: "unsupported" });
146
233
  expect(res.status).toBe(400);
@@ -151,6 +238,7 @@ describe("standalone smoke test", () => {
151
238
  handleRef = handle;
152
239
  const res = await request(app)
153
240
  .post("/oauth/token")
241
+ .set("Authorization", SMOKE_BASIC_AUTH)
154
242
  .type("form")
155
243
  .send({ grant_type: "unsupported" });
156
244
  expect(res.status).toBe(400);
@@ -173,6 +261,7 @@ describe("standalone smoke test", () => {
173
261
  const modules = buildModules(config, {
174
262
  keyStoreModule: testKeyStoreModule,
175
263
  repositoriesModule: testRepositoriesModule,
264
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
176
265
  });
177
266
  const moduleNames = modules.map((m) => m.name);
178
267
  expect(moduleNames).not.toContain("federation:google");
@@ -186,6 +275,161 @@ describe("standalone smoke test", () => {
186
275
  expect(handle).toBeDefined();
187
276
  });
188
277
 
278
+ // D-2 v2 / OR-1 closure: the standalone composition root must default to the
279
+ // Redis-backed RT family store + ioredis client module pair, NOT the
280
+ // in-memory store. Multi-replica deployments lose RT family persistence
281
+ // when each replica holds families in-process; this test guards the
282
+ // production default. The override path is exercised by the smoke tests
283
+ // above (which substitute the memory store for CI).
284
+ describe("D-2 v2 + Wave 5d: redis-clients + adapter wiring", () => {
285
+ it("buildModules includes the shared redis-clients module + redis store by default", () => {
286
+ const modules = buildModules(config, {
287
+ keyStoreModule: testKeyStoreModule,
288
+ repositoriesModule: testRepositoriesModule,
289
+ });
290
+ const names = modules.map((m) => m.name);
291
+ // Wave 5d: single shared ioredis client module (was per-purpose
292
+ // `standalone:refresh-token-family-client` in PR1).
293
+ expect(names).toContain("standalone:redis-clients");
294
+ expect(names).toContain("redis-refresh-token-family-store");
295
+ expect(names).not.toContain("core-refresh-token-family-store-memory");
296
+ });
297
+
298
+ it("buildModules drops the shared redis-clients module when override forces memory-only", () => {
299
+ const modules = buildModules(config, {
300
+ keyStoreModule: testKeyStoreModule,
301
+ repositoriesModule: testRepositoriesModule,
302
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
303
+ });
304
+ const names = modules.map((m) => m.name);
305
+ expect(names).toContain("core-refresh-token-family-store-memory");
306
+ expect(names).not.toContain("standalone:redis-clients");
307
+ expect(names).not.toContain("redis-refresh-token-family-store");
308
+ });
309
+
310
+ it("buildModules wires memoryRateLimiterModule by default (closes IH-14)", () => {
311
+ const modules = buildModules(config, {
312
+ keyStoreModule: testKeyStoreModule,
313
+ repositoriesModule: testRepositoriesModule,
314
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
315
+ });
316
+ const names = modules.map((m) => m.name);
317
+ expect(names).toContain("core-rate-limiter-memory");
318
+ expect(names).not.toContain("redis-rate-limiter");
319
+ });
320
+
321
+ it("buildModules switches to redisRateLimiterModule when rateLimiter.adapter = 'redis'", () => {
322
+ const redisRlConfig = {
323
+ ...config,
324
+ rateLimiter: { adapter: "redis" as const },
325
+ };
326
+ const modules = buildModules(redisRlConfig, {
327
+ keyStoreModule: testKeyStoreModule,
328
+ repositoriesModule: testRepositoriesModule,
329
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
330
+ });
331
+ const names = modules.map((m) => m.name);
332
+ expect(names).toContain("redis-rate-limiter");
333
+ expect(names).not.toContain("core-rate-limiter-memory");
334
+ // Adapter = redis pulls in the shared redis-clients module too.
335
+ expect(names).toContain("standalone:redis-clients");
336
+ });
337
+
338
+ it("buildModules switches to redisSessionStoresModule when userSessionStores.adapter = 'redis'", () => {
339
+ const redisSessConfig = {
340
+ ...config,
341
+ userSessionStores: { adapter: "redis" as const },
342
+ };
343
+ const modules = buildModules(redisSessConfig, {
344
+ keyStoreModule: testKeyStoreModule,
345
+ repositoriesModule: testRepositoriesModule,
346
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
347
+ });
348
+ const names = modules.map((m) => m.name);
349
+ expect(names).toContain("redisSessionStores");
350
+ expect(names).not.toContain("standalone:stores");
351
+ expect(names).toContain("standalone:redis-clients");
352
+ });
353
+ });
354
+
355
+ // OR-9 (Wave 5d): adapter switch for the OAuth code repository. Closes
356
+ // the deferred Phase 10 module-pattern wrapper for `codeRepository`. The
357
+ // memory branch wires `inMemoryCodeRepositoryModule`; the redis branch
358
+ // wires `redisCodeRepositoryModule` against the shared ioredis socket
359
+ // (same `standaloneRedisClientsModule` as the RT family / rate limiter).
360
+ describe("OR-9: code-repository adapter wiring", () => {
361
+ it("buildModules wires inMemoryCodeRepositoryModule by default (oauth.code.adapter = 'memory')", () => {
362
+ const modules = buildModules(config, {
363
+ keyStoreModule: testKeyStoreModule,
364
+ repositoriesModule: testRepositoriesModule,
365
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
366
+ });
367
+ const names = modules.map((m) => m.name);
368
+ expect(names).toContain("standalone:in-memory-code-repository");
369
+ expect(names).not.toContain("redis-code-repository");
370
+ });
371
+
372
+ it("buildModules switches to redisCodeRepositoryModule when oauth.code.adapter = 'redis'", () => {
373
+ const redisCodeConfig = {
374
+ ...config,
375
+ oauth: { ...config.oauth, code: { adapter: "redis" as const } },
376
+ };
377
+ const modules = buildModules(redisCodeConfig, {
378
+ keyStoreModule: testKeyStoreModule,
379
+ repositoriesModule: testRepositoriesModule,
380
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
381
+ });
382
+ const names = modules.map((m) => m.name);
383
+ expect(names).toContain("redis-code-repository");
384
+ expect(names).not.toContain("standalone:in-memory-code-repository");
385
+ // adapter = "redis" pulls in the shared ioredis socket so the
386
+ // `codeRepositoryClient` slot is satisfied.
387
+ expect(names).toContain("standalone:redis-clients");
388
+ });
389
+
390
+ it("buildModules honors legacy repositories.code.type='redis' with deprecation warn when oauth.code.adapter is absent", () => {
391
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
392
+ try {
393
+ const legacyConfig = {
394
+ ...config,
395
+ oauth: { ...config.oauth, code: undefined },
396
+ repositories: {
397
+ ...config.repositories,
398
+ code: { type: "redis" as const, defaultExpiresIn: 600 },
399
+ },
400
+ };
401
+ const modules = buildModules(legacyConfig, {
402
+ keyStoreModule: testKeyStoreModule,
403
+ repositoriesModule: testRepositoriesModule,
404
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
405
+ });
406
+ const names = modules.map((m) => m.name);
407
+ expect(names).toContain("redis-code-repository");
408
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("repositories.code.type"));
409
+ } finally {
410
+ warnSpy.mockRestore();
411
+ }
412
+ });
413
+
414
+ it("buildModules has only ONE codeRepository provider in the manifest (no slot collision)", () => {
415
+ // Regression guard: `repositoriesModule` must NOT provide
416
+ // `codeRepository` post-OR-9 — the slot is owned by
417
+ // `inMemoryCodeRepositoryModule` or `redisCodeRepositoryModule`
418
+ // exclusively, selected via `oauth.code.adapter`.
419
+ const modules = buildModules(config, {
420
+ keyStoreModule: testKeyStoreModule,
421
+ // Use the production repositoriesModule (not the test override)
422
+ // so we exercise its post-OR-9 provides-shape.
423
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
424
+ });
425
+ const codeRepoProviders = modules.filter((m) =>
426
+ Object.keys(m.provides ?? {}).includes("codeRepository"),
427
+ );
428
+ expect(codeRepoProviders).toHaveLength(1);
429
+ expect(codeRepoProviders[0]?.name).toBe("standalone:in-memory-code-repository");
430
+ });
431
+ });
432
+
189
433
  it("POST /oauth/introspect returns iat in active token response", async () => {
190
434
  const { app, handle } = await buildApp();
191
435
  handleRef = handle;
@@ -16,14 +16,9 @@
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { gracefulShutdown } from "@o3co/auth.utils";
18
18
  import { type AppConfig, AppConfigSchema, createApp } from "@o3co/auth-provider-core";
19
- import {
20
- createSessionStoreFactory,
21
- registerBuiltinSessionStores,
22
- } from "@o3co/auth-provider-session";
23
19
  import { parseFile } from "@o3co/ts.hocon";
24
20
  import { validate } from "@o3co/ts.hocon/zod";
25
21
  import express from "express";
26
- import session from "express-session";
27
22
  import helmet from "helmet";
28
23
 
29
24
  import logger from "#/logger.mjs";
@@ -56,36 +51,14 @@ await (async (): Promise<void> => {
56
51
  }),
57
52
  );
58
53
 
59
- // Step 3: Build the Express session store and mount express-session.
60
- // (express-session is host-environment middleware, not part of the auth
61
- // boot pipeline — wired before createApp so it sits ahead of the router.)
62
- const sessionStoreFactory = createSessionStoreFactory();
63
- registerBuiltinSessionStores(sessionStoreFactory);
64
- const sessionStorageSlice = config.session.storage as {
65
- type: string;
66
- } & Record<string, unknown>;
67
- const sessionStore = await sessionStoreFactory.create({
68
- type: sessionStorageSlice.type,
69
- ...((sessionStorageSlice[sessionStorageSlice.type] ?? {}) as Record<string, unknown>),
70
- });
71
- app.use(
72
- session({
73
- secret: config.session.secret,
74
- resave: false,
75
- saveUninitialized: false,
76
- store: sessionStore,
77
- cookie: {
78
- path: "/",
79
- httpOnly: true,
80
- secure: config.session.secure,
81
- maxAge: config.session.maxAge,
82
- sameSite: config.session.sameSite,
83
- domain: config.session.domain || undefined,
84
- },
85
- }),
86
- );
87
-
88
- // Step 4: Boot the auth pipeline. bootstrapComponents carries only host-
54
+ // Step 3: Boot the auth pipeline.
55
+ // D-5: express-session middleware is now wired by `sessionStoreModule`
56
+ // inside the boot planner. Mount order is enforced by declarationIndex
57
+ // tie-breaking — sessionStoreModule MUST come first in `buildModules(...)`
58
+ // so the middleware initialises `req.session` for every downstream route.
59
+ // The connect-redis client's lifetime is owned by the planner via
60
+ // `BuilderContext.lifecycle` and drained on `handle.dispose()`.
61
+ // bootstrapComponents carries only host-
89
62
  // environment values (config + pathResolver per A2-γ §4 worked example);
90
63
  // every other component flows through composition-root-local modules.
91
64
  // `buildModules` is the single source of truth for the module list — it
@@ -99,20 +72,20 @@ await (async (): Promise<void> => {
99
72
  },
100
73
  });
101
74
 
102
- // Step 5: Wire host-level routes (healthcheck) before the auth router so
75
+ // Step 4: Wire host-level routes (healthcheck) before the auth router so
103
76
  // they remain reachable even when the auth pipeline is degraded.
104
77
  app.get("/_healthcheck", (_req, res) => {
105
78
  res.status(200).json({ status: "ok" });
106
79
  });
107
80
 
108
- // Step 6: Mount the composed auth router and start the HTTP server.
81
+ // Step 5: Mount the composed auth router and start the HTTP server.
109
82
  app.use(handle.router);
110
83
  const server = app.listen(config.http.port, (): void => {
111
84
  logger.info(`Server is running on http://localhost:${config.http.port}`);
112
85
  });
113
86
 
114
- // Step 7: Graceful shutdown — handle.dispose() runs reverse-topological
115
- // per-component cleanup (per A2-β §8.1). Replaces the v0.4.x
116
- // grantRegistry.cleanup() bridge.
87
+ // Step 6: Graceful shutdown — handle.dispose() runs reverse-topological
88
+ // per-component cleanup (per A2-β §8.1) plus D-5 LifecycleRegistrar drain
89
+ // (Redis clients, interval timers).
117
90
  gracefulShutdown(server, () => handle.dispose());
118
91
  })();
@@ -18,7 +18,7 @@ import {
18
18
  defaultRefreshTokenFamilyRevocationModule,
19
19
  defaultRefreshTokenFamilyRotationModule,
20
20
  type Module,
21
- memoryRefreshTokenFamilyStoreModule,
21
+ memoryRateLimiterModule,
22
22
  } from "@o3co/auth-provider-core";
23
23
  import { googleFederationModule } from "@o3co/auth-provider-federation-google";
24
24
  import {
@@ -26,12 +26,21 @@ import {
26
26
  oauthModule,
27
27
  oauthSessionModule,
28
28
  } from "@o3co/auth-provider-oauth";
29
- import { sessionModule } from "@o3co/auth-provider-session";
30
29
  import {
30
+ redisCodeRepositoryModule,
31
+ redisRateLimiterModule,
32
+ redisRefreshTokenFamilyStoreModule,
33
+ redisSessionStoresModule,
34
+ } from "@o3co/auth-provider-redis";
35
+ import { sessionModule, sessionStoreModule } from "@o3co/auth-provider-session";
36
+ import {
37
+ federationTokenStoreModule,
31
38
  googleFederationConfigModule,
39
+ inMemoryCodeRepositoryModule,
40
+ inMemorySessionStoresModule,
32
41
  keyStoreModule,
33
42
  repositoriesModule,
34
- storesModule,
43
+ standaloneRedisClientsModule,
35
44
  } from "./modules.mjs";
36
45
 
37
46
  /**
@@ -43,6 +52,18 @@ export interface BuildModulesOverrides {
43
52
  readonly keyStoreModule?: Module;
44
53
  readonly repositoriesModule?: Module;
45
54
  readonly storesModule?: Module;
55
+ /**
56
+ * D-2 v2 / Wave 5d: override the RT family store module + (when adapter
57
+ * is `"redis"`) the bundled redis-clients + redis-store pair. Default
58
+ * production manifest is `[standaloneRedisClientsModule, redisRefreshTokenFamilyStoreModule]`
59
+ * (single shared ioredis socket per replica via the F4 PR2 unification).
60
+ *
61
+ * Smoke tests / unit tests that don't want to open an ioredis connection
62
+ * pass `[memoryRefreshTokenFamilyStoreModule]` here. The override REPLACES
63
+ * the entire group — when the override is provided, `standaloneRedisClientsModule`
64
+ * is dropped from the manifest unless the override list includes it.
65
+ */
66
+ readonly refreshTokenFamilyModules?: readonly Module[];
46
67
  }
47
68
 
48
69
  /**
@@ -62,7 +83,85 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
62
83
  const googleEnabled =
63
84
  (config.federations?.google as { enabled?: boolean } | undefined)?.enabled === true;
64
85
 
86
+ // Wave 5d (IH-14 + OR-M1 + OR-4) + OR-9: adapter-driven branching for
87
+ // the OAuth-endpoint rate limiter, the user-session-store family, AND
88
+ // the OAuth code repository. The RT family store always uses Redis in
89
+ // the production manifest (D-2 v2 / OR-1) unless
90
+ // `overrides.refreshTokenFamilyModules` swaps it out. When ANY consumer
91
+ // adapter is `"redis"` (or the RT family override includes a
92
+ // Redis-backed store module), the shared `standaloneRedisClientsModule`
93
+ // is added once and provides every per-purpose ComponentMap slot from a
94
+ // single ioredis socket per replica. Memory-only deployments skip it.
95
+ const rateLimiterAdapter = config.rateLimiter?.adapter ?? "memory";
96
+ const userSessionStoresAdapter = config.userSessionStores?.adapter ?? "memory";
97
+
98
+ // OR-9: effective code-repo adapter. `oauth.code.adapter` is the
99
+ // authoritative switch; the legacy `repositories.code.type = "redis"`
100
+ // path is honored for one release with a deprecation warn so existing
101
+ // operators relying on `CLIENT_CODE_TYPE=redis` env-var overrides keep
102
+ // working until they migrate. Removed in v0.6.
103
+ const oauthCodeAdapter = config.oauth?.code?.adapter;
104
+ const legacyCodeType = (config.repositories?.code as { type?: string } | undefined)?.type;
105
+ let codeRepositoryAdapter: "memory" | "redis";
106
+ if (oauthCodeAdapter !== undefined) {
107
+ codeRepositoryAdapter = oauthCodeAdapter;
108
+ } else if (legacyCodeType === "redis") {
109
+ console.warn(
110
+ '[buildModules] `repositories.code.type = "redis"` is deprecated; use ' +
111
+ '`oauth.code.adapter = "redis"` instead (will be removed in v0.6).',
112
+ );
113
+ codeRepositoryAdapter = "redis";
114
+ } else {
115
+ codeRepositoryAdapter = "memory";
116
+ }
117
+
118
+ const refreshTokenFamilyModules: readonly Module[] = overrides.refreshTokenFamilyModules ?? [
119
+ redisRefreshTokenFamilyStoreModule,
120
+ ];
121
+ // Detect whether the RT-family override (if any) keeps the Redis-backed
122
+ // store. Default (no override) uses Redis. An override that includes the
123
+ // Redis store module without also wiring `standaloneRedisClientsModule`
124
+ // would otherwise boot-fail on the missing `refreshTokenFamilyClient`
125
+ // component (Copilot review on PR #121).
126
+ const refreshTokenFamilyUsesRedis = refreshTokenFamilyModules.some(
127
+ (m) => m.name === "redis-refresh-token-family-store",
128
+ );
129
+ const usingRedisAnywhere =
130
+ refreshTokenFamilyUsesRedis ||
131
+ rateLimiterAdapter === "redis" ||
132
+ userSessionStoresAdapter === "redis" ||
133
+ codeRepositoryAdapter === "redis";
134
+
135
+ // Federation-token store is always wired; only the 4 user-session
136
+ // stores switch on the adapter. Pre-Wave-5d the redis branch dropped
137
+ // the federation-token-store provider entirely (Copilot review on
138
+ // PR #121); splitting `storesModule` fixes that boot failure.
139
+ const sessionStoresModules: Module[] =
140
+ userSessionStoresAdapter === "redis"
141
+ ? [redisSessionStoresModule]
142
+ : overrides.storesModule
143
+ ? [overrides.storesModule]
144
+ : [inMemorySessionStoresModule];
145
+
146
+ const rateLimiterModules: Module[] =
147
+ rateLimiterAdapter === "redis" ? [redisRateLimiterModule] : [memoryRateLimiterModule];
148
+
149
+ // OR-9: code-repository module — mutually exclusive memory/redis pair.
150
+ // Same pattern as sessionStoresModules + rateLimiterModules. The two
151
+ // modules provide the same `codeRepository` slot; including both would
152
+ // be a boot-time slot collision.
153
+ const codeRepositoryModules: Module[] =
154
+ codeRepositoryAdapter === "redis"
155
+ ? [redisCodeRepositoryModule]
156
+ : [inMemoryCodeRepositoryModule];
157
+
65
158
  return [
159
+ // D-5: sessionStoreModule wires the express-session middleware into the
160
+ // boot-planner-managed lifecycle. **Mount order is enforced by this
161
+ // list position (declarationIndex tie-breaking)** — the module
162
+ // intentionally has no `before`/`after` clause, so it MUST be listed
163
+ // ahead of every session-consuming module here. Do not reorder.
164
+ sessionStoreModule,
66
165
  oauthModule({ config }),
67
166
  oauthSessionModule({ config }),
68
167
  oauthAuthorizationModule({ config }),
@@ -70,8 +169,24 @@ export function buildModules(config: AppConfig, overrides: BuildModulesOverrides
70
169
  ...(googleEnabled ? [googleFederationModule, googleFederationConfigModule] : []),
71
170
  overrides.keyStoreModule ?? keyStoreModule,
72
171
  overrides.repositoriesModule ?? repositoriesModule,
73
- overrides.storesModule ?? storesModule,
74
- memoryRefreshTokenFamilyStoreModule,
172
+ // Shared ioredis clients — only when at least one consumer adapter
173
+ // actually needs Redis. Memory-only deployments skip this so they
174
+ // don't open an unused socket.
175
+ ...(usingRedisAnywhere ? [standaloneRedisClientsModule] : []),
176
+ // Federation-token store — always wired; independent of the
177
+ // `userSessionStores.adapter` switch. Was bundled into `storesModule`
178
+ // pre-Wave-5d; split so the redis session-stores branch doesn't
179
+ // drop the provider.
180
+ federationTokenStoreModule,
181
+ // User-session-store family: redis (multi-replica) or memory (dev).
182
+ ...sessionStoresModules,
183
+ // OAuth-endpoint rate limiter: redis (shared counters) or memory.
184
+ ...rateLimiterModules,
185
+ // OAuth code repository: redis (multi-replica) or memory (single-instance).
186
+ ...codeRepositoryModules,
187
+ // RT family store: redis by default (closes OR-1); override path
188
+ // swaps to `[memoryRefreshTokenFamilyStoreModule]` for unit tests.
189
+ ...refreshTokenFamilyModules,
75
190
  defaultRefreshTokenFamilyRotationModule,
76
191
  defaultRefreshTokenFamilyRevocationModule,
77
192
  ];