@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
@@ -15,45 +15,30 @@
15
15
  */
16
16
 
17
17
  /**
18
- * #455 / #456 — the standalone's own store modules under
19
- * `deployment.mode = "multi"`, booted the way an operator reaches them: from
20
- * the shipped HOCON with one environment variable flipped.
18
+ * The standalone's own store modules under `core.deployment.mode = "multi"`,
19
+ * booted the way an operator reaches them: from the shipped HOCON with one
20
+ * environment variable flipped.
21
21
  *
22
- * #455: the replica-safety guard keyed on core's module names, and this
23
- * template wires its *own* in-memory modules for three of the stores —
24
- * session stores, the code repository, the federation token store — under
25
- * names the guard had never heard of. `DEPLOYMENT_MODE=multi` with
26
- * `USER_SESSION_STORES_ADAPTER=memory` booted, and the refusal the README
27
- * promises did not fire for exactly the stores that fork per replica the
28
- * worst. Every memory branch is booted here under `"multi"` and must be
29
- * refused by name; the all-Redis environment must boot.
22
+ * - Every memory branch must be refused by name, and the all-Redis
23
+ * environment must boot. The replica-safety guard reads each module's
24
+ * manifest declaration, which covers the template's own in-memory modules
25
+ * and express-session's store (`buildModules` builds it from config,
26
+ * `sessionStoreModuleFor`).
27
+ * - `federationTokenStore.type = "redis"` mounts
28
+ * `redisFederationTokenStoreModule` off the shared ioredis socket.
29
+ * - The Redis federation store's `allow-plaintext` guard reads the
30
+ * environment the config was selected by (`CONFIG_ENV || NODE_ENV`, passed
31
+ * through `buildModules`) and refuses under `core.deployment.mode = "multi"` in
32
+ * every environment.
30
33
  *
31
- * #456: `federationTokenStore.type = "redis"` was documented and could not
32
- * boot — the module went through the adapter factory without ever handing
33
- * the builder a client. The Redis branch now mounts
34
- * `redisFederationTokenStoreModule` off the shared ioredis socket; the boot
35
- * below is the test the README sentence never had.
36
- *
37
- * #474: express-session's own store was the one memory store the guard could
38
- * not see — `SESSION_STORAGE_TYPE=memory` is config, and `sessionStoreModule`'s
39
- * static manifest carried no declaration. `buildModules` now builds it from
40
- * the config (`sessionStoreModuleFor`), so it is refused by name like the rest.
41
- *
42
- * #473: the Redis federation store's `allow-plaintext` guard keyed off
43
- * `NODE_ENV` alone. It now reads the environment the config was selected by
44
- * (`CONFIG_ENV || NODE_ENV`, passed through `buildModules`) and refuses under
45
- * `deployment.mode = "multi"` in every environment.
46
- *
47
- * ioredis is mocked, as in `device-code-store-client-module.test.mts`: the
48
- * point is composition and the boot planner's stage-1 verdict, not Redis.
49
- * Nothing here issues a command. node-redis and connect-redis — the session
50
- * store's own connection — are mocked for the same reason, since #474 puts
51
- * `SESSION_STORAGE_TYPE=redis` in the baseline.
34
+ * ioredis, node-redis and connect-redis are mocked, as in
35
+ * `device-code-store-client-module.test.mts`: the point is composition and
36
+ * the boot planner's stage-1 verdict, not Redis. Nothing here issues a
37
+ * command.
52
38
  */
53
39
 
54
40
  import { fileURLToPath } from "node:url";
