@o3co/create-auth-provider 0.15.0 → 0.17.0-rc.1

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 (92) hide show
  1. package/README.ja.md +139 -48
  2. package/README.md +138 -47
  3. package/dist/index.d.mts +33 -10
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +118 -59
  6. package/dist/internal/template-filter.d.mts +7 -10
  7. package/dist/internal/template-filter.d.mts.map +1 -1
  8. package/dist/internal/template-filter.mjs +7 -10
  9. package/package.json +6 -4
  10. package/templates/standalone/.dockerignore +15 -2
  11. package/templates/standalone/.env.example +69 -24
  12. package/templates/standalone/Dockerfile +7 -3
  13. package/templates/standalone/README.ja.md +677 -184
  14. package/templates/standalone/README.md +699 -238
  15. package/templates/standalone/config/application.conf +203 -325
  16. package/templates/standalone/config/clients.yaml +2 -0
  17. package/templates/standalone/config/clients.yaml.example +4 -0
  18. package/templates/standalone/config/development.conf +13 -4
  19. package/templates/standalone/config/mailpit.conf +8 -0
  20. package/templates/standalone/config/production.conf +5 -6
  21. package/templates/standalone/config/reference.conf +399 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +58 -30
  24. package/templates/standalone/docker-compose.yml +13 -3
  25. package/templates/standalone/gitignore +4 -2
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +65 -32
  28. package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1178 -0
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +256 -0
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1119 -0
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
  33. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  34. package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
  35. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  36. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
  37. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +672 -271
  38. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +480 -78
  39. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +102 -66
  40. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +135 -0
  41. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +49 -18
  42. package/templates/standalone/src/__tests__/google-session.fixture.mts +315 -0
  43. package/templates/standalone/src/__tests__/host-routes.test.mts +134 -0
  44. package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
  45. package/templates/standalone/src/__tests__/listen.test.mts +121 -0
  46. package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
  47. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  48. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
  49. package/templates/standalone/src/__tests__/metrics.test.mts +17 -7
  50. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  51. package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
  52. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
  53. package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
  54. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +183 -0
  55. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +135 -0
  56. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
  57. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  58. package/templates/standalone/src/__tests__/replica-safety.test.mts +166 -145
  59. package/templates/standalone/src/__tests__/router-error-answers.test.mts +146 -0
  60. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
  61. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  62. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
  63. package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
  64. package/templates/standalone/src/__tests__/shutdown.test.mts +319 -115
  65. package/templates/standalone/src/__tests__/smoke.test.mts +179 -233
  66. package/templates/standalone/src/__tests__/store-credential.test.mts +317 -0
  67. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  68. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
  69. package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
  70. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
  71. package/templates/standalone/src/adapters.mts +149 -0
  72. package/templates/standalone/src/app.mts +71 -115
  73. package/templates/standalone/src/buildModules.mts +229 -251
  74. package/templates/standalone/src/configPath.mts +422 -29
  75. package/templates/standalone/src/listen.mts +60 -0
  76. package/templates/standalone/src/logger.mts +31 -57
  77. package/templates/standalone/src/metrics.mts +24 -47
  78. package/templates/standalone/src/mfaSwitch.mts +262 -0
  79. package/templates/standalone/src/modules.mts +480 -464
  80. package/templates/standalone/src/rootRenames.mts +79 -0
  81. package/templates/standalone/src/routes.mts +89 -0
  82. package/templates/standalone/src/sections.mts +366 -0
  83. package/templates/standalone/src/shutdown.mts +54 -126
  84. package/templates/standalone/tests/index.test.js +2 -2
  85. package/templates/standalone/tests/package-lock.json +1615 -2223
  86. package/templates/standalone/tests/package.json +2 -2
  87. package/templates/standalone/tests/vitest.config.mjs +26 -0
  88. package/templates/standalone/vitest.config.mts +8 -7
  89. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  90. package/templates/versions.json +18 -15
  91. package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
  92. package/templates/standalone/src/terminalError.mts +0 -60
@@ -29,14 +29,22 @@ import {
29
29
  memoryRefreshTokenFamilyStoreModule,
30
30
  registerBuiltinKeyStores,
31
31
  } from "@o3co/auth-provider-core";
32
+ import { coreConfigForTests } from "@o3co/auth-provider-core/testing";
32
33
  import express from "express";
33
34
  import request from "supertest";
34
- import { afterEach, describe, expect, it, vi } from "vitest";
35
+ import { afterEach, describe, expect, it } from "vitest";
35
36
  import { buildModules } from "../buildModules.mjs";
37
+ import type { Switches } from "../configPath.mjs";
38
+ import type { Adapters } from "../sections.mjs";
39
+ import {
40
+ capturedRenames,
41
+ inProcessAdapters,
42
+ shippedAdapters,
43
+ } from "./library-references.fixture.mjs";
36
44
 
37
45
  const dockerfile = readFileSync(new URL("../../Dockerfile", import.meta.url), "utf8");
38
46
 
