@o3co/create-auth-provider 0.16.0 → 0.17.0-rc.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.
Files changed (100) hide show
  1. package/README.ja.md +66 -21
  2. package/README.md +76 -31
  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 +59 -29
  12. package/templates/standalone/Dockerfile +6 -2
  13. package/templates/standalone/README.ja.md +324 -169
  14. package/templates/standalone/README.md +618 -208
  15. package/templates/standalone/config/application.conf +211 -352
  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 +418 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +63 -32
  24. package/templates/standalone/docker-compose.yml +13 -3
  25. package/templates/standalone/gitignore +1 -0
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/adapters.test.mts +419 -0
  28. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +287 -198
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +71 -35
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +148 -94
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +84 -81
  33. package/templates/standalone/src/__tests__/config-notices.test.mts +154 -0
  34. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  35. package/templates/standalone/src/__tests__/core-federations.test.mts +249 -0
  36. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  37. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +10 -15
  38. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +970 -288
  39. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +421 -86
  40. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +87 -72
  41. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +16 -8
  42. package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
  43. package/templates/standalone/src/__tests__/google-session.fixture.mts +97 -93
  44. package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
  45. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
  46. package/templates/standalone/src/__tests__/library-references.fixture.mts +188 -0
  47. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
  48. package/templates/standalone/src/__tests__/listen.test.mts +6 -13
  49. package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
  50. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  51. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +326 -0
  52. package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
  53. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
  54. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  55. package/templates/standalone/src/__tests__/mfa-switch.test.mts +825 -0
  56. package/templates/standalone/src/__tests__/own-modules.test.mts +1486 -0
  57. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +10 -14
  58. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
  59. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +116 -81
  60. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  61. package/templates/standalone/src/__tests__/replica-safety.test.mts +265 -146
  62. package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
  63. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +278 -103
  64. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  65. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
  66. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +69 -61
  67. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
  68. package/templates/standalone/src/__tests__/session-requirements.test.mts +382 -0
  69. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +270 -0
  70. package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
  71. package/templates/standalone/src/__tests__/smoke.test.mts +176 -258
  72. package/templates/standalone/src/__tests__/store-credential.test.mts +88 -52
  73. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  74. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +131 -102
  75. package/templates/standalone/src/__tests__/two-phase-config.test.mts +604 -0
  76. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +382 -0
  77. package/templates/standalone/src/adapters.mts +162 -0
  78. package/templates/standalone/src/app.mts +65 -79
  79. package/templates/standalone/src/bootRefusal.mts +117 -0
  80. package/templates/standalone/src/buildModules.mts +220 -309
  81. package/templates/standalone/src/configPath.mts +449 -29
  82. package/templates/standalone/src/listen.mts +12 -18
  83. package/templates/standalone/src/logger.mts +31 -60
  84. package/templates/standalone/src/metrics.mts +24 -47
  85. package/templates/standalone/src/mfaSwitch.mts +285 -0
  86. package/templates/standalone/src/modules.mts +423 -629
  87. package/templates/standalone/src/rootRenames.mts +96 -0
  88. package/templates/standalone/src/routes.mts +8 -14
  89. package/templates/standalone/src/sections.mts +367 -0
  90. package/templates/standalone/src/shutdown.mts +45 -123
  91. package/templates/standalone/tests/index.test.js +6 -5
  92. package/templates/standalone/tests/package-lock.json +1615 -2223
  93. package/templates/standalone/tests/package.json +2 -2
  94. package/templates/standalone/tests/vitest.config.mjs +26 -0
  95. package/templates/standalone/vitest.config.mts +8 -7
  96. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  97. package/templates/versions.json +18 -15
  98. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -135
  99. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -199
  100. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +0 -138
@@ -29,14 +29,23 @@ import {
29
29
  memoryRefreshTokenFamilyStoreModule,
30
30
  registerBuiltinKeyStores,
31
31
  } from "@o3co/auth-provider-core";
