@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
@@ -15,63 +15,56 @@
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 (`sessionStoreModule`, which declares from
26
+ * its own section).
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
- AppConfigSchema,
58
42
  createApp,
59
43
  createKeyStoreFactory,
60
44
  defineModule,
61
45
  InMemoryClientRepository,
62
46
  InMemoryUserRepository,
47
+ type Module,
63
48
  memoryRefreshTokenFamilyStoreModule,
64
49
  registerBuiltinKeyStores,
65
50
  replicaUnsafeReason,
66
51
  } from "@o3co/auth-provider-core";
52
+ import { sessionStoreModule } from "@o3co/auth-provider-session";
53
+ import { standardSmtpMailSenderConfigForTests } from "@o3co/auth-provider-standard/testing";
67
54
  import { parseFile } from "@o3co/ts.hocon";
68
- 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 } from "../configPath.mjs";
58
+ import { templateReference } from "../modules.mjs";
59
+ import {
60
+ type BothPhases,
61
+ bothPhasesOf,
62
+ capturedRenames,
63
+ libraryLayers,
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,47 @@ 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
+ // The login's attempt counter, shared: per process, `multi` refuses the login.
156
+ ADAPTERS_ATTEMPT_COUNTER: "redis",
157
+ ADAPTERS_CODE_REPOSITORY: "redis",
158
+ ADAPTERS_ACCESS_TOKEN_DENYLIST: "redis",
159
+ ADAPTERS_REPLAY_SEEN_SET: "redis",
160
+ ADAPTERS_FEDERATION_TOKEN_STORE: "redis",
165
161
  REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: ENCRYPTION_KEY,
166
- // #561: the consent step for clients that are not first-party, on the
162
+ // The consent step for clients that are not first-party, on the
167
163
  // shared store — the one switch value `multi` accepts besides `none`.
168
- CONSENT_STORE_ADAPTER: "redis",
164
+ ADAPTERS_CONSENT_STORE: "redis",
169
165
  };
170
166
 
171
- function resolveConfig(env: Record<string, string>): AppConfig {
167
+ function resolveConfig(env: Record<string, string>): BothPhases {
172
168
  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
- );
169
+ const layers = parseFile(envConfPath, { env })
170
+ .withFallback(parseFile(applicationConfPath, { env }))
171
+ .withFallback(parseFile(fileURLToPath(templateReference()), { env }))
172
+ .withFallback(libraryLayers(env));
173
+ return {
174
+ ...bothPhasesOf(layers, env),
175
+ // What the resolution captured of the renamed variables.
176
+ "renamed-variables": capturedRenames(env),
177
+ } as BothPhases;
179
178
  }
180
179
 
181
180
  /** Drops a variable, so the HOCON default takes over. */
@@ -204,20 +203,27 @@ const testKeyStoreModule = defineModule({
204
203
  registerBuiltinKeyStores(factory);
205
204
  return factory.create({
206
205
  type: "local",
207
- ...((c as AppConfig).oauth.jwt.signingKey.local ?? {}),
206
+ ...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
208
207
  });
209
208
  },
210
209
  },
211
210
  });
212
211
 
213
- const modulesFor = (config: AppConfig, environment?: string) =>
212
+ const modulesFor = (config: BothPhases, environment?: string) =>
214
213
  buildModules(config, {
215
214
  keyStoreModule: testKeyStoreModule,
216
215
  repositoriesModule: testRepositoriesModule,
217
216
  ...(environment === undefined ? {} : { environment }),
218
217
  });
219
218
 