39
- // #282: the template's shipped default is EdDSA with a published JWKS, so the
47
+ // The template's shipped default is EdDSA with a published JWKS, so the
40
48
  // smoke test signs the way a real deployment of this scaffold does. Generated
41
49
  // per run — no key material is committed.
42
50
  const smokeKeyPair = generateKeyPairSync("ed25519", {
@@ -44,22 +52,40 @@ const smokeKeyPair = generateKeyPairSync("ed25519", {
44
52
  privateKeyEncoding: { type: "pkcs8", format: "pem" },
45
53
  });
46
54
 
47
- const config: AppConfig = {
48
- http: { port: 0, trustProxy: false, readinessTimeoutMs: 1000 },
55
+ /** The `http` module's section, as the hand-built configuration below carries it. */
56
+ const HTTP = {
57
+ port: 0,
58
+ trustProxy: false,
59
+ readinessTimeoutMs: 1000,
60
+ cors: { allowedOrigins: [] as string[] },
61
+ };
62
+
63
+ /** The template modules' sections sit beside core's, so the configuration is wider than `AppConfig`. */
64
+ const config: AppConfig & Record<string, unknown> = {
65
+ // What a resolution under an environment that sets none captures of
66
+ // core's renamed variables.
67
+ ...{
68
+ "renamed-variables": capturedRenames({}),
69
+ },
70
+ http: HTTP,
49
71
  logging: { level: "silent" },
72
+ "key-store": {
73
+ provider: "local",
74
+ local: {
75
+ algorithm: "EdDSA",
76
+ kid: "v0",
77
+ privateKey: smokeKeyPair.privateKey,
78
+ publicKey: smokeKeyPair.publicKey,
79
+ previousKeys: [],
80
+ },
81
+ },
82
+ // A composition with a consumer of session admission states what it
83
+ // expects (ADR 2026-09-28-session-admission): the shipped
84
+ // `application.conf` expects none, and so does this hand-built config.
85
+ ...coreConfigForTests({ federations: { google: { enabled: false } } }),
50
86
  oauth: {
51
87
  jwt: {
52
88
  issuer: "https://auth.test",
53
- signingKey: {
54
- provider: "local",
55
- local: {
56
- algorithm: "EdDSA",
57
- kid: "v0",
58
- privateKey: smokeKeyPair.privateKey,
59
- publicKey: smokeKeyPair.publicKey,
60
- previousKeys: [],
61
- },
62
- },
63
89
  },
64
90
  accessToken: { expiresIn: 3600 },
65
91
  refreshToken: {
@@ -69,16 +95,10 @@ const config: AppConfig = {
69
95
  },
70
96
  grants: {},
71
97
  oidcMode: "oidc-required",
72
- // #330: no `authorize` section — the `allowUnmarkedClients` migration
73
- // flag was removed; the first-party invariant is unconditional.
74
- // OR-9: explicit memory adapter for the smoke test. The legacy
75
- // `repositories.code.type` fallback would also pick memory here,
76
- // but the explicit setting documents the intent and avoids
77
- // console.warn deprecation noise in the test output.
78
- code: { adapter: "memory" as const },
98
+ // No `authorize` section: the first-party invariant is unconditional.
79
99
  },
80
- session: {
81
- // #282: `session.secret` carries a 256-bit entropy floor.
100
+ "session-store": {
101
+ // `session-store.secret` carries a 256-bit entropy floor.
82
102
  secret: "test-session-secret.at-least-32-bytes.ok",
83
103
  name: "auth.sid",
84
104
  maxAge: 3600000,
@@ -87,53 +107,45 @@ const config: AppConfig = {
87
107
  domain: null,
88
108
  storage: { type: "memory", redis: { url: "redis://localhost:6379" } },
89
109
  },
90
- rateLimit: {
91
- login: { windowMs: 60000, limit: 10 },
92
- failMode: "open",
93
- },
94
- federations: {
95
- google: { enabled: false },
96
- },
97
- repositories: {
98
- client: { type: "yaml", path: "./config/clients.yaml" },
99
- user: { type: "yaml", path: "./config/users.yaml", timeout: 5000 },
100
- code: { type: "memory", defaultExpiresIn: 600 },
101
- },
102
- endpoints: {
103
- login: { url: "/login" },
110
+ session: {
111
+ loginPage: { url: "/login" },
112
+ rateLimit: { login: { windowMs: 60000, limit: 10 } },
104
113
  },
105
- cors: { allowedOrigins: [] },
114
+ rateLimit: { failMode: "open" },
115
+ "standalone-in-memory-code-repository": { defaultExpiresIn: 600 },
106
116
  };
107
117
 
118
+ /** What phase one hands `buildModules`: the configuration, with `changes` over every store in process. */
119
+ const switchesWith = (changes: Partial<Adapters> = {}): Switches =>
120
+ ({
121
+ ...config,
122
+ adapters: { ...inProcessAdapters(), ...changes },
123
+ mfaMode: "off",
124
+ }) as unknown as Switches;
125
+
126
+ const switches = switchesWith();
127
+
108
128
  /**
109
- * Test-only repository module — bypasses the file-system-backed repositories
110
- * that `repositoriesModule` provides in production. Smoke tests use in-memory
111
- * empty repositories to verify the boot pipeline shape, not the data layer.
112
- *
113
- * Post-OR-9 (Wave 5d): `codeRepository` is no longer provided here — it is
114
- * wired by `inMemoryCodeRepositoryModule` (or `redisCodeRepositoryModule`)
115
- * via the `oauth.code.adapter` switch in `buildModules`. The smoke test
116
- * config sets `oauth.code.adapter = "memory"`, so `inMemoryCodeRepositoryModule`
117
- * is added automatically and provides the slot.
118
- *
119
- * D-6 (v0.5.1): a single confidential client is registered so the
120
- * `clientAuthMw` middleware in front of `/oauth/token` has a record to
121
- * authenticate against. Smoke tests that hit `/oauth/token` send
122
- * `Authorization: ${SMOKE_BASIC_AUTH}`.
129
+ * The one confidential client, so the `clientAuthMw` middleware in front of
130
+ * `/oauth/token` has a record to authenticate against. Smoke tests that hit
131
+ * `/oauth/token` send `Authorization: ${SMOKE_BASIC_AUTH}`.
123
132
  */
124
133
  const SMOKE_CLIENT_ID = "smoke-client";
125
134
  const SMOKE_CLIENT_SECRET = "smoke-secret";
126
135
  const SMOKE_BASIC_AUTH = `Basic ${Buffer.from(`${SMOKE_CLIENT_ID}:${SMOKE_CLIENT_SECRET}`).toString("base64")}`;
127
136
 
137
+ /**
138
+ * Test-only repository module in place of the file-system-backed repositories
139
+ * `repositoriesModule` provides in production: the smoke tests verify the
140
+ * boot pipeline shape, not the data layer. `codeRepository` is not provided
141
+ * here; the `adapters.codeRepository` selection in `buildModules` wires it.
142
+ */
128
143
  const testRepositoriesModule = defineModule({
129
144
  name: "test:repositories",
130
145
  provides: {
131
- // `InMemoryClientRepository` constructor parameter `Map<string, ClientEntry>`
132
- // uses `ClientEntry = z.infer<typeof ClientEntrySchema>` (the schema's
133
- // OUTPUT type) — fields with `.default(...)` are non-optional in the
134
- // output even though they are optional on input. The fixture supplies
135
- // every field explicitly so the literal satisfies the output shape
136
- // without requiring a cast.
146
+ // The map takes `ClientEntry`, the schema's OUTPUT type, where fields
147
+ // with `.default(...)` are required; every field is supplied so the
148
+ // literal needs no cast.
137
149
  clientRepository: () =>
138
150
  new InMemoryClientRepository(
139
151
  new Map([
@@ -165,7 +177,7 @@ const testKeyStoreModule = defineModule({
165
177
  registerBuiltinKeyStores(factory);
166
178
  return factory.create({
167
179
  type: "local",
168
- ...((c as AppConfig).oauth.jwt.signingKey.local ?? {}),
180
+ ...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
169
181
  });
170
182
  },
171
183
  },
@@ -176,14 +188,11 @@ describe("standalone smoke test", () => {
176
188
 
177
189
  async function buildApp() {
178
190
  const handle = await createApp({
179
- modules: buildModules(config, {
191
+ modules: buildModules(switches, {
180
192
  keyStoreModule: testKeyStoreModule,
181
193
  repositoriesModule: testRepositoriesModule,
182
- // D-2 v2: smoke tests stay in-memory for the RT family store.
183
- // Production composition root pairs `refreshTokenFamilyClientModule`
184
- // with `redisRefreshTokenFamilyStoreModule`; this override replaces
185
- // the pair entirely with a single memory-backed store module so no
186
- // real ioredis connection is opened during CI unit tests.
194
+ // A memory RT family store in place of the default Redis store and
195
+ // its client, so no real ioredis connection is opened.
187
196
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
188
197
  }),
189
198
  bootstrapComponents: { config, pathResolver: (s) => s },
@@ -194,7 +203,7 @@ describe("standalone smoke test", () => {
194
203
  app.use(
195
204
  createReadinessRouter(express, {
196
205
  probes: handle.readinessProbes,
197
- timeoutMs: config.http.readinessTimeoutMs,
206
+ timeoutMs: HTTP.readinessTimeoutMs,
198
207
  }),
199
208
  );
200
209
  app.use(handle.router);
@@ -207,16 +216,11 @@ describe("standalone smoke test", () => {
207
216
  });
208
217
 
209
218
  it("Dockerfile declares runtime metadata and runs install/build steps as node", () => {
210
- // Default port is 3000 but the HEALTHCHECK and EXPOSE must read it
211
- // from ${HTTP_PORT} so an operator overriding the env var keeps the
212
- // app, EXPOSE metadata, and healthcheck in sync. Assert the ENV /
213
- // EXPOSE / HEALTHCHECK structure rather than the literal port so the
214
- // test is not brittle when operators or downstream forks change the
215
- // default.
216
- // The literal `${HTTP_PORT}` token from the Dockerfile is split across
217
- // concatenations to sidestep biome's `noTemplateCurlyInString` rule
218
- // (it cannot statically tell that this string is intentionally NOT
219
- // a JS template placeholder — it is a Dockerfile env-substitution).
219
+ // HEALTHCHECK and EXPOSE must read the port from ${HTTP_PORT} so an
220
+ // operator overriding it keeps the app, EXPOSE and the healthcheck in
221
+ // sync; they are asserted by that token, not the literal port. The token
222
+ // is built by concatenation to sidestep biome's
223
+ // `noTemplateCurlyInString`: it is a Dockerfile substitution, not JS.
220
224
  const HTTP_PORT_VAR = `$${"{"}HTTP_PORT}`;
221
225
  expect(dockerfile).toContain("ENV HTTP_PORT=3000");
222
226
  expect(dockerfile).toContain(`EXPOSE ${HTTP_PORT_VAR}`);
@@ -233,7 +237,7 @@ describe("standalone smoke test", () => {
233
237
  /FROM node-base AS deps[\s\S]*USER node[\s\S]*RUN pnpm install --frozen-lockfile/,
234
238
  );
235
239
  expect(dockerfile).toMatch(/FROM deps AS builder[\s\S]*USER node[\s\S]*RUN pnpm run build/);
236
- // #289: dependencies are resolved exactly once, in `deps`. The runtime
240
+ // Dependencies are resolved exactly once, in `deps`. The runtime
237
241
  // stage must take its node_modules from a prune of that same tree — a
238
242
  // second `pnpm install` would be a second resolution, so its absence
239
243
  // after the runtime FROM is part of the contract.
@@ -248,12 +252,12 @@ describe("standalone smoke test", () => {
248
252
  );
249
253
  });
250
254
 
251
- it("Dockerfile pins its mutable build inputs (#289)", () => {
255
+ it("Dockerfile pins its mutable build inputs", () => {
252
256
  // Base image by digest — the tag alone is a moving pointer, and
253
257
  // Dependabot's docker ecosystem bumps tag and digest together.
254
258
  expect(dockerfile).toMatch(/FROM node:26-alpine@sha256:[0-9a-f]{64} AS node-base/);
255
259
  // Global corepack by version — `npm install -g corepack` with no pin
256
- // resolved whatever was latest at build time.
260
+ // resolves whatever is latest at build time.
257
261
  expect(dockerfile).toMatch(/npm install -g corepack@\d+\.\d+\.\d+ --force/);
258
262
  // The lockfile is a required build input for --frozen-lockfile.
259
263
  expect(dockerfile).toMatch(/COPY --chown=node:node package\.json pnpm-lock\.yaml/);
@@ -290,7 +294,7 @@ describe("standalone smoke test", () => {
290
294
  },
291
295
  },
292
296
  ],
293
- timeoutMs: config.http.readinessTimeoutMs,
297
+ timeoutMs: HTTP.readinessTimeoutMs,
294
298
  }),
295
299
  );
296
300
  const res = await request(app).get("/readyz");
@@ -300,15 +304,11 @@ describe("standalone smoke test", () => {
300
304
  });
301
305
 
302
306
  it("issuer-configured buildModules serves the advertised jwks_uri (discovery <-> JWKS presence contract)", async () => {
303
- // Guards the cross-module contract end-to-end against the REAL composition
304
- // root: core's `jwksModule` contributes `jwks_uri` to discovery AND mounts
305
- // the JWKS route, while `oauthModule` contributes the provider endpoints;
306
- // core's aggregator assembles the document. If `buildModules` ever drops
307
- // `jwksModule`, the issuer-configured composition now fails the discovery
308
- // presence contract at boot (missing `jwks_uri`) — this test fails (RED) in
309
- // that case. (The oauth package's integration test hand-composes both
310
- // modules, so only a scaffold-level test like this can catch the
311
- // composition root forgetting to wire jwksModule.)
307
+ // Against the REAL composition root: core's `jwksModule` contributes
308
+ // `jwks_uri` to discovery AND mounts the JWKS route. Without it the
309
+ // issuer-configured composition fails the discovery presence contract
310
+ // at boot. The oauth package's integration test composes both modules by
311
+ // hand, so only a scaffold-level test catches `buildModules` dropping it.
312
312
  const issuerConfig: AppConfig = {
313
313
  ...config,
314
314
  oauth: {
@@ -317,11 +317,14 @@ describe("standalone smoke test", () => {
317
317
  },
318
318
  };
319
319
  const handle = await createApp({
320
- modules: buildModules(issuerConfig, {
321
- keyStoreModule: testKeyStoreModule,
322
- repositoriesModule: testRepositoriesModule,
323
- refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
324
- }),
320
+ modules: buildModules(
321
+ { ...switches, oauth: issuerConfig.oauth },
322
+ {
323
+ keyStoreModule: testKeyStoreModule,
324
+ repositoriesModule: testRepositoriesModule,
325
+ refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
326
+ },
327
+ ),
325
328
  bootstrapComponents: { config: issuerConfig, pathResolver: (s) => s },
326
329
  });
327
330
  handleRef = handle;
@@ -333,7 +336,7 @@ describe("standalone smoke test", () => {
333
336
  const res = await request(app).get(jwksPath);
334
337
  expect(res.status).toBe(200);
335
338
  expect(Array.isArray(res.body.keys)).toBe(true);
336
- // #282: presence is no longer enough — the advertised jwks_uri must
339
+ // Presence is not enough — the advertised jwks_uri must
337
340
  // publish an actual verification key, and never the private half.
338
341
  expect(res.body.keys).toHaveLength(1);
339
342
  expect(res.body.keys[0].alg).toBe("EdDSA");
@@ -364,27 +367,22 @@ describe("standalone smoke test", () => {
364
367
  expect(res.headers["cache-control"]).not.toBe("no-store");
365
368
  });
366
369
 
367
- // Regression guard: a freshly scaffolded standalone app must boot under
368
- // the default `federations.google.enabled = false`. The earlier shape of
369
- // app.mts unconditionally listed `googleFederationModule` and the config
370
- // bridge `googleFederationConfigModule`, so the bridge's
371
- // `extractFederationSection` returned undefined and threw at boot. The
372
- // fix moved the manifest assembly into `buildModules`, which conditionally
373
- // includes the federation pair. The first sub-assertion asserts directly
374
- // on the manifest list — a regression that bypasses the gate fails the
375
- // `not.toContain` even if `googleFederationConfigModule` is later made
376
- // tolerant of `undefined` (which would otherwise mask the gating bug).
377
- // The second sub-assertion verifies the resulting handle still boots
378
- // (Codex Round 1 P1 reproducibility).
370
+ // A freshly scaffolded app must boot under the default
371
+ // `core.federations.google.enabled = false`. `buildModules` includes
372
+ // `googleFederationModule` and its config bridge only when enabled, since
373
+ // the bridge throws at boot when `extractFederationSection` returns
374
+ // undefined. The manifest is asserted directly, so a bypassed gate fails
375
+ // even if the bridge is later made tolerant of `undefined`; then the
376
+ // handle must still boot.
379
377
  it("boots when google federation is disabled (default scaffold config)", async () => {
380
- const modules = buildModules(config, {
378
+ const modules = buildModules(switches, {
381
379
  keyStoreModule: testKeyStoreModule,
382
380
  repositoriesModule: testRepositoriesModule,
383
381
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
384
382
  });
385
383
  const moduleNames = modules.map((m) => m.name);
386
- expect(moduleNames).not.toContain("federation:google");
387
- expect(moduleNames).not.toContain("standalone:google-federation-config");
384
+ expect(moduleNames).not.toContain("federation-google");
385
+ expect(moduleNames).not.toContain("google-federation-config");
388
386
 
389
387
  const handle = await createApp({
390
388
  modules,
@@ -394,40 +392,36 @@ describe("standalone smoke test", () => {
394
392
  expect(handle).toBeDefined();
395
393
  });
396
394
 
397
- // D-2 v2 / OR-1 closure: the standalone composition root must default to the
398
- // Redis-backed RT family store + ioredis client module pair, NOT the
399
- // in-memory store. Multi-replica deployments lose RT family persistence
400
- // when each replica holds families in-process; this test guards the
401
- // production default. The override path is exercised by the smoke tests
402
- // above (which substitute the memory store for CI).
403
- describe("D-2 v2 + Wave 5d: redis-clients + adapter wiring", () => {
395
+ // The composition root must default to the Redis-backed RT family store
396
+ // and the ioredis client module, NOT the in-memory store: multi-replica
397
+ // deployments lose RT family persistence when each replica holds families
398
+ // in-process. The smoke tests above exercise the memory override.
399
+ describe("redis-clients + adapter wiring", () => {
404
400
  it("buildModules includes the shared redis-clients module + redis store by default", () => {
405
- const modules = buildModules(config, {
401
+ const modules = buildModules(switches, {
406
402
  keyStoreModule: testKeyStoreModule,
407
403
  repositoriesModule: testRepositoriesModule,
408
404
  });
409
405
  const names = modules.map((m) => m.name);
410
- // Wave 5d: single shared ioredis client module (was per-purpose
411
- // `standalone:refresh-token-family-client` in PR1).
412
- expect(names).toContain("standalone:redis-clients");
406
+ expect(names).toContain("redis-clients");
413
407
  expect(names).toContain("redis-refresh-token-family-store");
414
408
  expect(names).not.toContain("core-refresh-token-family-store-memory");
415
409
  });
416
410
 
417
411
  it("buildModules drops the shared redis-clients module when override forces memory-only", () => {
418
- const modules = buildModules(config, {
412
+ const modules = buildModules(switches, {
419
413
  keyStoreModule: testKeyStoreModule,
420
414
  repositoriesModule: testRepositoriesModule,
421
415
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
422
416
  });
423
417
  const names = modules.map((m) => m.name);
424
418
  expect(names).toContain("core-refresh-token-family-store-memory");
425
- expect(names).not.toContain("standalone:redis-clients");
419
+ expect(names).not.toContain("redis-clients");
426
420
  expect(names).not.toContain("redis-refresh-token-family-store");
427
421
  });
428
422
 
429
- it("buildModules wires memoryRateLimiterModule by default (closes IH-14)", () => {
430
- const modules = buildModules(config, {
423
+ it("buildModules wires memoryRateLimiterModule by default", () => {
424
+ const modules = buildModules(switches, {
431
425
  keyStoreModule: testKeyStoreModule,
432
426
  repositoriesModule: testRepositoriesModule,
433
427
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
@@ -437,12 +431,8 @@ describe("standalone smoke test", () => {
437
431
  expect(names).not.toContain("redis-rate-limiter");
438
432
  });
439
433
 
440
- it("buildModules switches to redisRateLimiterModule when rateLimiter.adapter = 'redis'", () => {
441
- const redisRlConfig = {
442
- ...config,
443
- rateLimiter: { adapter: "redis" as const },
444
- };
445
- const modules = buildModules(redisRlConfig, {
434
+ it("buildModules switches to redisRateLimiterModule when adapters.rateLimiter = 'redis'", () => {
435
+ const modules = buildModules(switchesWith({ rateLimiter: "redis" }), {
446
436
  keyStoreModule: testKeyStoreModule,
447
437
  repositoriesModule: testRepositoriesModule,
448
438
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
@@ -451,44 +441,35 @@ describe("standalone smoke test", () => {
451
441
  expect(names).toContain("redis-rate-limiter");
452
442
  expect(names).not.toContain("core-rate-limiter-memory");
453
443
  // Adapter = redis pulls in the shared redis-clients module too.
454
- expect(names).toContain("standalone:redis-clients");
444
+ expect(names).toContain("redis-clients");
455
445
  });
456
446
 
457
- it("buildModules switches to redisSessionStoresModule when userSessionStores.adapter = 'redis'", () => {
458
- const redisSessConfig = {
459
- ...config,
460
- userSessionStores: { adapter: "redis" as const },
461
- };
462
- const modules = buildModules(redisSessConfig, {
447
+ it("buildModules switches to redisSessionStoresModule when adapters.userSessionStores = 'redis'", () => {
448
+ const modules = buildModules(switchesWith({ userSessionStores: "redis" }), {
463
449
  keyStoreModule: testKeyStoreModule,
464
450
  repositoriesModule: testRepositoriesModule,
465
451
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
466
452
  });
467
453
  const names = modules.map((m) => m.name);
468
- expect(names).toContain("redisSessionStores");
469
- expect(names).not.toContain("standalone:stores");
470
- expect(names).toContain("standalone:redis-clients");
454
+ expect(names).toContain("redis-session-stores");
455
+ expect(names).not.toContain("stores");
456
+ expect(names).toContain("redis-clients");
471
457
  });
472
458
 
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
- consentStore: { adapter: "redis" as const },
490
- oauth: { ...config.oauth, code: { adapter: "redis" as const } },
491
- };
459
+ // The tests above check which modules are selected, not whether the
460
+ // selection is satisfiable: a `*Client` slot with no provider (such as
461
+ // `redisSessionStoresModule`'s `subjectSessionIndexClient`) fails
462
+ // stage-1 boot with `missing-required-component`. The invariant the
463
+ // shared clients module exists for: every `*Client` slot any selected
464
+ // module requires is one it provides.
465
+ it("redis-clients provides every *Client slot the Redis branches require", () => {
466
+ const allRedisConfig = switchesWith({
467
+ userSessionStores: "redis",
468
+ rateLimiter: "redis",
469
+ accessTokenDenylist: "redis",
470
+ consentStore: "redis",
471
+ codeRepository: "redis",
472
+ });
492
473
  // No `refreshTokenFamilyModules` override: the default is the Redis
493
474
  // RT-family store, so `refreshTokenFamilyClient` is part of what the
494
475
  // invariant covers. Still hermetic — buildModules opens no socket.
@@ -496,7 +477,7 @@ describe("standalone smoke test", () => {
496
477
  keyStoreModule: testKeyStoreModule,
497
478
  repositoriesModule: testRepositoriesModule,
498
479
  });
499
- const clients = modules.find((m) => m.name === "standalone:redis-clients");
480
+ const clients = modules.find((m) => m.name === "redis-clients");
500
481
  expect(clients).toBeDefined();
501
482
  const provided = new Set(Object.keys(clients?.provides ?? {}));
502
483
  const requiredClientSlots = new Set(
@@ -508,14 +489,14 @@ describe("standalone smoke test", () => {
508
489
  });
509
490
  });
510
491
 
511
- // #277: the scaffold used to wire NO access-token denylist, so the
512
- // `/oauth/revoke` it shipped answered 200 for an access token and left the
513
- // JWT working until expiry. The template now always wires one, and its own
514
- // application.conf selects the Redis-backed adapter — the memory one forks
515
- // per replica and `deployment.mode = "multi"` refuses it.
516
- describe("#277: access-token denylist wiring", () => {
492
+ // Without an access-token denylist, `/oauth/revoke` would answer 200 for
493
+ // an access token and leave the JWT working until expiry. The template
494
+ // always wires one, and its own application.conf selects the Redis-backed
495
+ // adapter — the memory one forks per replica and `core.deployment.mode =
496
+ // "multi"` refuses it.
497
+ describe("access-token denylist wiring", () => {
517
498
  it("always wires a denylist, so /oauth/revoke can keep its promise", () => {
518
- const modules = buildModules(config, {
499
+ const modules = buildModules(switches, {
519
500
  keyStoreModule: testKeyStoreModule,
520
501
  repositoriesModule: testRepositoriesModule,
521
502
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
@@ -527,7 +508,7 @@ describe("standalone smoke test", () => {
527
508
  });
528
509
 
529
510
  it("defaults to the memory denylist when no adapter is configured", () => {
530
- const modules = buildModules(config, {
511
+ const modules = buildModules(switches, {
531
512
  keyStoreModule: testKeyStoreModule,
532
513
  repositoriesModule: testRepositoriesModule,
533
514
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
@@ -537,12 +518,8 @@ describe("standalone smoke test", () => {
537
518
  expect(names).not.toContain("redis-access-token-denylist");
538
519
  });
539
520
 
540
- it("switches to the Redis denylist when accessTokenDenylist.adapter = 'redis'", () => {
541
- const redisDenylistConfig = {
542
- ...config,
543
- accessTokenDenylist: { adapter: "redis" as const },
544
- };
545
- const modules = buildModules(redisDenylistConfig, {
521
+ it("switches to the Redis denylist when adapters.accessTokenDenylist = 'redis'", () => {
522
+ const modules = buildModules(switchesWith({ accessTokenDenylist: "redis" }), {
546
523
  keyStoreModule: testKeyStoreModule,
547
524
  repositoriesModule: testRepositoriesModule,
548
525
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
@@ -553,25 +530,24 @@ describe("standalone smoke test", () => {
553
530
  // The Redis branch must also pull in the shared ioredis socket, or the
554
531
  // `accessTokenDenylistClient` slot has no provider and boot fails on a
555
532
  // missing component instead of on the thing the operator changed.
556
- expect(names).toContain("standalone:redis-clients");
533
+ expect(names).toContain("redis-clients");
557
534
  });
558
535
 
559
- it("the shipped application.conf selects the replica-safe adapter", () => {
536
+ it("the template's reference.conf selects the replica-safe adapter", () => {
560
537
  // The template's own config is the artifact operators deploy. It runs
561
- // with `deployment.mode = multi` in the umbrella E2E, which refuses
538
+ // with `core.deployment.mode = multi` in the umbrella E2E, which refuses
562
539
  // every in-memory shared store — so shipping the memory denylist here
563
540
  // would be a boot failure in the very stack that proves the scaffold
564
541
  // works.
565
- const conf = readFileSync(new URL("../../config/application.conf", import.meta.url), "utf8");
566
- expect(conf).toMatch(/accessTokenDenylist\s*\{[\s\S]*?adapter\s*=\s*"redis"/);
542
+ expect(shippedAdapters().accessTokenDenylist).toBe("redis");
567
543
  });
568
544
 
569
545
  it("keeps the composition boot-valid end to end", async () => {
570
- // The core boot guard (#277 step 13.9) refuses a composition that reads
546
+ // The core boot guard refuses a composition that reads
571
547
  // the denylist slot without one. A scaffold that trips its own library's
572
548
  // guard is not a scaffold.
573
549
  const handle = await createApp({
574
- modules: buildModules(config, {
550
+ modules: buildModules(switches, {
575
551
  keyStoreModule: testKeyStoreModule,
576
552
  repositoriesModule: testRepositoriesModule,
577
553
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
@@ -583,81 +559,51 @@ describe("standalone smoke test", () => {
583
559
  });
584
560
  });
585
561
 
586
- // OR-9 (Wave 5d): adapter switch for the OAuth code repository. Closes
587
- // the deferred Phase 10 module-pattern wrapper for `codeRepository`. The
588
- // memory branch wires `inMemoryCodeRepositoryModule`; the redis branch
589
- // wires `redisCodeRepositoryModule` against the shared ioredis socket
590
- // (same `standaloneRedisClientsModule` as the RT family / rate limiter).
591
- describe("OR-9: code-repository adapter wiring", () => {
592
- it("buildModules wires inMemoryCodeRepositoryModule by default (oauth.code.adapter = 'memory')", () => {
593
- const modules = buildModules(config, {
562
+ // The OAuth code repository's selection: the memory branch wires
563
+ // `inMemoryCodeRepositoryModule`; the redis branch wires
564
+ // `redisCodeRepositoryModule` against the shared ioredis socket.
565
+ describe("code-repository adapter wiring", () => {
566
+ it("buildModules wires inMemoryCodeRepositoryModule when adapters.codeRepository = 'memory'", () => {
567
+ const modules = buildModules(switches, {
594
568
  keyStoreModule: testKeyStoreModule,
595
569
  repositoriesModule: testRepositoriesModule,
596
570
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
597
571
  });
598
572
  const names = modules.map((m) => m.name);
599
- expect(names).toContain("standalone:in-memory-code-repository");
573
+ expect(names).toContain("standalone-in-memory-code-repository");
600
574
  expect(names).not.toContain("redis-code-repository");
601
575
  });
602
576
 
603
- it("buildModules switches to redisCodeRepositoryModule when oauth.code.adapter = 'redis'", () => {
604
- const redisCodeConfig = {
605
- ...config,
606
- oauth: { ...config.oauth, code: { adapter: "redis" as const } },
607
- };
608
- const modules = buildModules(redisCodeConfig, {
577
+ it("buildModules switches to redisCodeRepositoryModule when adapters.codeRepository = 'redis'", () => {
578
+ const modules = buildModules(switchesWith({ codeRepository: "redis" }), {
609
579
  keyStoreModule: testKeyStoreModule,
610
580
  repositoriesModule: testRepositoriesModule,
611
581
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
612
582
  });
613
583
  const names = modules.map((m) => m.name);
614
584
  expect(names).toContain("redis-code-repository");
615
- expect(names).not.toContain("standalone:in-memory-code-repository");
585
+ expect(names).not.toContain("standalone-in-memory-code-repository");
616
586
  // adapter = "redis" pulls in the shared ioredis socket so the
617
587
  // `codeRepositoryClient` slot is satisfied.
618
- expect(names).toContain("standalone:redis-clients");
619
- });
620
-
621
- it("buildModules honors legacy repositories.code.type='redis' with deprecation warn when oauth.code.adapter is absent", () => {
622
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
623
- try {
624
- const legacyConfig = {
625
- ...config,
626
- oauth: { ...config.oauth, code: undefined },
627
- repositories: {
628
- ...config.repositories,
629
- code: { type: "redis" as const, defaultExpiresIn: 600 },
630
- },
631
- };
632
- const modules = buildModules(legacyConfig, {
633
- keyStoreModule: testKeyStoreModule,
634
- repositoriesModule: testRepositoriesModule,
635
- refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
636
- });
637
- const names = modules.map((m) => m.name);
638
- expect(names).toContain("redis-code-repository");
639
- expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("repositories.code.type"));
640
- } finally {
641
- warnSpy.mockRestore();
642
- }
588
+ expect(names).toContain("redis-clients");
643
589
  });
644
590
 
645
591
  it("buildModules has only ONE codeRepository provider in the manifest (no slot collision)", () => {
646
- // Regression guard: `repositoriesModule` must NOT provide
647
- // `codeRepository` post-OR-9 — the slot is owned by
648
- // `inMemoryCodeRepositoryModule` or `redisCodeRepositoryModule`
649
- // exclusively, selected via `oauth.code.adapter`.
650
- const modules = buildModules(config, {
592
+ // The `repositories` module must NOT provide `codeRepository` — the
593
+ // slot is owned by `inMemoryCodeRepositoryModule` or
594
+ // `redisCodeRepositoryModule` exclusively, selected via
595
+ // `adapters.codeRepository`.
596
+ const modules = buildModules(switches, {
651
597
  keyStoreModule: testKeyStoreModule,
652
- // Use the production repositoriesModule (not the test override)
653
- // so we exercise its post-OR-9 provides-shape.
598
+ // The production `repositories` module (not the test override), so
599
+ // its own provides are exercised.
654
600
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
655
601
  });
656
602
  const codeRepoProviders = modules.filter((m) =>
657
603
  Object.keys(m.provides ?? {}).includes("codeRepository"),
658
604
  );
659
605
  expect(codeRepoProviders).toHaveLength(1);
660
- expect(codeRepoProviders[0]?.name).toBe("standalone:in-memory-code-repository");
606
+ expect(codeRepoProviders[0]?.name).toBe("standalone-in-memory-code-repository");
661
607
  });
662
608
  });
663
609
 
@@ -669,10 +615,10 @@ describe("standalone smoke test", () => {
669
615
  registerBuiltinKeyStores(ksf);
670
616
  const keyStore = await ksf.create({
671
617
  type: "local",
672
- ...(config.oauth.jwt.signingKey.local ?? {}),
618
+ ...(config["key-store"] as { local: object }).local,
673
619
  });
674
620
  // The token must carry the deployment's configured `iss`: introspection
675
- // pins it (RFC 9068 §4), and since #266 every deployment has one.
621
+ // pins it (RFC 9068 §4), and every deployment has one.
676
622
  const { token } = await generateToken(
677
623
  {},
678
624
  {