32
+ import { coreConfigForTests } from "@o3co/auth-provider-core/testing";
33
+ import type { OAuthSection } from "@o3co/auth-provider-oauth";
32
34
  import express from "express";
33
35
  import request from "supertest";
34
- import { afterEach, describe, expect, it, vi } from "vitest";
36
+ import { afterEach, describe, expect, it } from "vitest";
35
37
  import { buildModules } from "../buildModules.mjs";
38
+ import type { Switches } from "../configPath.mjs";
39
+ import type { Adapters } from "../sections.mjs";
40
+ import {
41
+ capturedRenames,
42
+ inProcessAdapters,
43
+ shippedAdapters,
44
+ } from "./library-references.fixture.mjs";
36
45
 
37
46
  const dockerfile = readFileSync(new URL("../../Dockerfile", import.meta.url), "utf8");
38
47
 
39
- // #282: the template's shipped default is EdDSA with a published JWKS, so the
48
+ // The template's shipped default is EdDSA with a published JWKS, so the
40
49
  // smoke test signs the way a real deployment of this scaffold does. Generated
41
50
  // per run — no key material is committed.
42
51
  const smokeKeyPair = generateKeyPairSync("ed25519", {
@@ -44,41 +53,49 @@ const smokeKeyPair = generateKeyPairSync("ed25519", {
44
53
  privateKeyEncoding: { type: "pkcs8", format: "pem" },
45
54
  });
46
55
 
47
- const config: AppConfig = {
48
- http: { port: 0, trustProxy: false, readinessTimeoutMs: 1000 },
56
+ /** The `http` module's section, as the hand-built configuration below carries it. */
57
+ const HTTP = {
58
+ port: 0,
59
+ trustProxy: false,
60
+ readinessTimeoutMs: 1000,
61
+ cors: { allowedOrigins: [] as string[] },
62
+ };
63
+
64
+ /** The template modules' sections sit beside core's, so the configuration is wider than `AppConfig`. */
65
+ const config: AppConfig & Record<string, unknown> = {
66
+ // What a resolution under an environment that sets none captures of
67
+ // core's renamed variables.
68
+ ...{
69
+ "renamed-variables": capturedRenames({}),
70
+ },
71
+ http: HTTP,
49
72
  logging: { level: "silent" },
73
+ "key-store": {
74
+ provider: "local",
75
+ local: {
76
+ algorithm: "EdDSA",
77
+ kid: "v0",
78
+ privateKey: smokeKeyPair.privateKey,
79
+ publicKey: smokeKeyPair.publicKey,
80
+ previousKeys: [],
81
+ },
82
+ },
83
+ // A composition with a consumer of session admission states what it
84
+ // expects (ADR 2026-09-28-session-admission): the shipped
85
+ // `application.conf` expects none, and so does this hand-built config.
86
+ ...coreConfigForTests({ federations: { google: { enabled: false, type: "google" } } }),
50
87
  oauth: {
51
88
  jwt: {
52
89
  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
- },
64
- accessToken: { expiresIn: 3600 },
65
- refreshToken: {
66
- expiresIn: 86400,
67
- unknownFamilyPolicy: "reject" as const,
68
- legacyRtPolicy: "reject" as const,
69
90
  },
91
+ accessToken: { defaultExpiresIn: 3600 },
92
+ refreshToken: { expiresIn: 86400 },
70
93
  grants: {},
71
94
  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 },
95
+ // No `authorize` section: the first-party invariant is unconditional.
79
96
  },
80
- session: {
81
- // #282: `session.secret` carries a 256-bit entropy floor.
97
+ "session-store": {
98
+ // `session-store.secret` carries a 256-bit entropy floor.
82
99
  secret: "test-session-secret.at-least-32-bytes.ok",
83
100
  name: "auth.sid",
84
101
  maxAge: 3600000,
@@ -87,53 +104,45 @@ const config: AppConfig = {
87
104
  domain: null,
88
105
  storage: { type: "memory", redis: { url: "redis://localhost:6379" } },
89
106
  },
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" },
107
+ session: {
108
+ loginPage: { url: "/login" },
109
+ rateLimit: { login: { windowMs: 60000, limit: 10 } },
104
110
  },
105
- cors: { allowedOrigins: [] },
111
+ rateLimit: { failMode: "open" },
112
+ "standalone-in-memory-code-repository": { defaultExpiresIn: 600 },
106
113
  };
107
114
 
115
+ /** What phase one hands `buildModules`: the configuration, with `changes` over every store in process. */
116
+ const switchesWith = (changes: Partial<Adapters> = {}): Switches =>
117
+ ({
118
+ ...config,
119
+ adapters: { ...inProcessAdapters(), ...changes },
120
+ mfaMode: "off",
121
+ }) as unknown as Switches;
122
+
123
+ const switches = switchesWith();
124
+
108
125
  /**
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}`.
126
+ * The one confidential client, so the `clientAuthMw` middleware in front of
127
+ * `/oauth/token` has a record to authenticate against. Smoke tests that hit
128
+ * `/oauth/token` send `Authorization: ${SMOKE_BASIC_AUTH}`.
123
129
  */
124
130
  const SMOKE_CLIENT_ID = "smoke-client";
125
131
  const SMOKE_CLIENT_SECRET = "smoke-secret";
126
132
  const SMOKE_BASIC_AUTH = `Basic ${Buffer.from(`${SMOKE_CLIENT_ID}:${SMOKE_CLIENT_SECRET}`).toString("base64")}`;
127
133
 
134
+ /**
135
+ * Test-only repository module in place of the file-system-backed repositories
136
+ * `repositoriesModule` provides in production: the smoke tests verify the
137
+ * boot pipeline shape, not the data layer. `codeRepository` is not provided
138
+ * here; the `adapters.codeRepository` selection in `buildModules` wires it.
139
+ */
128
140
  const testRepositoriesModule = defineModule({
129
141
  name: "test:repositories",
130
142
  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.
143
+ // The map takes `ClientEntry`, the schema's OUTPUT type, where fields
144
+ // with `.default(...)` are required; every field is supplied so the
145
+ // literal needs no cast.
137
146
  clientRepository: () =>
138
147
  new InMemoryClientRepository(
139
148
  new Map([
@@ -165,7 +174,7 @@ const testKeyStoreModule = defineModule({
165
174
  registerBuiltinKeyStores(factory);
166
175
  return factory.create({
167
176
  type: "local",
168
- ...((c as AppConfig).oauth.jwt.signingKey.local ?? {}),
177
+ ...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
169
178
  });
170
179
  },
171
180
  },
@@ -176,14 +185,11 @@ describe("standalone smoke test", () => {
176
185
 
177
186
  async function buildApp() {
178
187
  const handle = await createApp({
179
- modules: buildModules(config, {
188
+ modules: buildModules(switches, {
180
189
  keyStoreModule: testKeyStoreModule,
181
190
  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.
191
+ // A memory RT family store in place of the default Redis store and
192
+ // its client, so no real ioredis connection is opened.
187
193
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
188
194
  }),
189
195
  bootstrapComponents: { config, pathResolver: (s) => s },
@@ -194,7 +200,7 @@ describe("standalone smoke test", () => {
194
200
  app.use(
195
201
  createReadinessRouter(express, {
196
202
  probes: handle.readinessProbes,
197
- timeoutMs: config.http.readinessTimeoutMs,
203
+ timeoutMs: HTTP.readinessTimeoutMs,
198
204
  }),
199
205
  );
200
206
  app.use(handle.router);
@@ -207,16 +213,11 @@ describe("standalone smoke test", () => {
207
213
  });
208
214
 
209
215
  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).
216
+ // HEALTHCHECK and EXPOSE must read the port from ${HTTP_PORT} so an
217
+ // operator overriding it keeps the app, EXPOSE and the healthcheck in
218
+ // sync; they are asserted by that token, not the literal port. The token
219
+ // is built by concatenation to sidestep biome's
220
+ // `noTemplateCurlyInString`: it is a Dockerfile substitution, not JS.
220
221
  const HTTP_PORT_VAR = `$${"{"}HTTP_PORT}`;
221
222
  expect(dockerfile).toContain("ENV HTTP_PORT=3000");
222
223
  expect(dockerfile).toContain(`EXPOSE ${HTTP_PORT_VAR}`);
@@ -233,7 +234,7 @@ describe("standalone smoke test", () => {
233
234
  /FROM node-base AS deps[\s\S]*USER node[\s\S]*RUN pnpm install --frozen-lockfile/,
234
235
  );
235
236
  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
237
+ // Dependencies are resolved exactly once, in `deps`. The runtime
237
238
  // stage must take its node_modules from a prune of that same tree — a
238
239
  // second `pnpm install` would be a second resolution, so its absence
239
240
  // after the runtime FROM is part of the contract.
@@ -248,12 +249,12 @@ describe("standalone smoke test", () => {
248
249
  );
249
250
  });
250
251
 
251
- it("Dockerfile pins its mutable build inputs (#289)", () => {
252
+ it("Dockerfile pins its mutable build inputs", () => {
252
253
  // Base image by digest — the tag alone is a moving pointer, and
253
254
  // Dependabot's docker ecosystem bumps tag and digest together.
254
255
  expect(dockerfile).toMatch(/FROM node:26-alpine@sha256:[0-9a-f]{64} AS node-base/);
255
256
  // Global corepack by version — `npm install -g corepack` with no pin
256
- // resolved whatever was latest at build time.
257
+ // resolves whatever is latest at build time.
257
258
  expect(dockerfile).toMatch(/npm install -g corepack@\d+\.\d+\.\d+ --force/);
258
259
  // The lockfile is a required build input for --frozen-lockfile.
259
260
  expect(dockerfile).toMatch(/COPY --chown=node:node package\.json pnpm-lock\.yaml/);
@@ -290,7 +291,7 @@ describe("standalone smoke test", () => {
290
291
  },
291
292
  },
292
293
  ],
293
- timeoutMs: config.http.readinessTimeoutMs,
294
+ timeoutMs: HTTP.readinessTimeoutMs,
294
295
  }),
295
296
  );
296
297
  const res = await request(app).get("/readyz");
@@ -300,24 +301,20 @@ describe("standalone smoke test", () => {
300
301
  });
301
302
 
302
303
  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.)
304
+ // Against the REAL composition root: core's `jwksModule` contributes
305
+ // `jwks_uri` to discovery AND mounts the JWKS route. Without it the
306
+ // issuer-configured composition fails the discovery presence contract
307
+ // at boot. The oauth package's integration test composes both modules by
308
+ // hand, so only a scaffold-level test catches `buildModules` dropping it.
312
309
  const issuerConfig: AppConfig = {
313
310
  ...config,
314
311
  oauth: {
315
- ...config.oauth,
316
- jwt: { ...config.oauth.jwt, issuer: "https://auth.example.com" },
312
+ ...(config.oauth as OAuthSection),
313
+ jwt: { ...(config.oauth as OAuthSection).jwt, issuer: "https://auth.example.com" },
317
314
  },
318
315
  };
319
316
  const handle = await createApp({
320
- modules: buildModules(issuerConfig, {
317
+ modules: buildModules(switches, {
321
318
  keyStoreModule: testKeyStoreModule,
322
319
  repositoriesModule: testRepositoriesModule,
323
320
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
@@ -333,7 +330,7 @@ describe("standalone smoke test", () => {
333
330
  const res = await request(app).get(jwksPath);
334
331
  expect(res.status).toBe(200);
335
332
  expect(Array.isArray(res.body.keys)).toBe(true);
336
- // #282: presence is no longer enough — the advertised jwks_uri must
333
+ // Presence is not enough — the advertised jwks_uri must
337
334
  // publish an actual verification key, and never the private half.
338
335
  expect(res.body.keys).toHaveLength(1);
339
336
  expect(res.body.keys[0].alg).toBe("EdDSA");
@@ -364,70 +361,58 @@ describe("standalone smoke test", () => {
364
361
  expect(res.headers["cache-control"]).not.toBe("no-store");
365
362
  });
366
363
 
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).
364
+ // A freshly scaffolded app must boot under the default
365
+ // `core.federations.google.enabled = false`. The federation types the
366
+ // template bundles are loaded whatever the configuration says, and with no
367
+ // entry enabled they handle nothing: no federation is registered.
379
368
  it("boots when google federation is disabled (default scaffold config)", async () => {
380
- const modules = buildModules(config, {
369
+ const modules = buildModules(switches, {
381
370
  keyStoreModule: testKeyStoreModule,
382
371
  repositoriesModule: testRepositoriesModule,
383
372
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
384
373
  });
385
374
  const moduleNames = modules.map((m) => m.name);
386
- expect(moduleNames).not.toContain("federation:google");
387
- expect(moduleNames).not.toContain("standalone:google-federation-config");
375
+ expect(moduleNames).toContain("federation-google-type");
376
+ expect(moduleNames).toContain("federation-oidc");
388
377
 
389
378
  const handle = await createApp({
390
379
  modules,
391
380
  bootstrapComponents: { config, pathResolver: (s) => s },
392
381
  });
393
382
  handleRef = handle;
394
- expect(handle).toBeDefined();
383
+ expect(handle.components.federationProviders?.size ?? 0).toBe(0);
395
384
  });
396
385
 
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", () => {
386
+ // The composition root must default to the Redis-backed RT family store
387
+ // and the ioredis client module, NOT the in-memory store: multi-replica
388
+ // deployments lose RT family persistence when each replica holds families
389
+ // in-process. The smoke tests above exercise the memory override.
390
+ describe("redis-clients + adapter wiring", () => {
404
391
  it("buildModules includes the shared redis-clients module + redis store by default", () => {
405
- const modules = buildModules(config, {
392
+ const modules = buildModules(switches, {
406
393
  keyStoreModule: testKeyStoreModule,
407
394
  repositoriesModule: testRepositoriesModule,
408
395
  });
409
396
  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");
397
+ expect(names).toContain("redis-clients");
413
398
  expect(names).toContain("redis-refresh-token-family-store");
414
399
  expect(names).not.toContain("core-refresh-token-family-store-memory");
415
400
  });
416
401
 
417
402
  it("buildModules drops the shared redis-clients module when override forces memory-only", () => {
418
- const modules = buildModules(config, {
403
+ const modules = buildModules(switches, {
419
404
  keyStoreModule: testKeyStoreModule,
420
405
  repositoriesModule: testRepositoriesModule,
421
406
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
422
407
  });
423
408
  const names = modules.map((m) => m.name);
424
409
  expect(names).toContain("core-refresh-token-family-store-memory");
425
- expect(names).not.toContain("standalone:redis-clients");
410
+ expect(names).not.toContain("redis-clients");
426
411
  expect(names).not.toContain("redis-refresh-token-family-store");
427
412
  });
428
413
 
429
- it("buildModules wires memoryRateLimiterModule by default (closes IH-14)", () => {
430
- const modules = buildModules(config, {
414
+ it("buildModules wires memoryRateLimiterModule by default", () => {
415
+ const modules = buildModules(switches, {
431
416
  keyStoreModule: testKeyStoreModule,
432
417
  repositoriesModule: testRepositoriesModule,
433
418
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
@@ -437,12 +422,8 @@ describe("standalone smoke test", () => {
437
422
  expect(names).not.toContain("redis-rate-limiter");
438
423
  });
439
424
 
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, {
425
+ it("buildModules switches to redisRateLimiterModule when adapters.rateLimiter = 'redis'", () => {
426
+ const modules = buildModules(switchesWith({ rateLimiter: "redis" }), {
446
427
  keyStoreModule: testKeyStoreModule,
447
428
  repositoriesModule: testRepositoriesModule,
448
429
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
@@ -451,44 +432,35 @@ describe("standalone smoke test", () => {
451
432
  expect(names).toContain("redis-rate-limiter");
452
433
  expect(names).not.toContain("core-rate-limiter-memory");
453
434
  // Adapter = redis pulls in the shared redis-clients module too.
454
- expect(names).toContain("standalone:redis-clients");
435
+ expect(names).toContain("redis-clients");
455
436
  });
456
437
 
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, {
438
+ it("buildModules switches to redisSessionStoresModule when adapters.userSessionStores = 'redis'", () => {
439
+ const modules = buildModules(switchesWith({ userSessionStores: "redis" }), {
463
440
  keyStoreModule: testKeyStoreModule,
464
441
  repositoriesModule: testRepositoriesModule,
465
442
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
466
443
  });
467
444
  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");
445
+ expect(names).toContain("redis-session-stores");
446
+ expect(names).not.toContain("standalone-in-memory-session-stores");
447
+ expect(names).toContain("redis-clients");
471
448
  });
472
449
 
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
- };
450
+ // The tests above check which modules are selected, not whether the
451
+ // selection is satisfiable: a `*Client` slot with no provider (such as
452
+ // `redisSessionStoresModule`'s `subjectSessionIndexClient`) fails
453
+ // stage-1 boot with `missing-required-component`. The invariant the
454
+ // shared clients module exists for: every `*Client` slot any selected
455
+ // module requires is one it provides.
456
+ it("redis-clients provides every *Client slot the Redis branches require", () => {
457
+ const allRedisConfig = switchesWith({
458
+ userSessionStores: "redis",
459
+ rateLimiter: "redis",
460
+ accessTokenDenylist: "redis",
461
+ consentStore: "redis",
462
+ codeRepository: "redis",
463
+ });
492
464
  // No `refreshTokenFamilyModules` override: the default is the Redis
493
465
  // RT-family store, so `refreshTokenFamilyClient` is part of what the
494
466
  // invariant covers. Still hermetic — buildModules opens no socket.
@@ -496,7 +468,7 @@ describe("standalone smoke test", () => {
496
468
  keyStoreModule: testKeyStoreModule,
497
469
  repositoriesModule: testRepositoriesModule,
498
470
  });
499
- const clients = modules.find((m) => m.name === "standalone:redis-clients");
471
+ const clients = modules.find((m) => m.name === "redis-clients");
500
472
  expect(clients).toBeDefined();
501
473
  const provided = new Set(Object.keys(clients?.provides ?? {}));
502
474
  const requiredClientSlots = new Set(
@@ -508,14 +480,14 @@ describe("standalone smoke test", () => {
508
480
  });
509
481
  });
510
482
 
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", () => {
483
+ // Without an access-token denylist, `/oauth/revoke` would answer 200 for
484
+ // an access token and leave the JWT working until expiry. The template
485
+ // always wires one, and its own application.conf selects the Redis-backed
486
+ // adapter — the memory one forks per replica and `core.deployment.mode =
487
+ // "multi"` refuses it.
488
+ describe("access-token denylist wiring", () => {
517
489
  it("always wires a denylist, so /oauth/revoke can keep its promise", () => {
518
- const modules = buildModules(config, {
490
+ const modules = buildModules(switches, {
519
491
  keyStoreModule: testKeyStoreModule,
520
492
  repositoriesModule: testRepositoriesModule,
521
493
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
@@ -527,7 +499,7 @@ describe("standalone smoke test", () => {
527
499
  });
528
500
 
529
501
  it("defaults to the memory denylist when no adapter is configured", () => {
530
- const modules = buildModules(config, {
502
+ const modules = buildModules(switches, {
531
503
  keyStoreModule: testKeyStoreModule,
532
504
  repositoriesModule: testRepositoriesModule,
533
505
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
@@ -537,12 +509,8 @@ describe("standalone smoke test", () => {
537
509
  expect(names).not.toContain("redis-access-token-denylist");
538
510
  });
539
511
 
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, {
512
+ it("switches to the Redis denylist when adapters.accessTokenDenylist = 'redis'", () => {
513
+ const modules = buildModules(switchesWith({ accessTokenDenylist: "redis" }), {
546
514
  keyStoreModule: testKeyStoreModule,
547
515
  repositoriesModule: testRepositoriesModule,
548
516
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
@@ -553,25 +521,24 @@ describe("standalone smoke test", () => {
553
521
  // The Redis branch must also pull in the shared ioredis socket, or the
554
522
  // `accessTokenDenylistClient` slot has no provider and boot fails on a
555
523
  // missing component instead of on the thing the operator changed.
556
- expect(names).toContain("standalone:redis-clients");
524
+ expect(names).toContain("redis-clients");
557
525
  });
558
526
 
559
- it("the shipped application.conf selects the replica-safe adapter", () => {
527
+ it("the template's reference.conf selects the replica-safe adapter", () => {
560
528
  // The template's own config is the artifact operators deploy. It runs
561
- // with `deployment.mode = multi` in the umbrella E2E, which refuses
529
+ // with `core.deployment.mode = multi` in the umbrella E2E, which refuses
562
530
  // every in-memory shared store — so shipping the memory denylist here
563
531
  // would be a boot failure in the very stack that proves the scaffold
564
532
  // 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"/);
533
+ expect(shippedAdapters().accessTokenDenylist).toBe("redis");
567
534
  });
568
535
 
569
536
  it("keeps the composition boot-valid end to end", async () => {
570
- // The core boot guard (#277 step 13.9) refuses a composition that reads
537
+ // The core boot guard refuses a composition that reads
571
538
  // the denylist slot without one. A scaffold that trips its own library's
572
539
  // guard is not a scaffold.
573
540
  const handle = await createApp({
574
- modules: buildModules(config, {
541
+ modules: buildModules(switches, {
575
542
  keyStoreModule: testKeyStoreModule,
576
543
  repositoriesModule: testRepositoriesModule,
577
544
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
@@ -583,100 +550,51 @@ describe("standalone smoke test", () => {
583
550
  });
584
551
  });
585
552
 
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, {
553
+ // The OAuth code repository's selection: the memory branch wires
554
+ // `inMemoryCodeRepositoryModule`; the redis branch wires
555
+ // `redisCodeRepositoryModule` against the shared ioredis socket.
556
+ describe("code-repository adapter wiring", () => {
557
+ it("buildModules wires inMemoryCodeRepositoryModule when adapters.codeRepository = 'memory'", () => {
558
+ const modules = buildModules(switches, {
594
559
  keyStoreModule: testKeyStoreModule,
595
560
  repositoriesModule: testRepositoriesModule,
596
561
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
597
562
  });
598
563
  const names = modules.map((m) => m.name);
599
- expect(names).toContain("standalone:in-memory-code-repository");
564
+ expect(names).toContain("standalone-in-memory-code-repository");
600
565
  expect(names).not.toContain("redis-code-repository");
601
566
  });
602
567
 
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, {
568
+ it("buildModules switches to redisCodeRepositoryModule when adapters.codeRepository = 'redis'", () => {
569
+ const modules = buildModules(switchesWith({ codeRepository: "redis" }), {
609
570
  keyStoreModule: testKeyStoreModule,
610
571
  repositoriesModule: testRepositoriesModule,
611
572
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
612
573
  });
613
574
  const names = modules.map((m) => m.name);
614
575
  expect(names).toContain("redis-code-repository");
615
- expect(names).not.toContain("standalone:in-memory-code-repository");
576
+ expect(names).not.toContain("standalone-in-memory-code-repository");
616
577
  // adapter = "redis" pulls in the shared ioredis socket so the
617
578
  // `codeRepositoryClient` slot is satisfied.
618
- expect(names).toContain("standalone:redis-clients");
619
- });
620
-
621
- it("buildModules honors legacy repositories.code.type='redis' with one object-first deprecation warn when oauth.code.adapter is absent", () => {
622
- const warn = vi.fn();
623
- const logger = {
624
- trace: vi.fn(),
625
- debug: vi.fn(),
626
- info: vi.fn(),
627
- warn,
628
- error: vi.fn(),
629
- fatal: vi.fn(),
630
- child: () => logger,
631
- };
632
- const legacyConfig = {
633
- ...config,
634
- oauth: { ...config.oauth, code: undefined },
635
- repositories: {
636
- ...config.repositories,
637
- code: { type: "redis" as const, defaultExpiresIn: 600 },
638
- },
639
- };
640
- const modules = buildModules(legacyConfig, {
641
- keyStoreModule: testKeyStoreModule,
642
- repositoriesModule: testRepositoriesModule,
643
- refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
644
- logger,
645
- });
646
- const names = modules.map((m) => m.name);
647
- expect(names).toContain("redis-code-repository");
648
- expect(warn.mock.calls).toEqual([
649
- [
650
- {
651
- key: "repositories.code.type",
652
- env: "CLIENT_CODE_TYPE",
653
- replacement: "oauth.code.adapter",
654
- replacementEnv: "OAUTH_CODE_ADAPTER",
655
- },
656
- "config_key_deprecated",
657
- ],
658
- ]);
659
- for (const level of ["trace", "debug", "info", "error", "fatal"] as const) {
660
- expect(logger[level]).not.toHaveBeenCalled();
661
- }
579
+ expect(names).toContain("redis-clients");
662
580
  });
663
581
 
664
582
  it("buildModules has only ONE codeRepository provider in the manifest (no slot collision)", () => {
665
- // Regression guard: `repositoriesModule` must NOT provide
666
- // `codeRepository` post-OR-9 — the slot is owned by
667
- // `inMemoryCodeRepositoryModule` or `redisCodeRepositoryModule`
668
- // exclusively, selected via `oauth.code.adapter`.
669
- const modules = buildModules(config, {
583
+ // The `repositories` module must NOT provide `codeRepository` — the
584
+ // slot is owned by `inMemoryCodeRepositoryModule` or
585
+ // `redisCodeRepositoryModule` exclusively, selected via
586
+ // `adapters.codeRepository`.
587
+ const modules = buildModules(switches, {
670
588
  keyStoreModule: testKeyStoreModule,
671
- // Use the production repositoriesModule (not the test override)
672
- // so we exercise its post-OR-9 provides-shape.
589
+ // The production `repositories` module (not the test override), so
590
+ // its own provides are exercised.
673
591
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
674
592
  });
675
593
  const codeRepoProviders = modules.filter((m) =>
676
594
  Object.keys(m.provides ?? {}).includes("codeRepository"),
677
595
  );
678
596
  expect(codeRepoProviders).toHaveLength(1);
679
- expect(codeRepoProviders[0]?.name).toBe("standalone:in-memory-code-repository");
597
+ expect(codeRepoProviders[0]?.name).toBe("standalone-in-memory-code-repository");
680
598
  });
681
599
  });
682
600
 
@@ -688,10 +606,10 @@ describe("standalone smoke test", () => {
688
606
  registerBuiltinKeyStores(ksf);
689
607
  const keyStore = await ksf.create({
690
608
  type: "local",
691
- ...(config.oauth.jwt.signingKey.local ?? {}),
609
+ ...(config["key-store"] as { local: object }).local,
692
610
  });
693
611
  // The token must carry the deployment's configured `iss`: introspection
694
- // pins it (RFC 9068 §4), and since #266 every deployment has one.
612
+ // pins it (RFC 9068 §4), and every deployment has one.
695
613
  const { token } = await generateToken(
696
614
  {},
697
615
  {
@@ -699,7 +617,7 @@ describe("standalone smoke test", () => {
699
617
  subject: "u1",
700
618
  expiresIn: 3600,
701
619
  tokenType: "at+jwt",
702
- issuer: config.oauth.jwt.issuer,
620
+ issuer: (config.oauth as OAuthSection).jwt.issuer,
703
621
  },
704
622
  );
705
623