220
- const boot = (config: AppConfig, environment?: string) =>
219
+ /**
220
+ * `module`'s section in `config`, for a declaration made from it: each module
221
+ * here that has one sits at its name.
222
+ */
223
+ const sectionOf = (config: BothPhases, module: Module): unknown =>
224
+ (config as unknown as Record<string, unknown>)[module.name];
225
+
226
+ const boot = (config: BothPhases, environment?: string) =>
221
227
  createApp({
222
228
  modules: modulesFor(config, environment),
223
229
  bootstrapComponents: { config, pathResolver: (s) => s },
@@ -229,7 +235,7 @@ const messageChain = (err: unknown): string => {
229
235
  return `${e.message ?? ""} ${e.cause?.message ?? ""}`;
230
236
  };
231
237
 
232
- describe('#455: the standalone\'s memory modules are refused under deployment.mode = "multi"', () => {
238
+ describe('the standalone\'s memory modules are refused under core.deployment.mode = "multi"', () => {
233
239
  let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
234
240
 
235
241
  afterEach(async () => {
@@ -238,21 +244,20 @@ describe('#455: the standalone\'s memory modules are refused under deployment.mo
238
244
  });
239
245
 
240
246
  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
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
+ // The template's own modules.
248
+ ["ADAPTERS_USER_SESSION_STORES", "standalone-in-memory-session-stores"],
249
+ ["ADAPTERS_CODE_REPOSITORY", "standalone-in-memory-code-repository"],
250
+ ["ADAPTERS_FEDERATION_TOKEN_STORE", "standalone-in-memory-federation-token-store"],
251
+ // express-session's own store. Not a module of this template: it
252
+ // declares from its own section, which boot parses.
253
+ ["SESSION_STORE_STORAGE_TYPE", "session-store"],
254
+ // The consent store, wired only when the switch says so.
255
+ ["ADAPTERS_CONSENT_STORE", "core-consent-store-memory"],
256
+ // Core's, selected by the same kind of switch.
257
+ ["ADAPTERS_RATE_LIMITER", "core-rate-limiter-memory"],
258
+ ["ADAPTERS_ACCESS_TOKEN_DENYLIST", "core-access-token-denylist-memory"],
259
+ // The jti single-use record behind private_key_jwt client auth.
260
+ ["ADAPTERS_REPLAY_SEEN_SET", "core-replay-seen-set-memory"],
256
261
  ];
257
262
 
258
263
  for (const [variable, module] of cases) {
@@ -269,14 +274,14 @@ describe('#455: the standalone\'s memory modules are refused under deployment.mo
269
274
  it("names every memory module together when every switch is memory", async () => {
270
275
  const config = resolveConfig({
271
276
  ...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",
277
+ ADAPTERS_USER_SESSION_STORES: "memory",
278
+ ADAPTERS_CODE_REPOSITORY: "memory",
279
+ ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
280
+ SESSION_STORE_STORAGE_TYPE: "memory",
281
+ ADAPTERS_RATE_LIMITER: "memory",
282
+ ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
283
+ ADAPTERS_REPLAY_SEEN_SET: "memory",
284
+ ADAPTERS_CONSENT_STORE: "memory",
280
285
  });
281
286
  await expect(boot(config)).rejects.toMatchObject({
282
287
  reason: "replica-unsafe-adapter",
@@ -287,21 +292,21 @@ describe('#455: the standalone\'s memory modules are refused under deployment.mo
287
292
  });
288
293
 
289
294
  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
295
+ // The declaration is what the guard reads, so it has to be on
291
296
  // the module and it has to say what breaks — the guard quotes it.
292
297
  const config = resolveConfig({
293
298
  ...ALL_REDIS_ENV,
294
- USER_SESSION_STORES_ADAPTER: "memory",
295
- OAUTH_CODE_ADAPTER: "memory",
296
- FEDERATION_TOKEN_STORE_TYPE: "memory",
299
+ ADAPTERS_USER_SESSION_STORES: "memory",
300
+ ADAPTERS_CODE_REPOSITORY: "memory",
301
+ ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
297
302
  });
298
303
  const standaloneMemoryModules = modulesFor(config).filter((m) =>
299
- m.name.startsWith("standalone:in-memory-"),
304
+ m.name.startsWith("standalone-in-memory-"),
300
305
  );
301
306
  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",
307
+ "standalone-in-memory-code-repository",
308
+ "standalone-in-memory-federation-token-store",
309
+ "standalone-in-memory-session-stores",
305
310
  ]);
306
311
  for (const m of standaloneMemoryModules) {
307
312
  expect(replicaUnsafeReason(m), m.name).toBeDefined();
@@ -312,14 +317,116 @@ describe('#455: the standalone\'s memory modules are refused under deployment.mo
312
317
  it("boots the all-Redis environment, with nothing declaring replica-unsafe state", async () => {
313
318
  const config = resolveConfig(ALL_REDIS_ENV);
314
319
  for (const m of modulesFor(config)) {
315
- expect(replicaUnsafeReason(m), m.name).toBeUndefined();
320
+ expect(replicaUnsafeReason(m, sectionOf(config, m)), m.name).toBeUndefined();
316
321
  }
317
322
  handleRef = await boot(config);
318
323
  expect(handleRef).toBeDefined();
319
324
  });
320
325
  });
321
326
 
322
- describe('#456: federationTokenStore.type = "redis" in the standalone', () => {
327
+ describe("express-session's store declares its replica safety from its own section", () => {
328
+ it("is the session package's sessionStoreModule, listed first", () => {
329
+ expect(modulesFor(resolveConfig(ALL_REDIS_ENV))[0]).toBe(sessionStoreModule);
330
+ });
331
+
332
+ /** Each way the storage type is set, laid over the all-Redis environment. */
333
+ const STORAGE: ReadonlyArray<readonly [string, Record<string, string>]> = [
334
+ [
335
+ "SESSION_STORE_STORAGE_TYPE=memory",
336
+ { ...ALL_REDIS_ENV, SESSION_STORE_STORAGE_TYPE: "memory" },
337
+ ],
338
+ ["SESSION_STORE_STORAGE_TYPE=redis", ALL_REDIS_ENV],
339
+ [
340
+ "no SESSION_STORE_STORAGE_TYPE, the reference's default",
341
+ without(ALL_REDIS_ENV, "SESSION_STORE_STORAGE_TYPE"),
342
+ ],
343
+ [
344
+ "the variable it was renamed from, SESSION_STORAGE_TYPE=memory",
345
+ { ...without(ALL_REDIS_ENV, "SESSION_STORE_STORAGE_TYPE"), SESSION_STORAGE_TYPE: "memory" },
346
+ ],
347
+ ];
348
+ const MODES: ReadonlyArray<
349
+ readonly [string, (env: Record<string, string>) => Record<string, string>]
350
+ > = [
351
+ ["multi", (env) => ({ ...env, CORE_DEPLOYMENT_MODE: "multi" })],
352
+ ["single", (env) => ({ ...env, CORE_DEPLOYMENT_MODE: "single" })],
353
+ ["unset", (env) => without(env, "CORE_DEPLOYMENT_MODE")],
354
+ ];
355
+
356
+ /** What a boot of `modules` settles as: the refusal, or the replica-safety warnings it logged. */
357
+ async function outcomeOf(config: BothPhases, modules: readonly Module[]): Promise<unknown> {
358
+ const warn = vi.fn();
359
+ const logger = {
360
+ warn,
361
+ info: vi.fn(),
362
+ error: vi.fn(),
363
+ debug: vi.fn(),
364
+ trace: vi.fn(),
365
+ fatal: vi.fn(),
366
+ child: vi.fn(),
367
+ };
368
+ try {
369
+ const handle = await createApp({
370
+ modules: [...modules],
371
+ bootstrapComponents: { config, logger, pathResolver: (s: string) => s } as never,
372
+ });
373
+ await handle.dispose();
374
+ } catch (err) {
375
+ const e = err as { reason?: string; message?: string; details?: unknown };
376
+ return { refused: { reason: e.reason, message: e.message, details: e.details } };
377
+ }
378
+ return {
379
+ warned: warn.mock.calls.filter(([, event]) => event === "replica_unsafe_adapters"),
380
+ };
381
+ }
382
+
383
+ for (const [storage, env] of STORAGE) {
384
+ for (const [mode, withMode] of MODES) {
385
+ it(`${storage} under ${mode}`, async () => {
386
+ const config = resolveConfig(withMode(env));
387
+ const modules = modulesFor(config);
388
+ expect(modules[0]).toBe(sessionStoreModule);
389
+ const outcome = await outcomeOf(config, modules);
390
+ if (
391
+ storage === "SESSION_STORE_STORAGE_TYPE=redis" ||
392
+ storage === "no SESSION_STORE_STORAGE_TYPE, the reference's default"
393
+ ) {
394
+ // A shared store: nothing is refused or warned about.
395
+ expect(outcome).toEqual({ warned: [] });
396
+ }
397
+ if (storage === "SESSION_STORE_STORAGE_TYPE=memory") {
398
+ const named = { modules: ["session-store"] };
399
+ expect(outcome).toEqual(
400
+ mode === "multi"
401
+ ? {
402
+ refused: expect.objectContaining({
403
+ reason: "replica-unsafe-adapter",
404
+ message: expect.stringContaining(
405
+ "session-store: the express-session store forks per replica",
406
+ ),
407
+ details: expect.objectContaining(named),
408
+ }),
409
+ }
410
+ : {
411
+ warned:
412
+ mode === "unset"
413
+ ? [[expect.objectContaining(named), "replica_unsafe_adapters"]]
414
+ : [],
415
+ },
416
+ );
417
+ }
418
+ if (storage === "the variable it was renamed from, SESSION_STORAGE_TYPE=memory") {
419
+ // Refused as renamed before the replica-safety guard reads anything.
420
+ expect(outcome).toEqual({
421
+ refused: expect.objectContaining({ reason: "environment-variable-renamed" }),
422
+ });
423
+ }
424
+ });
425
+ }
426
+ }
427
+ });
428
+
429
+ describe('adapters.federationTokenStore = "redis" in the standalone', () => {
323
430
  let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
324
431
 
325
432
  afterEach(async () => {
@@ -327,13 +434,11 @@ describe('#456: federationTokenStore.type = "redis" in the standalone', () => {
327
434
  handleRef = undefined;
328
435
  });
329
436
 
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");
437
+ it("reaches phase one's adapters from ADAPTERS_FEDERATION_TOKEN_STORE", () => {
438
+ expect(resolveConfig(ALL_REDIS_ENV).adapters.federationTokenStore).toBe("redis");
334
439
  expect(
335
- resolveConfig(without(ALL_REDIS_ENV, "FEDERATION_TOKEN_STORE_TYPE")).federationTokenStore
336
- ?.type,
440
+ resolveConfig(without(ALL_REDIS_ENV, "ADAPTERS_FEDERATION_TOKEN_STORE")).adapters
441
+ .federationTokenStore,
337
442
  ).toBe("memory");
338
443
  });
339
444
 
@@ -341,8 +446,8 @@ describe('#456: federationTokenStore.type = "redis" in the standalone', () => {
341
446
  const modules = modulesFor(resolveConfig(ALL_REDIS_ENV));
342
447
  const names = modules.map((m) => m.name);
343
448
  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");
449
+ expect(names).toContain("redis-clients");
450
+ expect(names).not.toContain("standalone-in-memory-federation-token-store");
346
451
  // Exactly one provider for the slot: both modules provide it, so
347
452
  // selecting both would be a boot-time slot collision.
348
453
  const providers = modules.filter((m) =>
@@ -355,18 +460,18 @@ describe('#456: federationTokenStore.type = "redis" in the standalone', () => {
355
460
  // Every other store on memory, single replica: the Redis federation
356
461
  // store still needs `federationTokenStoreClient`, which only the shared
357
462
  // clients module provides. Without this the branch fails stage 1 with
358
- // `missing-required-component` — the #439 shape, once more.
463
+ // `missing-required-component`.
359
464
  const config = resolveConfig({
360
465
  ...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",
466
+ CORE_DEPLOYMENT_MODE: "single",
467
+ ADAPTERS_USER_SESSION_STORES: "memory",
468
+ ADAPTERS_CODE_REPOSITORY: "memory",
469
+ ADAPTERS_RATE_LIMITER: "memory",
470
+ ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
366
471
  });
367
472
  const names = modulesFor(config).map((m) => m.name);
368
473
  expect(names).toContain("redis-federation-token-store");
369
- expect(names).toContain("standalone:redis-clients");
474
+ expect(names).toContain("redis-clients");
370
475
  });
371
476
 
372
477
  it("boots, and the resolved store is the Redis adapter", async () => {
@@ -388,20 +493,20 @@ describe('#456: federationTokenStore.type = "redis" in the standalone', () => {
388
493
  });
389
494
  });
390
495
 
391
- it("keeps the memory branch as it was: the default, in single mode, resolves the memory adapter", async () => {
496
+ it("keeps the memory branch: the default, in single mode, resolves the memory adapter", async () => {
392
497
  const config = resolveConfig({
393
- ...without(ALL_REDIS_ENV, "FEDERATION_TOKEN_STORE_TYPE"),
394
- DEPLOYMENT_MODE: "single",
498
+ ...without(ALL_REDIS_ENV, "ADAPTERS_FEDERATION_TOKEN_STORE"),
499
+ CORE_DEPLOYMENT_MODE: "single",
395
500
  });
396
501
  const names = modulesFor(config).map((m) => m.name);
397
- expect(names).toContain("standalone:in-memory-federation-token-store");
502
+ expect(names).toContain("standalone-in-memory-federation-token-store");
398
503
  expect(names).not.toContain("redis-federation-token-store");
399
504
  handleRef = await boot(config);
400
505
  expect(handleRef.components.federationTokenStore?.kind).toBe("memory");
401
506
  });
402
507
  });
403
508
 
404
- describe('#561: consentStore.adapter = "redis" in the standalone', () => {
509
+ describe('consentStore.adapter = "redis" in the standalone', () => {
405
510
  let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
406
511
 
407
512
  afterEach(async () => {
@@ -428,16 +533,16 @@ describe('#561: consentStore.adapter = "redis" in the standalone', () => {
428
533
  it("pulls the shared clients module in for the consent stores alone", () => {
429
534
  // Every other store on memory, single replica: the consent module still
430
535
  // needs its two client slots, which only the shared clients module
431
- // provides — the #439 shape otherwise.
536
+ // provides.
432
537
  const config = resolveConfig({
433
538
  ...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",
539
+ CORE_DEPLOYMENT_MODE: "single",
540
+ ADAPTERS_USER_SESSION_STORES: "memory",
541
+ ADAPTERS_CODE_REPOSITORY: "memory",
542
+ ADAPTERS_RATE_LIMITER: "memory",
543
+ ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
544
+ ADAPTERS_REPLAY_SEEN_SET: "memory",
545
+ ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
441
546
  });
442
547
  const names = buildModules(config, {
443
548
  keyStoreModule: testKeyStoreModule,
@@ -445,17 +550,17 @@ describe('#561: consentStore.adapter = "redis" in the standalone', () => {
445
550
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
446
551
  }).map((m) => m.name);
447
552
  expect(names).toContain("redis-consent-store");
448
- expect(names).toContain("standalone:redis-clients");
553
+ expect(names).toContain("redis-clients");
449
554
  });
450
555
 
451
- it("boots under DEPLOYMENT_MODE=multi with both slots resolved to the Redis adapters", async () => {
556
+ it("boots under CORE_DEPLOYMENT_MODE=multi with both slots resolved to the Redis adapters", async () => {
452
557
  handleRef = await boot(resolveConfig(ALL_REDIS_ENV));
453
558
  expect(handleRef.components.consentStore?.kind).toBe("redis");
454
559
  expect(handleRef.components.pendingConsentStore?.kind).toBe("redis");
455
560
  });
456
561
  });
457
562
 
458
- describe("#473: the Redis federation store's plaintext guard, booted from the shipped config", () => {
563
+ describe("the Redis federation store's plaintext guard, booted from the shipped config", () => {
459
564
  let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
460
565
  let warnSpy: ReturnType<typeof vi.spyOn>;
461
566
  let errorSpy: ReturnType<typeof vi.spyOn>;
@@ -486,28 +591,36 @@ describe("#473: the Redis federation store's plaintext guard, booted from the sh
486
591
  REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_MODE: "allow-plaintext",
487
592
  };
488
593
 
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)),
594
+ it('is refused under core.deployment.mode = "multi" in an environment that is not production', async () => {
595
+ // The umbrella shape with plaintext. A multi-replica deployment is
596
+ // never a development box. A development configuration under multi is
597
+ // refused by the development mail sender first, so the environment here
598
+ // is another name that is not production, with the SMTP sender's section.
599
+ await expect(
600
+ boot({ ...resolveConfig(PLAINTEXT_ENV), ...standardSmtpMailSenderConfigForTests() }, "test"),
601
+ ).rejects.toSatisfy((err: unknown) =>
602
+ /allow-plaintext[\s\S]*core\.deployment\.mode is "multi"/.test(messageChain(err)),
495
603
  );
496
604
  expect(errorSpy).not.toHaveBeenCalled();
497
605
  });
498
606
 
499
607
  it("is refused when the config was selected by CONFIG_ENV=production, whatever NODE_ENV says", async () => {
500
608
  // `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" });
609
+ // passes that name through `buildModules`; the guard reads that name,
610
+ // not NODE_ENV alone.
611
+ // Under production the template installs the SMTP sender's module, whose
612
+ // section the package's builder carries.
613
+ const config = {
614
+ ...resolveConfig({ ...PLAINTEXT_ENV, CORE_DEPLOYMENT_MODE: "single" }),
615
+ ...standardSmtpMailSenderConfigForTests(),
616
+ };
504
617
  await expect(boot(config, "production")).rejects.toSatisfy((err: unknown) =>
505
- /the environment is "production"/.test(messageChain(err)),
618
+ /allow-plaintext[\s\S]*the environment is "production"/.test(messageChain(err)),
506
619
  );
507
620
  });
508
621
 
509
622
  it("boots with the plaintext warning in a development environment on a single replica", async () => {
510
- const config = resolveConfig({ ...PLAINTEXT_ENV, DEPLOYMENT_MODE: "single" });
623
+ const config = resolveConfig({ ...PLAINTEXT_ENV, CORE_DEPLOYMENT_MODE: "single" });
511
624
  handleRef = await boot(config, "development");
512
625
  expect(handleRef.components.federationTokenStore?.kind).toBe("redis");
513
626
  expect(warnSpy).toHaveBeenCalledWith(
@@ -518,7 +631,13 @@ describe("#473: the Redis federation store's plaintext guard, booted from the sh
518
631
 
519
632
  it("keeps the FEDERATION_TOKENS_ALLOW_INSECURE=1 escape hatch under multi, logged at error", async () => {
520
633
  process.env.FEDERATION_TOKENS_ALLOW_INSECURE = "1";
521
- handleRef = await boot(resolveConfig(PLAINTEXT_ENV), "development");
634
+ // Under multi the development mail sender refuses a development
635
+ // configuration, so the environment here is another name that is not
636
+ // production, with the SMTP sender's section its module reads.
637
+ handleRef = await boot(
638
+ { ...resolveConfig(PLAINTEXT_ENV), ...standardSmtpMailSenderConfigForTests() },
639
+ "test",
640
+ );
522
641
  expect(errorSpy).toHaveBeenCalledWith(
523
642
  expect.objectContaining({
524
643
  store: "federation-tokens",
@@ -19,13 +19,10 @@
19
19
  * with no terminal handler of the template's after it — the router alone, as
20
20
  * a composition root that copies nothing of `app.mts` mounts it.
21
21
  *
22
- * The OAuth and session routers parse their own bodies and have no error
23
- * handler: a body parser's refusal on `/oauth/token`, `/oauth/introspect` or
24
- * `/session/login`, and any error a route let through, went on to whatever
25
- * the host had after the router. Only `app.mts` had a handler there, so any
26
- * other host answered with Express's final handler — an HTML page, with the
27
- * stack outside production, where V8's JSON error quotes the body it could
28
- * not parse. The router core returns now answers them itself.
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.
29
26
  */
30
27
 
31
28
  import { defineModule } from "@o3co/auth-provider-core";