@o3co/create-auth-provider 0.5.0 → 0.5.2

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.
@@ -21,13 +21,13 @@ import {
21
21
  defineModule,
22
22
  generateToken,
23
23
  InMemoryClientRepository,
24
- InMemoryCodeRepository,
25
24
  InMemoryUserRepository,
25
+ memoryRefreshTokenFamilyStoreModule,
26
26
  registerBuiltinKeyStores,
27
27
  } from "@o3co/auth-provider-core";
28
28
  import express from "express";
29
29
  import request from "supertest";
30
- import { afterEach, describe, expect, it } from "vitest";
30
+ import { afterEach, describe, expect, it, vi } from "vitest";
31
31
  import { buildModules } from "../buildModules.mjs";
32
32
 
33
33
  const config: AppConfig = {
@@ -40,13 +40,22 @@ const config: AppConfig = {
40
40
  algorithm: "HS256",
41
41
  secret: "test-secret-for-smoke-test",
42
42
  kid: "v0",
43
- previousKeys: [],
43
+ previousSecrets: [],
44
44
  },
45
45
  },
46
46
  },
47
47
  accessToken: { expiresIn: 3600 },
48
- refreshToken: { expiresIn: 86400 },
48
+ refreshToken: {
49
+ expiresIn: 86400,
50
+ unknownFamilyPolicy: "reject" as const,
51
+ legacyRtPolicy: "reject" as const,
52
+ },
49
53
  grants: {},
54
+ // OR-9: explicit memory adapter for the smoke test. The legacy
55
+ // `repositories.code.type` fallback would also pick memory here,
56
+ // but the explicit setting documents the intent and avoids
57
+ // console.warn deprecation noise in the test output.
58
+ code: { adapter: "memory" as const },
50
59
  },
