@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
@@ -0,0 +1,604 @@
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 the template's own `reference.conf`
21
+ * — reads only what the template reads before it knows its modules: the
22
+ * composition root's own `adapters` and `mfaMode`, with the template's
23
+ * schema (`adapters.test.mts`, `mfa-switch.test.mts`), the Store transport
24
+ * settings beside them, unparsed, and whether federation grants are
25
+ * installed, from `federation-grants.enabled` read with core's
26
+ * `coerceBooleanFromEnv` (the log level is the `logging` module's
27
+ * section, which `readLogging` reads with that module's schema:
28
+ * `own-modules.test.mts`);
29
+ * 2. `resolveForBoot` — its own files over the `reference.conf` of every
30
+ * package its modules come from, core's last — handed to `createApp`
31
+ * unparsed, which parses it once with every loaded module's schema.
32
+ *
33
+ * Phase one must read `federation-grants.enabled` as the federation-grants
34
+ * module's own schema and boot's parse do, and install the module for a value
35
+ * they refuse, so that boot refuses it; and it reads nothing else, so that a
36
+ * section a package's reference completes (known only in phase two) does not
37
+ * 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 {
45
+ BootError,
46
+ createApp,
47
+ defineModule,
48
+ type Module,
49
+ moduleReferences,
50
+ } from "@o3co/auth-provider-core";
51
+ import {
52
+ federationGrantsConfigSchema,
53
+ federationGrantsModules,
54
+ } from "@o3co/auth-provider-federation-grants";
55
+ import { oauthEndpointsModule } from "@o3co/auth-provider-oauth";
56
+ import {
57
+ redisFederationGrantIntentStoreModule,
58
+ redisFederationGrantStoreModule,
59
+ } from "@o3co/auth-provider-redis";
60
+ import { describe, expect, it } from "vitest";
61
+ import { ADAPTERS_SECTION } from "../adapters.mjs";
62
+ import { buildModules } from "../buildModules.mjs";
63
+ import {
64
+ readOwnLayers,
65
+ readSwitches,
66
+ resolveConfigPaths,
67
+ resolveForBoot,
68
+ resolveLayers,
69
+ type Switches,
70
+ } from "../configPath.mjs";
71
+ import { MFA_SWITCH } from "../mfaSwitch.mjs";
72
+
73
+ const configDir = fileURLToPath(new URL("../../config", import.meta.url));
74
+
75
+ const REQUIRED_ENV = {
76
+ KEY_STORE_LOCAL_SECRET: "two-phase-config-secret.at-least-32-bytes.ok",
77
+ OAUTH_JWT_ISSUER: "https://auth.test",
78
+ SESSION_STORE_SECRET: "two-phase-config-session.at-least-32-bytes.ok",
79
+ };
80
+
81
+ /** The environments the template ships for: none but the secrets, and the Redis-backed production one. */
82
+ const ENVIRONMENTS: Readonly<Record<string, Readonly<Record<string, string>>>> = {
83
+ "the secrets alone": REQUIRED_ENV,
84
+ "every adapter on Redis, MFA optional": {
85
+ ...REQUIRED_ENV,
86
+ CORE_DEPLOYMENT_MODE: "multi",
87
+ SESSION_STORE_STORAGE_TYPE: "redis",
88
+ SESSION_STORE_STORAGE_REDIS_URL: "redis://redis:6379",
89
+ REDIS_CLIENTS_URL: "redis://redis:6379",
90
+ ADAPTERS_USER_SESSION_STORES: "redis",
91
+ ADAPTERS_RATE_LIMITER: "redis",
92
+ ADAPTERS_CODE_REPOSITORY: "redis",
93
+ HTTP_PORT: "8080",
94
+ HTTP_TRUST_PROXY: "loopback",
95
+ SESSION_STORE_SECURE: "false",
96
+ MFA_MODE: "optional",
97
+ ADAPTERS_MFA_FACTOR_STORE: "redis",
98
+ ADAPTERS_MFA_TRANSACTION_STORE: "redis",
99
+ },
100
+ };
101
+
102
+ /** What phase one answers: the composition root's own keys, and the one switch of a module's section it decides by. */
103
+ const PHASE_ONE_PATHS: readonly string[] = [
104
+ ADAPTERS_SECTION,
105
+ MFA_SWITCH,
106
+ "storeTransport",
107
+ "federation-grants.enabled",
108
+ ];
109
+
110
+ const ownFiles = (environment: string): string[] => {
111
+ const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, environment);
112
+ return [envConfPath, applicationConfPath];
113
+ };
114
+
115
+ /** `federation-grants.enabled` as the federation-grants module's own schema parses the same layers. */
116
+ function ownerReads(environment: string, env: Readonly<Record<string, string>>): unknown {
117
+ const own = readOwnLayers(ownFiles(environment), { env });
118
+ const resolved = resolveLayers(own, moduleReferences(federationGrantsModules));
119
+ return federationGrantsConfigSchema.parse(resolved["federation-grants"])?.enabled;
120
+ }
121
+
122
+ /** The value at a dotted path, or `undefined`. */
123
+ function valueAt(tree: unknown, path: string): unknown {
124
+ let cursor: unknown = tree;
125
+ for (const key of path.split(".")) {
126
+ if (typeof cursor !== "object" || cursor === null || !Object.hasOwn(cursor, key))
127
+ return undefined;
128
+ cursor = (cursor as Record<string, unknown>)[key];
129
+ }
130
+ return cursor;
131
+ }
132
+
133
+ /** An operator's own HOCON layer, as a file above the template's. */
134
+ function operatorLayer(text: string): string {
135
+ const file = join(mkdtempSync(join(tmpdir(), "two-phase-config-")), "operator.conf");
136
+ writeFileSync(file, text);
137
+ return file;
138
+ }
139
+
140
+ /** `config`, recording every dotted path read off it. */
141
+ function recording(config: unknown): { readonly config: Switches; readonly reads: Set<string> } {
142
+ const reads = new Set<string>();
143
+ const wrap = (target: object, path: string): object =>
144
+ new Proxy(target, {
145
+ get(object, key, receiver) {
146
+ const value: unknown = Reflect.get(object, key, receiver);
147
+ if (typeof key !== "string" || !Object.hasOwn(object, key)) return value;
148
+ const at = path === "" ? key : `${path}.${key}`;
149
+ reads.add(at);
150
+ return typeof value === "object" && value !== null ? wrap(value, at) : value;
151
+ },
152
+ });
153
+ return { config: wrap(config as object, "") as Switches, reads };
154
+ }
155
+
156
+ describe("phase one reads federation-grants.enabled as the federation-grants module reads it", () => {
157
+ for (const environment of ["development", "production"]) {
158
+ for (const [name, env] of Object.entries(ENVIRONMENTS)) {
159
+ it(`${environment}, ${name}`, () => {
160
+ const switches = readSwitches(readOwnLayers(ownFiles(environment), { env }));
161
+ expect(switches["federation-grants"]?.enabled).toBe(ownerReads(environment, env) ?? false);
162
+ });
163
+ }
164
+ }
165
+
166
+ it.each([
167
+ ["true", true],
168
+ ["1", true],
169
+ [" TRUE ", true],
170
+ ["false", false],
171
+ ["0", false],
172
+ ["", false],
173
+ ])("reads FEDERATION_GRANTS_ENABLED=%j as the module's schema does: %s", (value, enabled) => {
174
+ const env = { ...REQUIRED_ENV, FEDERATION_GRANTS_ENABLED: value };
175
+ const switches = readSwitches(readOwnLayers(ownFiles("production"), { env }));
176
+ expect(switches["federation-grants"]?.enabled).toBe(enabled);
177
+ expect(ownerReads("production", env)).toBe(enabled);
178
+ });
179
+
180
+ it.each([
181
+ ["true", true],
182
+ ["false", false],
183
+ ])("reads federation-grants.enabled = %s written in HOCON", (literal, enabled) => {
184
+ const operator = operatorLayer(`federation-grants.enabled = ${literal}\n`);
185
+ const switches = readSwitches(
186
+ readOwnLayers([operator, ...ownFiles("production")], { env: REQUIRED_ENV }),
187
+ );
188
+ expect(switches["federation-grants"]?.enabled).toBe(enabled);
189
+ });
190
+
191
+ it.each([
192
+ 'federation-grants = "x"',
193
+ "federation-grants = null",
194
+ "federation-grants.enabled = 1",
195
+ "federation-grants.enabled = null",
196
+ ])("installs the modules for %s, which the module's schema refuses", (hocon) => {
197
+ const operator = operatorLayer(`${hocon}\n`);
198
+ const own = readOwnLayers([operator, ...ownFiles("production")], {
199
+ env: { ...REQUIRED_ENV, MFA_MODE: "off" },
200
+ });
201
+ const resolved = resolveLayers(own, moduleReferences(federationGrantsModules));
202
+ expect(federationGrantsConfigSchema.safeParse(resolved["federation-grants"]).success).toBe(
203
+ false,
204
+ );
205
+ const switches = readSwitches(own);
206
+ expect(switches["federation-grants"]?.enabled).toBe(true);
207
+ expect(
208
+ buildModules(switches, { environment: "production" }).map((module) => module.name),
209
+ ).toContain("federation-grants");
210
+ });
211
+
212
+ it("leaves the modules out for an empty section at the old path, federationGrants {}, which sets nothing", () => {
213
+ const operator = operatorLayer("federationGrants {}\n");
214
+ const switches = readSwitches(
215
+ readOwnLayers([operator, ...ownFiles("production")], {
216
+ env: { ...REQUIRED_ENV, MFA_MODE: "off" },
217
+ }),
218
+ );
219
+ expect(switches["federation-grants"]?.enabled).toBe(false);
220
+ expect(
221
+ buildModules(switches, { environment: "production" }).map((module) => module.name),
222
+ ).not.toContain("federation-grants");
223
+ });
224
+
225
+ it("reads the switch unwritten as off", () => {
226
+ const operator = operatorLayer('mfaMode = "off"\n');
227
+ const switches = readSwitches(readOwnLayers([operator], { env: {} }));
228
+ expect(switches["federation-grants"]?.enabled).toBe(false);
229
+ });
230
+
231
+ it("installs the modules for a value the module's schema refuses, so that boot refuses it naming the key", async () => {
232
+ const env = { ...REQUIRED_ENV, MFA_MODE: "off", FEDERATION_GRANTS_ENABLED: "yes" };
233
+ expect(() => ownerReads("production", env)).toThrow();
234
+ const own = readOwnLayers(ownFiles("production"), { env });
235
+ const switches = readSwitches(own);
236
+ expect(switches["federation-grants"]?.enabled).toBe(true);
237
+ const modules = buildModules(switches, { environment: "production" });
238
+ expect(modules.map((module) => module.name)).toContain("federation-grants");
239
+ const err = await createApp({
240
+ modules,
241
+ bootstrapComponents: {
242
+ config: resolveForBoot(own, modules, switches),
243
+ pathResolver: (s: string) => s,
244
+ },
245
+ }).then(
246
+ async (handle) => {
247
+ await handle.dispose();
248
+ return undefined;
249
+ },
250
+ (caught: unknown) => caught,
251
+ );
252
+ expect(err).toBeInstanceOf(BootError);
253
+ expect((err as BootError).reason).toBe("config-validation-failed");
254
+ expect((err as BootError).message).toContain("federation-grants.enabled");
255
+ });
256
+
257
+ it("installs the modules for a setting at the section's old path, which boot refuses naming the new one", () => {
258
+ const operator = operatorLayer("federationGrants.enabled = true\n");
259
+ const switches = readSwitches(
260
+ readOwnLayers([operator, ...ownFiles("production")], {
261
+ env: { ...REQUIRED_ENV, MFA_MODE: "off" },
262
+ }),
263
+ );
264
+ expect(switches["federation-grants"]?.enabled).toBe(true);
265
+ expect(
266
+ buildModules(switches, { environment: "production" }).map((module) => module.name),
267
+ ).toContain("federation-grants");
268
+ });
269
+
270
+ it("reads the MFA switch as its own mfaMode, as the environment sets it, and no key of the MFA module's", () => {
271
+ for (const [name, env] of Object.entries(ENVIRONMENTS)) {
272
+ const switches = readSwitches(readOwnLayers(ownFiles("production"), { env }));
273
+ expect(switches.mfaMode, name).toBe(env.MFA_MODE ?? "required");
274
+ expect(switches, name).not.toHaveProperty("mfa");
275
+ }
276
+ });
277
+ });
278
+
279
+ describe("phase one reads its switches and nothing else", () => {
280
+ const env = ENVIRONMENTS["every adapter on Redis, MFA optional"] as Readonly<
281
+ Record<string, string>
282
+ >;
283
+
284
+ it("accepts a section a package's reference completes, which only phase two layers", () => {
285
+ // The device grant's reference ships `windowSeconds`; boot layers it,
286
+ // and accepts what the operator wrote.
287
+ const partial = operatorLayer("device-grant.rateLimit.limit = 10\n");
288
+ expect(() =>
289
+ readSwitches(readOwnLayers([partial, ...ownFiles("production")], { env })),
290
+ ).not.toThrow();
291
+ });
292
+
293
+ it("answers the composition root's own keys and federation-grants.enabled, nothing of core's section or any other module's", () => {
294
+ const switches = readSwitches(readOwnLayers(ownFiles("production"), { env }));
295
+ expect(Object.keys(switches).sort()).toEqual(
296
+ [ADAPTERS_SECTION, MFA_SWITCH, "storeTransport", "federation-grants"].sort(),
297
+ );
298
+ expect(Object.keys(switches["federation-grants"] ?? {})).toEqual(["enabled"]);
299
+ });
300
+
301
+ it("reads, before boot, only paths among its switches", () => {
302
+ const { config, reads } = recording(
303
+ readSwitches(readOwnLayers(ownFiles("production"), { env })),
304
+ );
305
+ // What `app.mts` reads of phase one before boot: the modules.
306
+ buildModules(config, { environment: "production" });
307
+ const covered = (path: string) =>
308
+ PHASE_ONE_PATHS.some(
309
+ (switchPath) =>
310
+ path === switchPath ||
311
+ path.startsWith(`${switchPath}.`) ||
312
+ switchPath.startsWith(`${path}.`),
313
+ );
314
+ expect(reads.size).toBeGreaterThan(10);
315
+ expect([...reads].filter((path) => !covered(path))).toEqual([]);
316
+ });
317
+
318
+ it("reads no federation entry: core dispatches each by its type at boot", () => {
319
+ const federationEnvs: readonly Readonly<Record<string, string>>[] = [
320
+ {},
321
+ { CORE_FEDERATIONS_GOOGLE_ENABLED: "true" },
322
+ ];
323
+ for (const federations of federationEnvs) {
324
+ const { config, reads } = recording(
325
+ readSwitches(readOwnLayers(ownFiles("production"), { env: { ...env, ...federations } })),
326
+ );
327
+ expect(config).not.toHaveProperty("core");
328
+ buildModules(config, { environment: "production" });
329
+ expect(
330
+ [...reads].filter(
331
+ (path) => path === "core.federations" || path.startsWith("core.federations."),
332
+ ),
333
+ ).toEqual([]);
334
+ }
335
+ });
336
+
337
+ it("still refuses a switch it reads that the schema refuses, naming it", () => {
338
+ const bad = operatorLayer('adapters.rateLimiter = "carrier-pigeon"\n');
339
+ expect(() => readSwitches(readOwnLayers([bad, ...ownFiles("production")], { env }))).toThrow(
340
+ /adapters\.rateLimiter/,
341
+ );
342
+ });
343
+ });
344
+
345
+ describe("phase two: what createApp is handed", () => {
346
+ const env = ENVIRONMENTS["the secrets alone"] as Readonly<Record<string, string>>;
347
+ const own = readOwnLayers(ownFiles("development"), { env });
348
+ const switches = readSwitches(own);
349
+
350
+ /**
351
+ * A package the template does not load, shipping a reference.conf of its
352
+ * own: only its manifest's `section.reference` is read here, so the
353
+ * manifest carries nothing else.
354
+ */
355
+ function widgetModule(): Module {
356
+ const dir = mkdtempSync(join(tmpdir(), "two-phase-config-"));
357
+ const reference = join(dir, "reference.conf");
358
+ writeFileSync(
359
+ reference,
360
+ 'widget { size = 3 }\noauth.revocation.accessToken = "widget-revocation"\ncore.tokenBinding.dispatchPolicy = "widget-policy"\n',
361
+ );
362
+ return {
363
+ name: "widget",
364
+ section: { reference: pathToFileURL(reference) },
365
+ } as unknown as Module;
366
+ }
367
+
368
+ it("layers each loaded module's reference beneath the template's own files, over core's", () => {
369
+ const modules = [...buildModules(switches, { environment: "development" }), widgetModule()];
370
+ const resolved = resolveForBoot(own, modules, switches) as unknown as Record<
371
+ string,
372
+ Record<string, unknown>
373
+ >;
374
+ // The package's own section, from its reference.
375
+ expect(resolved.widget).toEqual({ size: 3 });
376
+ // The template's application.conf wins over a package's reference…
377
+ expect(resolved.oauth?.revocation).toEqual({ accessToken: "denylist" });
378
+ // …and a package's reference over core's.
379
+ expect(
380
+ (resolved.core?.tokenBinding as { dispatchPolicy?: unknown } | undefined)?.dispatchPolicy,
381
+ ).toBe("widget-policy");
382
+ });
383
+
384
+ it("layers no reference a loaded module does not declare", () => {
385
+ const modules = buildModules(switches, { environment: "development" });
386
+ const resolved = resolveForBoot(own, modules, switches) as unknown as Record<string, unknown>;
387
+ expect(resolved).not.toHaveProperty("widget");
388
+ });
389
+
390
+ 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", () => {
391
+ const optionalOwn = readOwnLayers(ownFiles("development"), {
392
+ env: { ...env, MFA_MODE: "optional", HTTP_PORT: "8080" },
393
+ });
394
+ const optional = readSwitches(optionalOwn);
395
+ const resolved = resolveForBoot(
396
+ optionalOwn,
397
+ buildModules(switches, { environment: "development" }),
398
+ optional,
399
+ ) as unknown as Record<string, Record<string, unknown>>;
400
+ // An environment variable's string, as HOCON substituted it: createApp parses it.
401
+ expect(resolved.http?.port).toBe("8080");
402
+ expect(resolved.core?.sessionRequirements).toEqual({
403
+ expected: ["mfa"],
404
+ secondFactorAuthority: "mfa",
405
+ });
406
+ });
407
+ });
408
+
409
+ describe("phase two refuses the Redis grant store's key prefix moved while the intent store's is left at its default", () => {
410
+ const env = ENVIRONMENTS["the secrets alone"] as Readonly<Record<string, string>>;
411
+ const GRANT = "REDIS_FEDERATION_GRANT_STORE_KEY_PREFIX";
412
+ const INTENT = "REDIS_FEDERATION_GRANT_INTENT_STORE_KEY_PREFIX";
413
+ const BOTH_ON_REDIS = [redisFederationGrantStoreModule, redisFederationGrantIntentStoreModule];
414
+
415
+ /** Phase two over the template's development files, the variables set beside the secrets. */
416
+ const resolve = (
417
+ variables: Readonly<Record<string, string>>,
418
+ modules: readonly Module[] = BOTH_ON_REDIS,
419
+ files: readonly string[] = ownFiles("development"),
420
+ ) => {
421
+ const own = readOwnLayers(files, { env: { ...env, ...variables } });
422
+ return resolveForBoot(own, modules, readSwitches(own));
423
+ };
424
+
425
+ /** What phase two refused with. */
426
+ const refusal = (...args: Parameters<typeof resolve>): BootError => {
427
+ try {
428
+ resolve(...args);
429
+ } catch (err) {
430
+ if (err instanceof BootError) return err;
431
+ throw err;
432
+ }
433
+ throw new Error("phase two resolved");
434
+ };
435
+
436
+ it("the grant store's set and the intent store's left alone: refused, naming both keys and both variables and quoting no value", () => {
437
+ const { message, reason, details } = refusal({ [GRANT]: "t1:fg:" });
438
+ expect(reason).toBe("config-validation-failed");
439
+ expect(details).toMatchObject({
440
+ issues: [{ code: "custom", path: ["redis-federation-grant-intent-store", "keyPrefix"] }],
441
+ modules: [
442
+ {
443
+ module: "redis-federation-grant-intent-store",
444
+ schemaPath: "redis-federation-grant-intent-store",
445
+ },
446
+ ],
447
+ });
448
+ expect(JSON.stringify(details)).not.toContain("fg:");
449
+ expect(message).toContain(`redis-federation-grant-store.keyPrefix (${GRANT})`);
450
+ expect(message).toContain(`redis-federation-grant-intent-store.keyPrefix (${INTENT})`);
451
+ expect(message).not.toContain("fg:");
452
+ });
453
+
454
+ it("the grant store's written in the operator's own layer: refused the same", () => {
455
+ const operator = operatorLayer('redis-federation-grant-store.keyPrefix = "t1:fg:"\n');
456
+ const { message } = refusal({}, BOTH_ON_REDIS, [operator, ...ownFiles("development")]);
457
+ expect(message).toContain(`redis-federation-grant-intent-store.keyPrefix (${INTENT})`);
458
+ expect(message).not.toContain("fg:");
459
+ });
460
+
461
+ it.each([
462
+ ["both set to the same prefix", { [GRANT]: "t1:fg:", [INTENT]: "t1:fg:" }],
463
+ ["each set to a prefix of its own", { [GRANT]: "t1:fg:", [INTENT]: "t1:fgi:" }],
464
+ ["neither set", {}],
465
+ ])("%s: resolved", (_, variables: Readonly<Record<string, string>>) => {
466
+ const resolved = resolve(variables) as unknown as Record<string, { keyPrefix?: unknown }>;
467
+ expect(resolved["redis-federation-grant-store"]?.keyPrefix).toBe(variables[GRANT] ?? "fg:");
468
+ expect(resolved["redis-federation-grant-intent-store"]?.keyPrefix).toBe(
469
+ variables[INTENT] ?? "fg:",
470
+ );
471
+ });
472
+
473
+ it("the grant store's set with the intent store not on Redis: resolved, as nothing of acquisition's is kept there", () => {
474
+ expect(() => resolve({ [GRANT]: "t1:fg:" }, [redisFederationGrantStoreModule])).not.toThrow();
475
+ });
476
+
477
+ 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", () => {
478
+ const { message } = refusal({ [GRANT]: "t1:fg:" }, [redisFederationGrantIntentStoreModule]);
479
+ expect(message).toContain(`redis-federation-grant-store.keyPrefix (${GRANT})`);
480
+ expect(message).toContain(`redis-federation-grant-intent-store.keyPrefix (${INTENT})`);
481
+ expect(message).not.toContain("fg:");
482
+ });
483
+
484
+ 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", () => {
485
+ const operator = operatorLayer('redisFederationGrantStore.keyPrefix = "t1:fg:"\n');
486
+ const { message, reason, details } = refusal(
487
+ { [INTENT]: "t1:fgi:" },
488
+ [redisFederationGrantIntentStoreModule],
489
+ [operator, ...ownFiles("development")],
490
+ );
491
+ expect(reason).toBe("config-path-relocated");
492
+ expect(details).toEqual({
493
+ reason: "config-path-relocated",
494
+ relocated: [
495
+ {
496
+ module: "redis-federation-grant-intent-store",
497
+ from: "redisFederationGrantStore.keyPrefix",
498
+ to: "redis-federation-grant-intent-store.keyPrefix",
499
+ environmentVariable: INTENT,
500
+ },
501
+ ],
502
+ });
503
+ expect(message).toContain("redisFederationGrantStore.keyPrefix");
504
+ expect(message).toContain(`redis-federation-grant-intent-store.keyPrefix (${INTENT})`);
505
+ expect(message).not.toContain("fg:");
506
+ });
507
+
508
+ it("grants kept in memory and intents on Redis, the intent store's prefix set: resolved", () => {
509
+ expect(() =>
510
+ resolve({ [GRANT]: "t1:fg:", [INTENT]: "t1:fgi:" }, [redisFederationGrantIntentStoreModule]),
511
+ ).not.toThrow();
512
+ });
513
+ });
514
+
515
+ describe("both phases read one snapshot of the composition's own layers", () => {
516
+ const env = ENVIRONMENTS["the secrets alone"] as Readonly<Record<string, string>>;
517
+
518
+ it("sees a file's first contents in both phases, though it is replaced between them", () => {
519
+ // Mounted configuration is commonly replaced atomically: read twice,
520
+ // boot could parse the federation-grants routes off while phase one
521
+ // installed them, and nothing would refuse the disagreement.
522
+ const operator = operatorLayer("federation-grants.enabled = true\n");
523
+ const own = readOwnLayers([operator, ...ownFiles("production")], { env });
524
+ writeFileSync(operator, "federation-grants.enabled = false\n");
525
+ const switches = readSwitches(own);
526
+ const resolved = resolveForBoot(own, [], switches);
527
+ expect(valueAt(switches, "federation-grants.enabled")).toBe(true);
528
+ expect(valueAt(resolved, "federation-grants.enabled")).toBe(true);
529
+ });
530
+
531
+ it("substitutes one snapshot of the environment in both phases", () => {
532
+ const changing: Record<string, string> = { ...env, FEDERATION_GRANTS_ENABLED: "true" };
533
+ const own = readOwnLayers(ownFiles("production"), { env: changing });
534
+ changing.FEDERATION_GRANTS_ENABLED = "false";
535
+ const switches = readSwitches(own);
536
+ const resolved = resolveForBoot(own, [], switches);
537
+ expect(valueAt(switches, "federation-grants.enabled")).toBe(true);
538
+ expect(valueAt(resolved, "federation-grants.enabled")).toBe("true");
539
+ });
540
+
541
+ it("reads the adapters from the same snapshot of the environment", () => {
542
+ const changing: Record<string, string> = { ...env, ADAPTERS_RATE_LIMITER: "redis" };
543
+ const own = readOwnLayers(ownFiles("production"), { env: changing });
544
+ changing.ADAPTERS_RATE_LIMITER = "memory";
545
+ expect(readSwitches(own).adapters.rateLimiter).toBe("redis");
546
+ });
547
+
548
+ it("reads every switch as boot's parse has it, for the shipped environments", async () => {
549
+ for (const environment of ["development", "production"]) {
550
+ for (const [name, shipped] of Object.entries(ENVIRONMENTS)) {
551
+ // `mfa.mode` off: the template declares `mfa` under another mode, and
552
+ // installs no module that registers it, so boot refuses. Phase one's
553
+ // reading of the mode is pinned against the pre-parse above.
554
+ const variables = { ...shipped, MFA_MODE: "off" };
555
+ const own = readOwnLayers(ownFiles(environment), { env: variables });
556
+ const switches = readSwitches(own);
557
+ const modules = buildModules(switches, { environment });
558
+ const resolved = resolveForBoot(own, modules, switches);
559
+ const handle = await createApp({
560
+ // The oauth module's section alone, read without its factories:
561
+ // the template loads the module, and core refuses keys of
562
+ // `oauth {}` where no loaded module's section is `oauth`.
563
+ modules: [
564
+ defineModule({
565
+ name: oauthEndpointsModule.name,
566
+ ...(oauthEndpointsModule.section === undefined
567
+ ? {}
568
+ : { section: oauthEndpointsModule.section }),
569
+ }),
570
+ ],
571
+ bootstrapComponents: {
572
+ config: resolved,
573
+ pathResolver: (s: string) => s,
574
+ ...FEDERATION_STORES,
575
+ } as never,
576
+ });
577
+ const parsed = handle.components.config;
578
+ await handle.dispose();
579
+ // Unwritten, the switch reads as off, as the shipped files leave it
580
+ // unless FEDERATION_GRANTS_ENABLED is set.
581
+ expect(valueAt(switches, "federation-grants.enabled"), `${environment}, ${name}`).toBe(
582
+ valueAt(parsed, "federation-grants.enabled") ?? false,
583
+ );
584
+ expect(valueAt(resolved, "core.sessionRequirements"), `${environment}, ${name}`).toEqual(
585
+ valueAt(parsed, "core.sessionRequirements"),
586
+ );
587
+ }
588
+ }
589
+ });
590
+
591
+ it("resolves nothing from no files and no references", () => {
592
+ expect(resolveLayers(readOwnLayers([], { env }), [])).toEqual({});
593
+ });
594
+ });
595
+
596
+ /** The stores an enabled federation needs, which boot refuses a composition without. */
597
+ const FEDERATION_STORES = Object.fromEntries(
598
+ [
599
+ "userSessionStore",
600
+ "sessionLifecycle",
601
+ "federationTokenStore",
602
+ "refreshTokenFamilyRevocation",
603
+ ].map((key) => [key, {}]),
604
+ );