@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
@@ -0,0 +1,438 @@
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 reads its configuration in two phases, as `app.mts` does:
19
+ *
20
+ * 1. `readSwitches` — its own files over core's `reference.conf`, read with
21
+ * core's transitional reader — parses only `SWITCHES`, what the template
22
+ * reads before it knows its modules: the switches `buildModules` chooses
23
+ * them by, and the configuration's `core.sessionRequirements`, which
24
+ * `expectedSessionRequirements` reads (the log level is the `logging`
25
+ * module's section, which `readLogging` reads with that module's schema:
26
+ * `own-modules.test.mts`) — and the composition root's own `adapters` and
27
+ * `mfaMode`, over the template's own reference, with the template's
28
+ * schema (`adapters.test.mts`, `mfa-switch.test.mts`), and the Store
29
+ * transport settings beside them, unparsed;
30
+ * 2. `resolveForBoot` — its own files over the `reference.conf` of every
31
+ * package its modules come from, core's last — handed to `createApp`
32
+ * unparsed, which parses it once with every loaded module's schema.
33
+ *
34
+ * Phase one must read each switch as a parse of the same layers with
35
+ * `AppConfigSchema` (through the HOCON library's Zod bridge) does, and nothing
36
+ * but its switches, so that a section a package's reference completes (known
37
+ * only in phase two) does not refuse it.
38
+ */
39
+
40
+ import { mkdtempSync, writeFileSync } from "node:fs";
41
+ import { tmpdir } from "node:os";
42
+ import { join } from "node:path";
43
+ import { fileURLToPath, pathToFileURL } from "node:url";
44
+ import { AppConfigSchema, coreReference, createApp, type Module } from "@o3co/auth-provider-core";
45
+ import {
46
+ redisFederationGrantIntentStoreModule,
47
+ redisFederationGrantStoreModule,
48
+ } from "@o3co/auth-provider-redis";
49
+ import { parseFile } from "@o3co/ts.hocon";
50
+ import { validate } from "@o3co/ts.hocon/zod";
51
+ import { describe, expect, it } from "vitest";
52
+ import { ADAPTERS_SECTION } from "../adapters.mjs";
53
+ import { buildModules } from "../buildModules.mjs";
54
+ import {
55
+ expectedSessionRequirements,
56
+ readOwnLayers,
57
+ readSwitches,
58
+ resolveConfigPaths,
59
+ resolveForBoot,
60
+ resolveLayers,
61
+ SWITCHES,
62
+ type Switches,
63
+ } from "../configPath.mjs";
64
+ import { MFA_SWITCH } from "../mfaSwitch.mjs";
65
+ import { templateReference } from "../modules.mjs";
66
+
67
+ const configDir = fileURLToPath(new URL("../../config", import.meta.url));
68
+
69
+ const REQUIRED_ENV = {
70
+ KEY_STORE_LOCAL_SECRET: "two-phase-config-secret.at-least-32-bytes.ok",
71
+ OAUTH_JWT_ISSUER: "https://auth.test",
72
+ SESSION_STORE_SECRET: "two-phase-config-session.at-least-32-bytes.ok",
73
+ };
74
+
75
+ /** The environments the template ships for: none but the secrets, and the Redis-backed production one. */
76
+ const ENVIRONMENTS: Readonly<Record<string, Readonly<Record<string, string>>>> = {
77
+ "the secrets alone": REQUIRED_ENV,
78
+ "every adapter on Redis, MFA optional": {
79
+ ...REQUIRED_ENV,
80
+ CORE_DEPLOYMENT_MODE: "multi",
81
+ SESSION_STORE_STORAGE_TYPE: "redis",
82
+ SESSION_STORE_STORAGE_REDIS_URL: "redis://redis:6379",
83
+ REDIS_CLIENTS_URL: "redis://redis:6379",
84
+ ADAPTERS_USER_SESSION_STORES: "redis",
85
+ ADAPTERS_RATE_LIMITER: "redis",
86
+ ADAPTERS_CODE_REPOSITORY: "redis",
87
+ HTTP_PORT: "8080",
88
+ HTTP_TRUST_PROXY: "loopback",
89
+ SESSION_STORE_SECURE: "false",
90
+ MFA_MODE: "optional",
91
+ ADAPTERS_MFA_FACTOR_STORE: "redis",
92
+ ADAPTERS_MFA_TRANSACTION_STORE: "redis",
93
+ },
94
+ };
95
+
96
+ const ownFiles = (environment: string): string[] => {
97
+ const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, environment);
98
+ return [envConfPath, applicationConfPath];
99
+ };
100
+
101
+ /** The layers parsed with `AppConfigSchema` through the bridge: how phase one must read each switch. */
102
+ function preParsed(environment: string, env: Readonly<Record<string, string>>): unknown {
103
+ const read = (file: string) => parseFile(file, { env: { ...env } });
104
+ const [top, application] = ownFiles(environment) as [string, string];
105
+ return validate(
106
+ read(top)
107
+ .withFallback(read(application))
108
+ .withFallback(read(fileURLToPath(templateReference())))
109
+ .withFallback(read(fileURLToPath(coreReference()))),
110
+ AppConfigSchema,
111
+ );
112
+ }
113
+
114
+ /** The value at a dotted path, or `undefined`. */
115
+ function valueAt(tree: unknown, path: string): unknown {
116
+ let cursor: unknown = tree;
117
+ for (const key of path.split(".")) {
118
+ if (typeof cursor !== "object" || cursor === null || !Object.hasOwn(cursor, key))
119
+ return undefined;
120
+ cursor = (cursor as Record<string, unknown>)[key];
121
+ }
122
+ return cursor;
123
+ }
124
+
125
+ /** An operator's own HOCON layer, as a file above the template's. */
126
+ function operatorLayer(text: string): string {
127
+ const file = join(mkdtempSync(join(tmpdir(), "two-phase-config-")), "operator.conf");
128
+ writeFileSync(file, text);
129
+ return file;
130
+ }
131
+
132
+ /** `config`, recording every dotted path read off it. */
133
+ function recording(config: unknown): { readonly config: Switches; readonly reads: Set<string> } {
134
+ const reads = new Set<string>();
135
+ const wrap = (target: object, path: string): object =>
136
+ new Proxy(target, {
137
+ get(object, key, receiver) {
138
+ const value: unknown = Reflect.get(object, key, receiver);
139
+ if (typeof key !== "string" || !Object.hasOwn(object, key)) return value;
140
+ const at = path === "" ? key : `${path}.${key}`;
141
+ reads.add(at);
142
+ return typeof value === "object" && value !== null ? wrap(value, at) : value;
143
+ },
144
+ });
145
+ return { config: wrap(config as object, "") as Switches, reads };
146
+ }
147
+
148
+ describe("phase one reads each switch as the template's AppConfigSchema pre-parse read it", () => {
149
+ for (const environment of ["development", "production"]) {
150
+ for (const [name, env] of Object.entries(ENVIRONMENTS)) {
151
+ it(`${environment}, ${name}`, () => {
152
+ const before = preParsed(environment, env);
153
+ const switches = readSwitches(readOwnLayers(ownFiles(environment), { env }));
154
+ const changed = SWITCHES.filter(
155
+ (path) =>
156
+ JSON.stringify(valueAt(before, path)) !== JSON.stringify(valueAt(switches, path)),
157
+ );
158
+ expect(changed).toEqual([]);
159
+ });
160
+ }
161
+ }
162
+
163
+ it("reads the MFA switch as its own mfaMode, as the environment sets it, and no key of the MFA module's", () => {
164
+ expect(SWITCHES.filter((path) => path.split(".")[0] === "mfa")).toEqual([]);
165
+ for (const [name, env] of Object.entries(ENVIRONMENTS)) {
166
+ expect(readSwitches(readOwnLayers(ownFiles("production"), { env })).mfaMode, name).toBe(
167
+ env.MFA_MODE ?? "required",
168
+ );
169
+ }
170
+ });
171
+ });
172
+
173
+ describe("phase one reads its switches and nothing else", () => {
174
+ const env = ENVIRONMENTS["every adapter on Redis, MFA optional"] as Readonly<
175
+ Record<string, string>
176
+ >;
177
+
178
+ it("accepts a section a package's reference completes, which only phase two layers", () => {
179
+ // The device grant's and WebAuthn's references ship `windowSeconds`;
180
+ // boot layers them, and accepts what the operator wrote.
181
+ const partial = operatorLayer(
182
+ "device-grant.rateLimit.limit = 10\nwebauthn.rateLimit.authenticationOptions.limit = 10\n",
183
+ );
184
+ expect(() =>
185
+ readSwitches(readOwnLayers([partial, ...ownFiles("production")], { env })),
186
+ ).not.toThrow();
187
+ });
188
+
189
+ it("reads, before boot, only paths among its switches", () => {
190
+ const { config, reads } = recording(
191
+ readSwitches(readOwnLayers(ownFiles("production"), { env })),
192
+ );
193
+ // What `app.mts` reads of phase one: the modules, and what the
194
+ // composition expects of session admission.
195
+ expectedSessionRequirements(config);
196
+ buildModules(config, { environment: "production" });
197
+ const covered = (path: string) =>
198
+ [...SWITCHES, ADAPTERS_SECTION, MFA_SWITCH, "storeTransport"].some(
199
+ (switchPath) =>
200
+ path === switchPath ||
201
+ path.startsWith(`${switchPath}.`) ||
202
+ switchPath.startsWith(`${path}.`),
203
+ );
204
+ expect(reads.size).toBeGreaterThan(10);
205
+ expect([...reads].filter((path) => !covered(path))).toEqual([]);
206
+ });
207
+
208
+ it("still refuses a switch it reads that the schema refuses, naming it", () => {
209
+ const bad = operatorLayer('adapters.rateLimiter = "carrier-pigeon"\n');
210
+ expect(() => readSwitches(readOwnLayers([bad, ...ownFiles("production")], { env }))).toThrow(
211
+ /adapters\.rateLimiter/,
212
+ );
213
+ });
214
+ });
215
+
216
+ describe("phase two: what createApp is handed", () => {
217
+ const env = ENVIRONMENTS["the secrets alone"] as Readonly<Record<string, string>>;
218
+ const own = readOwnLayers(ownFiles("development"), { env });
219
+ const switches = readSwitches(own);
220
+
221
+ /**
222
+ * A package the template does not load, shipping a reference.conf of its
223
+ * own: only its manifest's `section.reference` is read here, so the
224
+ * manifest carries nothing else.
225
+ */
226
+ function widgetModule(): Module {
227
+ const dir = mkdtempSync(join(tmpdir(), "two-phase-config-"));
228
+ const reference = join(dir, "reference.conf");
229
+ writeFileSync(
230
+ reference,
231
+ 'widget { size = 3 }\noauth.revocation.accessToken = "widget-revocation"\noauth.oidcMode = "widget-mode"\n',
232
+ );
233
+ return {
234
+ name: "widget",
235
+ section: { reference: pathToFileURL(reference) },
236
+ } as unknown as Module;
237
+ }
238
+
239
+ it("layers each loaded module's reference beneath the template's own files, over core's", () => {
240
+ const modules = [...buildModules(switches, { environment: "development" }), widgetModule()];
241
+ const resolved = resolveForBoot(own, modules, switches) as unknown as Record<
242
+ string,
243
+ Record<string, unknown>
244
+ >;
245
+ // The package's own section, from its reference.
246
+ expect(resolved.widget).toEqual({ size: 3 });
247
+ // The template's application.conf wins over a package's reference…
248
+ expect(resolved.oauth?.revocation).toEqual({ accessToken: "denylist" });
249
+ // …and a package's reference over core's.
250
+ expect(resolved.oauth?.oidcMode).toBe("widget-mode");
251
+ });
252
+
253
+ it("layers no reference a loaded module does not declare", () => {
254
+ const modules = buildModules(switches, { environment: "development" });
255
+ const resolved = resolveForBoot(own, modules, switches) as unknown as Record<string, unknown>;
256
+ expect(resolved).not.toHaveProperty("widget");
257
+ });
258
+
259
+ it("hands the configuration over as resolved and unparsed, with what phase one says the composition expects: mfa beside the configuration's list under MFA_MODE=optional", () => {
260
+ const optionalOwn = readOwnLayers(ownFiles("development"), {
261
+ env: { ...env, MFA_MODE: "optional", HTTP_PORT: "8080" },
262
+ });
263
+ const optional = readSwitches(optionalOwn);
264
+ const resolved = resolveForBoot(
265
+ optionalOwn,
266
+ buildModules(switches, { environment: "development" }),
267
+ optional,
268
+ ) as unknown as Record<string, Record<string, unknown>>;
269
+ // An environment variable's string, as HOCON substituted it: createApp parses it.
270
+ expect(resolved.http?.port).toBe("8080");
271
+ expect(resolved.core?.sessionRequirements).toEqual({
272
+ expected: ["mfa"],
273
+ secondFactorAuthority: "mfa",
274
+ });
275
+ });
276
+ });
277
+
278
+ describe("phase two refuses the Redis grant store's key prefix moved while the intent store's is left at its default", () => {
279
+ const env = ENVIRONMENTS["the secrets alone"] as Readonly<Record<string, string>>;
280
+ const GRANT = "REDIS_FEDERATION_GRANT_STORE_KEY_PREFIX";
281
+ const INTENT = "REDIS_FEDERATION_GRANT_INTENT_STORE_KEY_PREFIX";
282
+ const BOTH_ON_REDIS = [redisFederationGrantStoreModule, redisFederationGrantIntentStoreModule];
283
+
284
+ /** Phase two over the template's development files, the variables set beside the secrets. */
285
+ const resolve = (
286
+ variables: Readonly<Record<string, string>>,
287
+ modules: readonly Module[] = BOTH_ON_REDIS,
288
+ files: readonly string[] = ownFiles("development"),
289
+ ) => {
290
+ const own = readOwnLayers(files, { env: { ...env, ...variables } });
291
+ return resolveForBoot(own, modules, readSwitches(own));
292
+ };
293
+
294
+ /** What phase two refused with. */
295
+ const refusal = (...args: Parameters<typeof resolve>): RangeError => {
296
+ try {
297
+ resolve(...args);
298
+ } catch (err) {
299
+ if (err instanceof RangeError) return err;
300
+ throw err;
301
+ }
302
+ throw new Error("phase two resolved");
303
+ };
304
+
305
+ it("the grant store's set and the intent store's left alone: refused, naming both keys and both variables and quoting no value", () => {
306
+ const { message } = refusal({ [GRANT]: "t1:fg:" });
307
+ expect(message).toContain(`redis-federation-grant-store.keyPrefix (${GRANT})`);
308
+ expect(message).toContain(`redis-federation-grant-intent-store.keyPrefix (${INTENT})`);
309
+ expect(message).not.toContain("fg:");
310
+ });
311
+
312
+ it("the grant store's written in the operator's own layer: refused the same", () => {
313
+ const operator = operatorLayer('redis-federation-grant-store.keyPrefix = "t1:fg:"\n');
314
+ const { message } = refusal({}, BOTH_ON_REDIS, [operator, ...ownFiles("development")]);
315
+ expect(message).toContain(`redis-federation-grant-intent-store.keyPrefix (${INTENT})`);
316
+ expect(message).not.toContain("fg:");
317
+ });
318
+
319
+ it.each([
320
+ ["both set to the same prefix", { [GRANT]: "t1:fg:", [INTENT]: "t1:fg:" }],
321
+ ["each set to a prefix of its own", { [GRANT]: "t1:fg:", [INTENT]: "t1:fgi:" }],
322
+ ["neither set", {}],
323
+ ])("%s: resolved", (_, variables: Readonly<Record<string, string>>) => {
324
+ const resolved = resolve(variables) as unknown as Record<string, { keyPrefix?: unknown }>;
325
+ expect(resolved["redis-federation-grant-store"]?.keyPrefix).toBe(variables[GRANT] ?? "fg:");
326
+ expect(resolved["redis-federation-grant-intent-store"]?.keyPrefix).toBe(
327
+ variables[INTENT] ?? "fg:",
328
+ );
329
+ });
330
+
331
+ it("the grant store's set with the intent store not on Redis: resolved, as nothing of acquisition's is kept there", () => {
332
+ expect(() => resolve({ [GRANT]: "t1:fg:" }, [redisFederationGrantStoreModule])).not.toThrow();
333
+ });
334
+
335
+ it("grants kept in memory and intents on Redis: the grant store's variable refused the same, as the package's reference binds it beside the intent store's", () => {
336
+ const { message } = refusal({ [GRANT]: "t1:fg:" }, [redisFederationGrantIntentStoreModule]);
337
+ expect(message).toContain(`redis-federation-grant-store.keyPrefix (${GRANT})`);
338
+ expect(message).toContain(`redis-federation-grant-intent-store.keyPrefix (${INTENT})`);
339
+ expect(message).not.toContain("fg:");
340
+ });
341
+
342
+ it("grants kept in memory and intents on Redis: the grant store's key at its old path, which no loaded module relocates, refused naming the intent store's", () => {
343
+ const operator = operatorLayer('redisFederationGrantStore.keyPrefix = "t1:fg:"\n');
344
+ const { message } = refusal(
345
+ { [INTENT]: "t1:fgi:" },
346
+ [redisFederationGrantIntentStoreModule],
347
+ [operator, ...ownFiles("development")],
348
+ );
349
+ expect(message).toContain("redisFederationGrantStore.keyPrefix");
350
+ expect(message).toContain(`redis-federation-grant-intent-store.keyPrefix (${INTENT})`);
351
+ expect(message).not.toContain("fg:");
352
+ });
353
+
354
+ it("grants kept in memory and intents on Redis, the intent store's prefix set: resolved", () => {
355
+ expect(() =>
356
+ resolve({ [GRANT]: "t1:fg:", [INTENT]: "t1:fgi:" }, [redisFederationGrantIntentStoreModule]),
357
+ ).not.toThrow();
358
+ });
359
+ });
360
+
361
+ describe("both phases read one snapshot of the composition's own layers", () => {
362
+ const env = ENVIRONMENTS["the secrets alone"] as Readonly<Record<string, string>>;
363
+
364
+ it("sees a file's first contents in both phases, though it is replaced between them", () => {
365
+ // Mounted configuration is commonly replaced atomically: read twice,
366
+ // boot could parse the Redis session store while phase one chose the
367
+ // memory one, and nothing would refuse the disagreement.
368
+ const operator = operatorLayer('session-store.storage.type = "redis"\n');
369
+ const own = readOwnLayers([operator, ...ownFiles("production")], { env });
370
+ writeFileSync(operator, 'session-store.storage.type = "memory"\n');
371
+ const switches = readSwitches(own);
372
+ const resolved = resolveForBoot(own, [], switches);
373
+ expect(valueAt(switches, "session-store.storage.type")).toBe("redis");
374
+ expect(valueAt(resolved, "session-store.storage.type")).toBe("redis");
375
+ });
376
+
377
+ it("substitutes one snapshot of the environment in both phases", () => {
378
+ const changing: Record<string, string> = { ...env, SESSION_STORE_STORAGE_TYPE: "redis" };
379
+ const own = readOwnLayers(ownFiles("production"), { env: changing });
380
+ changing.SESSION_STORE_STORAGE_TYPE = "memory";
381
+ const switches = readSwitches(own);
382
+ const resolved = resolveForBoot(own, [], switches);
383
+ expect(valueAt(switches, "session-store.storage.type")).toBe("redis");
384
+ expect(valueAt(resolved, "session-store.storage.type")).toBe("redis");
385
+ });
386
+
387
+ it("reads the adapters from the same snapshot of the environment", () => {
388
+ const changing: Record<string, string> = { ...env, ADAPTERS_RATE_LIMITER: "redis" };
389
+ const own = readOwnLayers(ownFiles("production"), { env: changing });
390
+ changing.ADAPTERS_RATE_LIMITER = "memory";
391
+ expect(readSwitches(own).adapters.rateLimiter).toBe("redis");
392
+ });
393
+
394
+ it("reads every switch as boot's parse has it, for the shipped environments", async () => {
395
+ for (const environment of ["development", "production"]) {
396
+ for (const [name, shipped] of Object.entries(ENVIRONMENTS)) {
397
+ // `mfa.mode` off: the template declares `mfa` under another mode, and
398
+ // installs no module that registers it, so boot refuses. Phase one's
399
+ // reading of the mode is pinned against the pre-parse above.
400
+ const variables = { ...shipped, MFA_MODE: "off" };
401
+ const own = readOwnLayers(ownFiles(environment), { env: variables });
402
+ const switches = readSwitches(own);
403
+ const modules = buildModules(switches, { environment });
404
+ const handle = await createApp({
405
+ modules: [],
406
+ bootstrapComponents: {
407
+ config: resolveForBoot(own, modules, switches),
408
+ pathResolver: (s: string) => s,
409
+ ...FEDERATION_STORES,
410
+ } as never,
411
+ });
412
+ const parsed = handle.components.config;
413
+ await handle.dispose();
414
+ const differing = SWITCHES.filter(
415
+ (path) =>
416
+ JSON.stringify(valueAt(switches, path)) !== JSON.stringify(valueAt(parsed, path)),
417
+ );
418
+ expect(differing, `${environment}, ${name}`).toEqual([]);
419
+ }
420
+ }
421
+ });
422
+
423
+ it("resolves nothing from no files and no references", () => {
424
+ expect(resolveLayers(readOwnLayers([], { env }), [])).toEqual({});
425
+ });
426
+ });
427
+
428
+ /** The stores an enabled federation needs, which boot refuses a composition without. */
429
+ const FEDERATION_STORES = Object.fromEntries(
430
+ [
431
+ "userSessionStore",
432
+ "sessionRPRegistry",
433
+ "sessionFamilyIndex",
434
+ "sessionFederationIndex",
435
+ "federationTokenStore",
436
+ "refreshTokenFamilyRevocation",
437
+ ].map((key) => [key, {}]),
438
+ );