55
41
  import {
56
- type AppConfig,
57
42
  AppConfigSchema,
58
43
  createApp,
59
44
  createKeyStoreFactory,
@@ -64,14 +49,22 @@ import {
64
49
  registerBuiltinKeyStores,
65
50
  replicaUnsafeReason,
66
51
  } from "@o3co/auth-provider-core";
52
+ import { standardSmtpMailSenderConfigForTests } from "@o3co/auth-provider-standard/testing";
67
53
  import { parseFile } from "@o3co/ts.hocon";
68
54
  import { validate } from "@o3co/ts.hocon/zod";
69
55
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
70
56
  import { buildModules } from "../buildModules.mjs";
71
- import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPath.mjs";
72
-
73
- // The redis session-store builder dynamically imports these (#474 puts the
74
- // Redis session store in the baseline); mock them so no socket opens.
57
+ import { resolveConfigPaths, type Switches } from "../configPath.mjs";
58
+ import { templateReference } from "../modules.mjs";
59
+ import {
60
+ capturedRenames,
61
+ libraryLayers,
62
+ rootSectionsOf,
63
+ sectionsCoreDoesNotDeclare,
64
+ } from "./library-references.fixture.mjs";
65
+
66
+ // The redis session-store builder, which the baseline selects, dynamically
67
+ // imports these; mock them so no socket opens.
75
68
  vi.mock("redis", () => ({
76
69
  createClient: vi.fn(() => ({
77
70
  connect: vi.fn().mockResolvedValue(undefined),
@@ -141,41 +134,48 @@ const ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
141
134
 
142
135
  /**
143
136
  * The umbrella E2E's shape (`o3co/auth` `tests/docker-compose.yml`), with
144
- * every shared store on Redis — express-session's own included, since #474
145
- * put it under the guard. Each case below flips one variable off this.
137
+ * every shared store on Redis, express-session's own included, and MFA off
138
+ * as the umbrella sets it. Each case below flips one variable off this.
146
139
  */
147
140
  const ALL_REDIS_ENV: Readonly<Record<string, string>> = {
148
- OAUTH_JWT_ALGORITHM: "HS256",
149
- OAUTH_JWT_SECRET: "replica-safety-test-secret.at-least-32-bytes.ok",
141
+ MFA_MODE: "off",
142
+ KEY_STORE_LOCAL_ALGORITHM: "HS256",
143
+ KEY_STORE_LOCAL_SECRET: "replica-safety-test-secret.at-least-32-bytes.ok",
150
144
  OAUTH_JWT_ISSUER: "https://auth.test",
151
- SESSION_SECRET: "replica-safety-session-secret.at-least-32-bytes.ok",
152
- SESSION_SECURE: "false",
153
- SESSION_NAME: "auth.session",
154
- SESSION_STORAGE_TYPE: "redis",
155
- SESSION_STORAGE_REDIS_URL: "redis://redis.test:6379",
156
- CLIENT_USER_TYPE: "yaml",
157
- DEPLOYMENT_MODE: "multi",
158
- REFRESH_TOKEN_FAMILY_STORE_REDIS_URL: "redis://redis.test:6379",
159
- USER_SESSION_STORES_ADAPTER: "redis",
160
- RATE_LIMITER_ADAPTER: "redis",
161
- OAUTH_CODE_ADAPTER: "redis",
162
- ACCESS_TOKEN_DENYLIST_ADAPTER: "redis",
163
- REPLAY_SEEN_SET_ADAPTER: "redis",
164
- FEDERATION_TOKEN_STORE_TYPE: "redis",
145
+ SESSION_STORE_SECRET: "replica-safety-session-secret.at-least-32-bytes.ok",
146
+ SESSION_STORE_SECURE: "false",
147
+ SESSION_STORE_NAME: "auth.session",
148
+ SESSION_STORE_STORAGE_TYPE: "redis",
149
+ SESSION_STORE_STORAGE_REDIS_URL: "redis://redis.test:6379",
150
+ ADAPTERS_USER_REPOSITORY: "yaml",
151
+ CORE_DEPLOYMENT_MODE: "multi",
152
+ REDIS_CLIENTS_URL: "redis://redis.test:6379",
153
+ ADAPTERS_USER_SESSION_STORES: "redis",
154
+ ADAPTERS_RATE_LIMITER: "redis",
155
+ ADAPTERS_CODE_REPOSITORY: "redis",
156
+ ADAPTERS_ACCESS_TOKEN_DENYLIST: "redis",
157
+ ADAPTERS_REPLAY_SEEN_SET: "redis",
158
+ ADAPTERS_FEDERATION_TOKEN_STORE: "redis",
165
159
  REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: ENCRYPTION_KEY,
166
- // #561: the consent step for clients that are not first-party, on the
160
+ // The consent step for clients that are not first-party, on the
167
161
  // shared store — the one switch value `multi` accepts besides `none`.
168
- CONSENT_STORE_ADAPTER: "redis",
162
+ ADAPTERS_CONSENT_STORE: "redis",
169
163
  };
170
164
 
171
- function resolveConfig(env: Record<string, string>): AppConfig {
165
+ function resolveConfig(env: Record<string, string>): Switches {
172
166
  const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
173
- return validate(
174
- parseFile(envConfPath, { env })
175
- .withFallback(parseFile(applicationConfPath, { env }))
176
- .withFallback(parseFile(resolveLibraryReferenceConfPath(), { env })),
177
- AppConfigSchema,
178
- );
167
+ const layers = parseFile(envConfPath, { env })
168
+ .withFallback(parseFile(applicationConfPath, { env }))
169
+ .withFallback(parseFile(fileURLToPath(templateReference()), { env }))
170
+ .withFallback(libraryLayers(env));
171
+ return {
172
+ ...sectionsCoreDoesNotDeclare(layers),
173
+ ...rootSectionsOf(layers, env),
174
+ ...validate(layers, AppConfigSchema),
175
+ // What the resolution captured of core's renamed variables, which the
176
+ // schema's parse drops.
177
+ "renamed-variables": capturedRenames(env),
178
+ } as Switches;
179
179
  }
180
180
 
181
181
  /** Drops a variable, so the HOCON default takes over. */
@@ -204,20 +204,20 @@ const testKeyStoreModule = defineModule({
204
204
  registerBuiltinKeyStores(factory);
205
205
  return factory.create({
206
206
  type: "local",
207
- ...((c as AppConfig).oauth.jwt.signingKey.local ?? {}),
207
+ ...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
208
208
  });
209
209
  },
210
210
  },
211
211
  });
212
212
 
213
- const modulesFor = (config: AppConfig, environment?: string) =>
213
+ const modulesFor = (config: Switches, environment?: string) =>
214
214
  buildModules(config, {
215
215
  keyStoreModule: testKeyStoreModule,
216
216
  repositoriesModule: testRepositoriesModule,
217
217
  ...(environment === undefined ? {} : { environment }),
218
218
  });
219
219
 
220
- const boot = (config: AppConfig, environment?: string) =>
220
+ const boot = (config: Switches, environment?: string) =>
221
221
  createApp({
222
222
  modules: modulesFor(config, environment),
223
223
  bootstrapComponents: { config, pathResolver: (s) => s },
@@ -229,7 +229,7 @@ const messageChain = (err: unknown): string => {
229
229
  return `${e.message ?? ""} ${e.cause?.message ?? ""}`;
230
230
  };
231
231
 
232
- describe('#455: the standalone\'s memory modules are refused under deployment.mode = "multi"', () => {
232
+ describe('the standalone\'s memory modules are refused under core.deployment.mode = "multi"', () => {
233
233
  let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
234
234
 
235
235
  afterEach(async () => {
@@ -238,21 +238,20 @@ describe('#455: the standalone\'s memory modules are refused under deployment.mo
238
238
  });
239
239
 
240
240
  const cases: ReadonlyArray<readonly [variable: string, module: string]> = [
241
- // The template's own modules — the three the guard could not see.
242
- ["USER_SESSION_STORES_ADAPTER", "standalone:in-memory-session-stores"],
243
- ["OAUTH_CODE_ADAPTER", "standalone:in-memory-code-repository"],
244
- ["FEDERATION_TOKEN_STORE_TYPE", "standalone:in-memory-federation-token-store"],
245
- // #474: express-session's own store. Not a module of this template but
241
+ // The template's own modules.
242
+ ["ADAPTERS_USER_SESSION_STORES", "standalone-in-memory-session-stores"],
243
+ ["ADAPTERS_CODE_REPOSITORY", "standalone-in-memory-code-repository"],
244
+ ["ADAPTERS_FEDERATION_TOKEN_STORE", "standalone-in-memory-federation-token-store"],
245
+ // express-session's own store. Not a module of this template but
246
246
  // built here from its config, which is what lets the manifest declare.
247
- ["SESSION_STORAGE_TYPE", "sessionStoreModule"],
248
- // #527: the consent store, wired only when the switch says so.
249
- ["CONSENT_STORE_ADAPTER", "core-consent-store-memory"],
250
- // Core's, selected by the same kind of switch. The guard knew these by
251
- // name; pinned so the name table's departure (#455) did not lose them.
252
- ["RATE_LIMITER_ADAPTER", "core-rate-limiter-memory"],
253
- ["ACCESS_TOKEN_DENYLIST_ADAPTER", "core-access-token-denylist-memory"],
254
- // #484: the jti single-use record behind private_key_jwt client auth.
255
- ["REPLAY_SEEN_SET_ADAPTER", "core-replay-seen-set-memory"],
247
+ ["SESSION_STORE_STORAGE_TYPE", "session-store"],
248
+ // The consent store, wired only when the switch says so.
249
+ ["ADAPTERS_CONSENT_STORE", "core-consent-store-memory"],
250
+ // Core's, selected by the same kind of switch.
251
+ ["ADAPTERS_RATE_LIMITER", "core-rate-limiter-memory"],
252
+ ["ADAPTERS_ACCESS_TOKEN_DENYLIST", "core-access-token-denylist-memory"],
253
+ // The jti single-use record behind private_key_jwt client auth.
254
+ ["ADAPTERS_REPLAY_SEEN_SET", "core-replay-seen-set-memory"],
256
255
  ];
257
256
 
258
257
  for (const [variable, module] of cases) {
@@ -269,14 +268,14 @@ describe('#455: the standalone\'s memory modules are refused under deployment.mo
269
268
  it("names every memory module together when every switch is memory", async () => {
270
269
  const config = resolveConfig({
271
270
  ...ALL_REDIS_ENV,
272
- USER_SESSION_STORES_ADAPTER: "memory",
273
- OAUTH_CODE_ADAPTER: "memory",
274
- FEDERATION_TOKEN_STORE_TYPE: "memory",
275
- SESSION_STORAGE_TYPE: "memory",
276
- RATE_LIMITER_ADAPTER: "memory",
277
- ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
278
- REPLAY_SEEN_SET_ADAPTER: "memory",
279
- CONSENT_STORE_ADAPTER: "memory",
271
+ ADAPTERS_USER_SESSION_STORES: "memory",
272
+ ADAPTERS_CODE_REPOSITORY: "memory",
273
+ ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
274
+ SESSION_STORE_STORAGE_TYPE: "memory",
275
+ ADAPTERS_RATE_LIMITER: "memory",
276
+ ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
277
+ ADAPTERS_REPLAY_SEEN_SET: "memory",
278
+ ADAPTERS_CONSENT_STORE: "memory",
280
279
  });
281
280
  await expect(boot(config)).rejects.toMatchObject({
282
281
  reason: "replica-unsafe-adapter",
@@ -287,21 +286,21 @@ describe('#455: the standalone\'s memory modules are refused under deployment.mo
287
286
  });
288
287
 
289
288
  it("each standalone memory module declares its consequence on its own manifest", () => {
290
- // The declaration is what the guard reads (#455), so it has to be on
289
+ // The declaration is what the guard reads, so it has to be on
291
290
  // the module and it has to say what breaks — the guard quotes it.
292
291
  const config = resolveConfig({
293
292
  ...ALL_REDIS_ENV,
294
- USER_SESSION_STORES_ADAPTER: "memory",
295
- OAUTH_CODE_ADAPTER: "memory",
296
- FEDERATION_TOKEN_STORE_TYPE: "memory",
293
+ ADAPTERS_USER_SESSION_STORES: "memory",
294
+ ADAPTERS_CODE_REPOSITORY: "memory",
295
+ ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
297
296
  });
298
297
  const standaloneMemoryModules = modulesFor(config).filter((m) =>
299
- m.name.startsWith("standalone:in-memory-"),
298
+ m.name.startsWith("standalone-in-memory-"),
300
299
  );
301
300
  expect(standaloneMemoryModules.map((m) => m.name).sort()).toEqual([
302
- "standalone:in-memory-code-repository",
303
- "standalone:in-memory-federation-token-store",
304
- "standalone:in-memory-session-stores",
301
+ "standalone-in-memory-code-repository",
302
+ "standalone-in-memory-federation-token-store",
303
+ "standalone-in-memory-session-stores",
305
304
  ]);
306
305
  for (const m of standaloneMemoryModules) {
307
306
  expect(replicaUnsafeReason(m), m.name).toBeDefined();
@@ -319,7 +318,7 @@ describe('#455: the standalone\'s memory modules are refused under deployment.mo
319
318
  });
320
319
  });
321
320
 
322
- describe('#456: federationTokenStore.type = "redis" in the standalone', () => {
321
+ describe('adapters.federationTokenStore = "redis" in the standalone', () => {
323
322
  let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
324
323
 
325
324
  afterEach(async () => {
@@ -327,13 +326,11 @@ describe('#456: federationTokenStore.type = "redis" in the standalone', () => {
327
326
  handleRef = undefined;
328
327
  });
329
328
 
330
- it("reaches the resolved config from FEDERATION_TOKEN_STORE_TYPE", () => {
331
- // `AppConfigSchema` strips top-level keys it does not declare; the
332
- // switch had to be declared before an env var could select anything.
333
- expect(resolveConfig(ALL_REDIS_ENV).federationTokenStore?.type).toBe("redis");
329
+ it("reaches phase one's adapters from ADAPTERS_FEDERATION_TOKEN_STORE", () => {
330
+ expect(resolveConfig(ALL_REDIS_ENV).adapters.federationTokenStore).toBe("redis");
334
331
  expect(
335
- resolveConfig(without(ALL_REDIS_ENV, "FEDERATION_TOKEN_STORE_TYPE")).federationTokenStore
336
- ?.type,
332
+ resolveConfig(without(ALL_REDIS_ENV, "ADAPTERS_FEDERATION_TOKEN_STORE")).adapters
333
+ .federationTokenStore,
337
334
  ).toBe("memory");
338
335
  });
339
336
 
@@ -341,8 +338,8 @@ describe('#456: federationTokenStore.type = "redis" in the standalone', () => {
341
338
  const modules = modulesFor(resolveConfig(ALL_REDIS_ENV));
342
339
  const names = modules.map((m) => m.name);
343
340
  expect(names).toContain("redis-federation-token-store");
344
- expect(names).toContain("standalone:redis-clients");
345
- expect(names).not.toContain("standalone:in-memory-federation-token-store");
341
+ expect(names).toContain("redis-clients");
342
+ expect(names).not.toContain("standalone-in-memory-federation-token-store");
346
343
  // Exactly one provider for the slot: both modules provide it, so
347
344
  // selecting both would be a boot-time slot collision.
348
345
  const providers = modules.filter((m) =>
@@ -355,18 +352,18 @@ describe('#456: federationTokenStore.type = "redis" in the standalone', () => {
355
352
  // Every other store on memory, single replica: the Redis federation
356
353
  // store still needs `federationTokenStoreClient`, which only the shared
357
354
  // clients module provides. Without this the branch fails stage 1 with
358
- // `missing-required-component` — the #439 shape, once more.
355
+ // `missing-required-component`.
359
356
  const config = resolveConfig({
360
357
  ...ALL_REDIS_ENV,
361
- DEPLOYMENT_MODE: "single",
362
- USER_SESSION_STORES_ADAPTER: "memory",
363
- OAUTH_CODE_ADAPTER: "memory",
364
- RATE_LIMITER_ADAPTER: "memory",
365
- ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
358
+ CORE_DEPLOYMENT_MODE: "single",
359
+ ADAPTERS_USER_SESSION_STORES: "memory",
360
+ ADAPTERS_CODE_REPOSITORY: "memory",
361
+ ADAPTERS_RATE_LIMITER: "memory",
362
+ ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
366
363
  });
367
364
  const names = modulesFor(config).map((m) => m.name);
368
365
  expect(names).toContain("redis-federation-token-store");
369
- expect(names).toContain("standalone:redis-clients");
366
+ expect(names).toContain("redis-clients");
370
367
  });
371
368
 
372
369
  it("boots, and the resolved store is the Redis adapter", async () => {
@@ -388,20 +385,20 @@ describe('#456: federationTokenStore.type = "redis" in the standalone', () => {
388
385
  });
389
386
  });
390
387
 
391
- it("keeps the memory branch as it was: the default, in single mode, resolves the memory adapter", async () => {
388
+ it("keeps the memory branch: the default, in single mode, resolves the memory adapter", async () => {
392
389
  const config = resolveConfig({
393
- ...without(ALL_REDIS_ENV, "FEDERATION_TOKEN_STORE_TYPE"),
394
- DEPLOYMENT_MODE: "single",
390
+ ...without(ALL_REDIS_ENV, "ADAPTERS_FEDERATION_TOKEN_STORE"),
391
+ CORE_DEPLOYMENT_MODE: "single",
395
392
  });
396
393
  const names = modulesFor(config).map((m) => m.name);
397
- expect(names).toContain("standalone:in-memory-federation-token-store");
394
+ expect(names).toContain("standalone-in-memory-federation-token-store");
398
395
  expect(names).not.toContain("redis-federation-token-store");
399
396
  handleRef = await boot(config);
400
397
  expect(handleRef.components.federationTokenStore?.kind).toBe("memory");
401
398
  });
402
399
  });
403
400
 
404
- describe('#561: consentStore.adapter = "redis" in the standalone', () => {
401
+ describe('consentStore.adapter = "redis" in the standalone', () => {
405
402
  let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
406
403
 
407
404
  afterEach(async () => {
@@ -428,16 +425,16 @@ describe('#561: consentStore.adapter = "redis" in the standalone', () => {
428
425
  it("pulls the shared clients module in for the consent stores alone", () => {
429
426
  // Every other store on memory, single replica: the consent module still
430
427
  // needs its two client slots, which only the shared clients module
431
- // provides — the #439 shape otherwise.
428
+ // provides.
432
429
  const config = resolveConfig({
433
430
  ...ALL_REDIS_ENV,
434
- DEPLOYMENT_MODE: "single",
435
- USER_SESSION_STORES_ADAPTER: "memory",
436
- OAUTH_CODE_ADAPTER: "memory",
437
- RATE_LIMITER_ADAPTER: "memory",
438
- ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
439
- REPLAY_SEEN_SET_ADAPTER: "memory",
440
- FEDERATION_TOKEN_STORE_TYPE: "memory",
431
+ CORE_DEPLOYMENT_MODE: "single",
432
+ ADAPTERS_USER_SESSION_STORES: "memory",
433
+ ADAPTERS_CODE_REPOSITORY: "memory",
434
+ ADAPTERS_RATE_LIMITER: "memory",
435
+ ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
436
+ ADAPTERS_REPLAY_SEEN_SET: "memory",
437
+ ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
441
438
  });
442
439
  const names = buildModules(config, {
443
440
  keyStoreModule: testKeyStoreModule,
@@ -445,17 +442,17 @@ describe('#561: consentStore.adapter = "redis" in the standalone', () => {
445
442
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
446
443
  }).map((m) => m.name);
447
444
  expect(names).toContain("redis-consent-store");
448
- expect(names).toContain("standalone:redis-clients");
445
+ expect(names).toContain("redis-clients");
449
446
  });
450
447
 
451
- it("boots under DEPLOYMENT_MODE=multi with both slots resolved to the Redis adapters", async () => {
448
+ it("boots under CORE_DEPLOYMENT_MODE=multi with both slots resolved to the Redis adapters", async () => {
452
449
  handleRef = await boot(resolveConfig(ALL_REDIS_ENV));
453
450
  expect(handleRef.components.consentStore?.kind).toBe("redis");
454
451
  expect(handleRef.components.pendingConsentStore?.kind).toBe("redis");
455
452
  });
456
453
  });
457
454
 
458
- describe("#473: the Redis federation store's plaintext guard, booted from the shipped config", () => {
455
+ describe("the Redis federation store's plaintext guard, booted from the shipped config", () => {
459
456
  let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
460
457
  let warnSpy: ReturnType<typeof vi.spyOn>;
461
458
  let errorSpy: ReturnType<typeof vi.spyOn>;
@@ -486,36 +483,60 @@ describe("#473: the Redis federation store's plaintext guard, booted from the sh
486
483
  REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_MODE: "allow-plaintext",
487
484
  };
488
485
 
489
- it('is refused under deployment.mode = "multi" in a development environment', async () => {
490
- // The umbrella shape with plaintext: refused before #473 only if
491
- // NODE_ENV happened to say production. A multi-replica deployment is
492
- // never a development box.
493
- await expect(boot(resolveConfig(PLAINTEXT_ENV), "development")).rejects.toSatisfy(
494
- (err: unknown) => /deployment\.mode is "multi"/.test(messageChain(err)),
486
+ it('is refused under core.deployment.mode = "multi" in an environment that is not production', async () => {
487
+ // The umbrella shape with plaintext. A multi-replica deployment is
488
+ // never a development box. A development configuration under multi is
489
+ // refused by the development mail sender first, so the environment here
490
+ // is another name that is not production, with the SMTP sender's section.
491
+ await expect(
492
+ boot({ ...resolveConfig(PLAINTEXT_ENV), ...standardSmtpMailSenderConfigForTests() }, "test"),
493
+ ).rejects.toSatisfy((err: unknown) =>
494
+ /allow-plaintext[\s\S]*core\.deployment\.mode is "multi"/.test(messageChain(err)),
495
495
  );
496
496
  expect(errorSpy).not.toHaveBeenCalled();
497
497
  });
498
498
 
499
499
  it("is refused when the config was selected by CONFIG_ENV=production, whatever NODE_ENV says", async () => {
500
500
  // `app.mts` selects `production.conf` by `CONFIG_ENV || NODE_ENV` and
501
- // passes that name through `buildModules`; the guard used to read
502
- // NODE_ENV alone and would have let this boot.
503
- const config = resolveConfig({ ...PLAINTEXT_ENV, DEPLOYMENT_MODE: "single" });
501
+ // passes that name through `buildModules`; the guard reads that name,
502
+ // not NODE_ENV alone.
503
+ // Under production the template installs the SMTP sender's module, whose
504
+ // section the package's builder carries.
505
+ const config = {
506
+ ...resolveConfig({ ...PLAINTEXT_ENV, CORE_DEPLOYMENT_MODE: "single" }),
507
+ ...standardSmtpMailSenderConfigForTests(),
508
+ };
504
509
  await expect(boot(config, "production")).rejects.toSatisfy((err: unknown) =>
505
- /the environment is "production"/.test(messageChain(err)),
510
+ /allow-plaintext[\s\S]*the environment is "production"/.test(messageChain(err)),
506
511
  );
507
512
  });
508
513
 
509
514
  it("boots with the plaintext warning in a development environment on a single replica", async () => {
510
- const config = resolveConfig({ ...PLAINTEXT_ENV, DEPLOYMENT_MODE: "single" });
515
+ const config = resolveConfig({ ...PLAINTEXT_ENV, CORE_DEPLOYMENT_MODE: "single" });
511
516
  handleRef = await boot(config, "development");
512
517
  expect(handleRef.components.federationTokenStore?.kind).toBe("redis");
513
- expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("allow-plaintext"));
518
+ expect(warnSpy).toHaveBeenCalledWith(
519
+ { store: "federation-tokens", mode: "allow-plaintext" },
520
+ "federation_store_plaintext",
521
+ );
514
522
  });
515
523
 
516
- it("keeps the FEDERATION_TOKENS_ALLOW_INSECURE=1 escape hatch under multi, at CRITICAL", async () => {
524
+ it("keeps the FEDERATION_TOKENS_ALLOW_INSECURE=1 escape hatch under multi, logged at error", async () => {
517
525
  process.env.FEDERATION_TOKENS_ALLOW_INSECURE = "1";
518
- handleRef = await boot(resolveConfig(PLAINTEXT_ENV), "development");
519
- expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("CRITICAL"));
526
+ // Under multi the development mail sender refuses a development
527
+ // configuration, so the environment here is another name that is not
528
+ // production, with the SMTP sender's section its module reads.
529
+ handleRef = await boot(
530
+ { ...resolveConfig(PLAINTEXT_ENV), ...standardSmtpMailSenderConfigForTests() },
531
+ "test",
532
+ );
533
+ expect(errorSpy).toHaveBeenCalledWith(
534
+ expect.objectContaining({
535
+ store: "federation-tokens",
536
+ deploymentMode: "multi",
537
+ override: "FEDERATION_TOKENS_ALLOW_INSECURE",
538
+ }),
539
+ "federation_store_plaintext_override",
540
+ );
520
541
  });
521
542
  });
@@ -0,0 +1,146 @@
1
+ /*
2
+ * Copyright 2026 1o1 Co. Ltd.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /**
18
+ * The template's composition, booted through core's `createApp` and mounted
19
+ * with no terminal handler of the template's after it — the router alone, as
20
+ * a composition root that copies nothing of `app.mts` mounts it.
21
+ *
22
+ * The router core returns answers a body parser's refusal and any error a
23
+ * route lets through itself. Left to Express's final handler, a host would
24
+ * answer with an HTML page carrying the stack outside production, where V8's
25
+ * JSON error quotes the body it could not parse.
26
+ */
27
+
28
+ import { defineModule } from "@o3co/auth-provider-core";
29
+ import { Router } from "express";
30
+ import request from "supertest";
31
+ import { afterEach, describe, expect, it } from "vitest";
32
+ import {
33
+ basic,
34
+ type Composition,
35
+ compose,
36
+ JSON_TYPE,
37
+ KIB,
38
+ M2M,
39
+ padJson,
40
+ } from "./all-modules-composition.fixture.mjs";
41
+
42
+ let current: Composition | undefined;
43
+
44
+ afterEach(async () => {
45
+ await current?.handle.dispose();
46
+ current = undefined;
47
+ });
48
+
49
+ /** A route that lets an error through, contributed as a deployment adds one. */
50
+ const throwingRouteModule = defineModule({
51
+ name: "test:throwing-route",
52
+ contributes: {
53
+ routes: [
54
+ () => {
55
+ const router = Router();
56
+ router.get("/", () => {
57
+ throw new Error("a store said something: escaped-error-marker");
58
+ });
59
+ return { id: "test:throwing-route", mountPath: "/throws", handler: router };
60
+ },
61
+ ],
62
+ },
63
+ });
64
+
65
+ /** The composition, the router alone mounted. */
66
+ const boot = async (): Promise<Composition> => {
67
+ current = await compose({
68
+ terminalErrorHandler: false,
69
+ extraModules: () => [throwingRouteModule],
70
+ });
71
+ return current;
72
+ };
73
+
74
+ /** The routes that parse a body, and what each needs to reach its parser. */
75
+ const PARSING_ROUTES = [
76
+ ["/oauth/token", { authorization: basic(M2M) }],
77
+ ["/oauth/introspect", { authorization: basic(M2M) }],
78
+ ["/session/login", {}],
79
+ ] as const;
80
+
81
+ const errorLines = (c: Composition) => c.logger.lines.filter((line) => line.level === "error");
82
+
83
+ describe("the composed router, mounted alone, answers a body parser's refusal", () => {
84
+ it.each(PARSING_ROUTES)(
85
+ "%s: malformed JSON is 400 in the envelope, nothing logged",
86
+ async (path, headers) => {
87
+ const c = await boot();
88
+ const res = await request(c.app)
89
+ .post(path)
90
+ .set(headers)
91
+ .set("Content-Type", JSON_TYPE)
92
+ .send('{"password":"body-secret-marker');
93
+
94
+ expect(res.status).toBe(400);
95
+ expect(res.headers["content-type"]).toMatch(/^application\/json/);
96
+ expect(res.body).toEqual({ error: "invalid_request", error_description: "malformed_body" });
97
+ expect(res.text).not.toContain("body-secret-marker");
98
+ expect(res.text).not.toMatch(/ at /);
99
+ expect(errorLines(c)).toEqual([]);
100
+ },
101
+ );
102
+
103
+ it.each(PARSING_ROUTES)(
104
+ "%s: a body over the parser's limit is 413 in the envelope, nothing logged",
105
+ async (path, headers) => {
106
+ const c = await boot();
107
+ const res = await request(c.app)
108
+ .post(path)
109
+ .set(headers)
110
+ .set("Content-Type", JSON_TYPE)
111
+ .send(padJson(200 * KIB));
112
+
113
+ expect(res.status).toBe(413);
114
+ expect(res.headers["content-type"]).toMatch(/^application\/json/);
115
+ expect(res.body).toEqual({ error: "invalid_request", error_description: "body_too_large" });
116
+ expect(errorLines(c)).toEqual([]);
117
+ },
118
+ );
119
+ });
120
+
121
+ describe("the composed router, mounted alone, answers an error a route let through", () => {
122
+ it("is 500 server_error in the envelope, logged once at error", async () => {
123
+ const c = await boot();
124
+ const res = await request(c.app).get("/throws");
125
+
126
+ expect(res.status).toBe(500);
127
+ expect(res.headers["content-type"]).toMatch(/^application\/json/);
128
+ expect(res.body).toEqual({ error: "server_error", error_description: "unexpected_error" });
129
+ expect(res.text).not.toContain("escaped-error-marker");
130
+ expect(errorLines(c)).toEqual([
131
+ {
132
+ level: "error",
133
+ args: [
134
+ {
135
+ endpoint: "/throws",
136
+ err: expect.objectContaining({
137
+ name: "Error",
138
+ detail: "a store said something: escaped-error-marker",
139
+ }),
140
+ },
141
+ "unhandled_request_error",
142
+ ],
143
+ },
144
+ ]);
145
+ });
146
+ });