51
60
  session: {
52
61
  secret: "test-session-secret",
@@ -58,6 +67,7 @@ const config: AppConfig = {
58
67
  },
59
68
  rateLimit: {
60
69
  login: { windowMs: 60000, limit: 10 },
70
+ failMode: "open",
61
71
  },
62
72
  federations: {
63
73
  google: { enabled: false },
@@ -69,8 +79,6 @@ const config: AppConfig = {
69
79
  },
70
80
  endpoints: {
71
81
  login: { url: "/login" },
72
- client: { url: undefined },
73
- authCallback: { url: undefined },
74
82
  },
75
83
  cors: { allowedOrigins: [] },
76
84
  };
@@ -79,13 +87,50 @@ const config: AppConfig = {
79
87
  * Test-only repository module — bypasses the file-system-backed repositories
80
88
  * that `repositoriesModule` provides in production. Smoke tests use in-memory
81
89
  * empty repositories to verify the boot pipeline shape, not the data layer.
90
+ *
91
+ * Post-OR-9 (Wave 5d): `codeRepository` is no longer provided here — it is
92
+ * wired by `inMemoryCodeRepositoryModule` (or `redisCodeRepositoryModule`)
93
+ * via the `oauth.code.adapter` switch in `buildModules`. The smoke test
94
+ * config sets `oauth.code.adapter = "memory"`, so `inMemoryCodeRepositoryModule`
95
+ * is added automatically and provides the slot.
96
+ *
97
+ * D-6 (v0.5.1): a single confidential client is registered so the
98
+ * `clientAuthMw` middleware in front of `/oauth/token` has a record to
99
+ * authenticate against. Smoke tests that hit `/oauth/token` send
100
+ * `Authorization: ${SMOKE_BASIC_AUTH}`.
82
101
  */
102
+ const SMOKE_CLIENT_ID = "smoke-client";
103
+ const SMOKE_CLIENT_SECRET = "smoke-secret";
104
+ const SMOKE_BASIC_AUTH = `Basic ${Buffer.from(`${SMOKE_CLIENT_ID}:${SMOKE_CLIENT_SECRET}`).toString("base64")}`;
105
+
83
106
  const testRepositoriesModule = defineModule({
84
107
  name: "test:repositories",
85
108
  provides: {
86
- clientRepository: () => new InMemoryClientRepository(new Map()),
109
+ // `InMemoryClientRepository` constructor parameter `Map<string, ClientEntry>`
110
+ // uses `ClientEntry = z.infer<typeof ClientEntrySchema>` (the schema's
111
+ // OUTPUT type) — fields with `.default(...)` are non-optional in the
112
+ // output even though they are optional on input. The fixture supplies
113
+ // every field explicitly so the literal satisfies the output shape
114
+ // without requiring a cast.
115
+ clientRepository: () =>
116
+ new InMemoryClientRepository(
117
+ new Map([
118
+ [
119
+ SMOKE_CLIENT_ID,
120
+ {
121
+ tokenEndpointAuthMethod: "client_secret_basic",
122
+ clientSecret: SMOKE_CLIENT_SECRET,
123
+ allowedRedirectUris: [],
124
+ allowedScopes: [],
125
+ allowedAudiences: [],
126
+ backchannelLogoutSessionRequired: true,
127
+ frontchannelLogoutSessionRequired: true,
128
+ allowedAzpForFederationToken: false,
129
+ },
130
+ ],
131
+ ]),
132
+ ),
87
133
  userRepository: () => new InMemoryUserRepository(new Map()),
88
- codeRepository: () => new InMemoryCodeRepository(),
89
134
  },
90
135
  });
91
136
 
@@ -112,6 +157,12 @@ describe("standalone smoke test", () => {
112
157
  modules: buildModules(config, {
113
158
  keyStoreModule: testKeyStoreModule,
114
159
  repositoriesModule: testRepositoriesModule,
160
+ // D-2 v2: smoke tests stay in-memory for the RT family store.
161
+ // Production composition root pairs `refreshTokenFamilyClientModule`
162
+ // with `redisRefreshTokenFamilyStoreModule`; this override replaces
163
+ // the pair entirely with a single memory-backed store module so no
164
+ // real ioredis connection is opened during CI unit tests.
165
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
115
166
  }),
116
167
  bootstrapComponents: { config, pathResolver: (s) => s },
117
168
  });
@@ -141,6 +192,7 @@ describe("standalone smoke test", () => {
141
192
  handleRef = handle;
142
193
  const res = await request(app)
143
194
  .post("/oauth/token")
195
+ .set("Authorization", SMOKE_BASIC_AUTH)
144
196
  .type("form")
145
197
  .send({ grant_type: "unsupported" });
146
198
  expect(res.status).toBe(400);
@@ -151,6 +203,7 @@ describe("standalone smoke test", () => {
151
203
  handleRef = handle;
152
204
  const res = await request(app)
153
205
  .post("/oauth/token")
206
+ .set("Authorization", SMOKE_BASIC_AUTH)
154
207
  .type("form")
155
208
  .send({ grant_type: "unsupported" });
156
209
  expect(res.status).toBe(400);
@@ -173,6 +226,7 @@ describe("standalone smoke test", () => {
173
226
  const modules = buildModules(config, {
174
227
  keyStoreModule: testKeyStoreModule,
175
228
  repositoriesModule: testRepositoriesModule,
229
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
176
230
  });
177
231
  const moduleNames = modules.map((m) => m.name);
178
232
  expect(moduleNames).not.toContain("federation:google");
@@ -186,6 +240,161 @@ describe("standalone smoke test", () => {
186
240
  expect(handle).toBeDefined();
187
241
  });
188
242
 
243
+ // D-2 v2 / OR-1 closure: the standalone composition root must default to the
244
+ // Redis-backed RT family store + ioredis client module pair, NOT the
245
+ // in-memory store. Multi-replica deployments lose RT family persistence
246
+ // when each replica holds families in-process; this test guards the
247
+ // production default. The override path is exercised by the smoke tests
248
+ // above (which substitute the memory store for CI).
249
+ describe("D-2 v2 + Wave 5d: redis-clients + adapter wiring", () => {
250
+ it("buildModules includes the shared redis-clients module + redis store by default", () => {
251
+ const modules = buildModules(config, {
252
+ keyStoreModule: testKeyStoreModule,
253
+ repositoriesModule: testRepositoriesModule,
254
+ });
255
+ const names = modules.map((m) => m.name);
256
+ // Wave 5d: single shared ioredis client module (was per-purpose
257
+ // `standalone:refresh-token-family-client` in PR1).
258
+ expect(names).toContain("standalone:redis-clients");
259
+ expect(names).toContain("redis-refresh-token-family-store");
260
+ expect(names).not.toContain("core-refresh-token-family-store-memory");
261
+ });
262
+
263
+ it("buildModules drops the shared redis-clients module when override forces memory-only", () => {
264
+ const modules = buildModules(config, {
265
+ keyStoreModule: testKeyStoreModule,
266
+ repositoriesModule: testRepositoriesModule,
267
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
268
+ });
269
+ const names = modules.map((m) => m.name);
270
+ expect(names).toContain("core-refresh-token-family-store-memory");
271
+ expect(names).not.toContain("standalone:redis-clients");
272
+ expect(names).not.toContain("redis-refresh-token-family-store");
273
+ });
274
+
275
+ it("buildModules wires memoryRateLimiterModule by default (closes IH-14)", () => {
276
+ const modules = buildModules(config, {
277
+ keyStoreModule: testKeyStoreModule,
278
+ repositoriesModule: testRepositoriesModule,
279
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
280
+ });
281
+ const names = modules.map((m) => m.name);
282
+ expect(names).toContain("core-rate-limiter-memory");
283
+ expect(names).not.toContain("redis-rate-limiter");
284
+ });
285
+
286
+ it("buildModules switches to redisRateLimiterModule when rateLimiter.adapter = 'redis'", () => {
287
+ const redisRlConfig = {
288
+ ...config,
289
+ rateLimiter: { adapter: "redis" as const },
290
+ };
291
+ const modules = buildModules(redisRlConfig, {
292
+ keyStoreModule: testKeyStoreModule,
293
+ repositoriesModule: testRepositoriesModule,
294
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
295
+ });
296
+ const names = modules.map((m) => m.name);
297
+ expect(names).toContain("redis-rate-limiter");
298
+ expect(names).not.toContain("core-rate-limiter-memory");
299
+ // Adapter = redis pulls in the shared redis-clients module too.
300
+ expect(names).toContain("standalone:redis-clients");
301
+ });
302
+
303
+ it("buildModules switches to redisSessionStoresModule when userSessionStores.adapter = 'redis'", () => {
304
+ const redisSessConfig = {
305
+ ...config,
306
+ userSessionStores: { adapter: "redis" as const },
307
+ };
308
+ const modules = buildModules(redisSessConfig, {
309
+ keyStoreModule: testKeyStoreModule,
310
+ repositoriesModule: testRepositoriesModule,
311
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
312
+ });
313
+ const names = modules.map((m) => m.name);
314
+ expect(names).toContain("redisSessionStores");
315
+ expect(names).not.toContain("standalone:stores");
316
+ expect(names).toContain("standalone:redis-clients");
317
+ });
318
+ });
319
+
320
+ // OR-9 (Wave 5d): adapter switch for the OAuth code repository. Closes
321
+ // the deferred Phase 10 module-pattern wrapper for `codeRepository`. The
322
+ // memory branch wires `inMemoryCodeRepositoryModule`; the redis branch
323
+ // wires `redisCodeRepositoryModule` against the shared ioredis socket
324
+ // (same `standaloneRedisClientsModule` as the RT family / rate limiter).
325
+ describe("OR-9: code-repository adapter wiring", () => {
326
+ it("buildModules wires inMemoryCodeRepositoryModule by default (oauth.code.adapter = 'memory')", () => {
327
+ const modules = buildModules(config, {
328
+ keyStoreModule: testKeyStoreModule,
329
+ repositoriesModule: testRepositoriesModule,
330
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
331
+ });
332
+ const names = modules.map((m) => m.name);
333
+ expect(names).toContain("standalone:in-memory-code-repository");
334
+ expect(names).not.toContain("redis-code-repository");
335
+ });
336
+
337
+ it("buildModules switches to redisCodeRepositoryModule when oauth.code.adapter = 'redis'", () => {
338
+ const redisCodeConfig = {
339
+ ...config,
340
+ oauth: { ...config.oauth, code: { adapter: "redis" as const } },
341
+ };
342
+ const modules = buildModules(redisCodeConfig, {
343
+ keyStoreModule: testKeyStoreModule,
344
+ repositoriesModule: testRepositoriesModule,
345
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
346
+ });
347
+ const names = modules.map((m) => m.name);
348
+ expect(names).toContain("redis-code-repository");
349
+ expect(names).not.toContain("standalone:in-memory-code-repository");
350
+ // adapter = "redis" pulls in the shared ioredis socket so the
351
+ // `codeRepositoryClient` slot is satisfied.
352
+ expect(names).toContain("standalone:redis-clients");
353
+ });
354
+
355
+ it("buildModules honors legacy repositories.code.type='redis' with deprecation warn when oauth.code.adapter is absent", () => {
356
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
357
+ try {
358
+ const legacyConfig = {
359
+ ...config,
360
+ oauth: { ...config.oauth, code: undefined },
361
+ repositories: {
362
+ ...config.repositories,
363
+ code: { type: "redis" as const, defaultExpiresIn: 600 },
364
+ },
365
+ };
366
+ const modules = buildModules(legacyConfig, {
367
+ keyStoreModule: testKeyStoreModule,
368
+ repositoriesModule: testRepositoriesModule,
369
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
370
+ });
371
+ const names = modules.map((m) => m.name);
372
+ expect(names).toContain("redis-code-repository");
373
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("repositories.code.type"));
374
+ } finally {
375
+ warnSpy.mockRestore();
376
+ }
377
+ });
378
+
379
+ it("buildModules has only ONE codeRepository provider in the manifest (no slot collision)", () => {
380
+ // Regression guard: `repositoriesModule` must NOT provide
381
+ // `codeRepository` post-OR-9 — the slot is owned by
382
+ // `inMemoryCodeRepositoryModule` or `redisCodeRepositoryModule`
383
+ // exclusively, selected via `oauth.code.adapter`.
384
+ const modules = buildModules(config, {
385
+ keyStoreModule: testKeyStoreModule,
386
+ // Use the production repositoriesModule (not the test override)
387
+ // so we exercise its post-OR-9 provides-shape.
388
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
389
+ });
390
+ const codeRepoProviders = modules.filter((m) =>
391
+ Object.keys(m.provides ?? {}).includes("codeRepository"),
392
+ );
393
+ expect(codeRepoProviders).toHaveLength(1);
394
+ expect(codeRepoProviders[0]?.name).toBe("standalone:in-memory-code-repository");
395
+ });
396
+ });
397
+
189
398
  it("POST /oauth/introspect returns iat in active token response", async () => {
190
399
  const { app, handle } = await buildApp();
191
400
  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
  ];