@o3co/create-auth-provider 0.17.0-rc.1 → 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 (58) hide show
  1. package/package.json +1 -1
  2. package/templates/standalone/.env.example +0 -3
  3. package/templates/standalone/README.ja.md +42 -18
  4. package/templates/standalone/README.md +120 -44
  5. package/templates/standalone/config/application.conf +40 -36
  6. package/templates/standalone/config/reference.conf +21 -2
  7. package/templates/standalone/docker-compose.production.yml +5 -2
  8. package/templates/standalone/package.json +1 -1
  9. package/templates/standalone/src/__tests__/adapters.test.mts +80 -19
  10. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
  11. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +112 -98
  12. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +27 -6
  13. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +86 -51
  14. package/templates/standalone/src/__tests__/audit-sink.test.mts +10 -9
  15. package/templates/standalone/src/__tests__/config-notices.test.mts +154 -0
  16. package/templates/standalone/src/__tests__/core-federations.test.mts +48 -25
  17. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +1 -1
  18. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +349 -54
  19. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +15 -21
  20. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +9 -15
  21. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +10 -4
  22. package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
  23. package/templates/standalone/src/__tests__/google-session.fixture.mts +60 -67
  24. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
  25. package/templates/standalone/src/__tests__/library-references.fixture.mts +51 -46
  26. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
  27. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -2
  28. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
  29. package/templates/standalone/src/__tests__/mfa-switch.test.mts +70 -25
  30. package/templates/standalone/src/__tests__/own-modules.test.mts +40 -11
  31. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +3 -3
  32. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +8 -4
  33. package/templates/standalone/src/__tests__/replica-safety.test.mts +127 -19
  34. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +15 -20
  35. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
  36. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +3 -7
  37. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
  38. package/templates/standalone/src/__tests__/session-requirements.test.mts +114 -31
  39. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +270 -0
  40. package/templates/standalone/src/__tests__/smoke.test.mts +19 -28
  41. package/templates/standalone/src/__tests__/store-credential.test.mts +8 -13
  42. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +50 -34
  43. package/templates/standalone/src/__tests__/two-phase-config.test.mts +242 -76
  44. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +23 -31
  45. package/templates/standalone/src/adapters.mts +31 -18
  46. package/templates/standalone/src/app.mts +6 -5
  47. package/templates/standalone/src/bootRefusal.mts +117 -0
  48. package/templates/standalone/src/buildModules.mts +46 -96
  49. package/templates/standalone/src/configPath.mts +180 -153
  50. package/templates/standalone/src/mfaSwitch.mts +43 -20
  51. package/templates/standalone/src/modules.mts +25 -213
  52. package/templates/standalone/src/rootRenames.mts +35 -18
  53. package/templates/standalone/src/sections.mts +1 -0
  54. package/templates/standalone/tests/index.test.js +4 -3
  55. package/templates/versions.json +18 -18
  56. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -134
  57. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -202
  58. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +0 -144
@@ -15,21 +15,27 @@
15
15
  */
16
16
 
17
17
  import { fileURLToPath } from "node:url";
18
- import { type AppConfig, AppConfigSchema, coreReference } from "@o3co/auth-provider-core";
18
+ import type { AppConfig } from "@o3co/auth-provider-core";
19
+ import { type OAuthSection, oauthEndpointsModule } from "@o3co/auth-provider-oauth";
19
20
  import { parseFile } from "@o3co/ts.hocon";
20
- import { validate } from "@o3co/ts.hocon/zod";
21
21
  import { describe, expect, it } from "vitest";
22
22
  import { readAdapters } from "../adapters.mjs";
23
23
  import { buildModules } from "../buildModules.mjs";
24
- import { resolveConfigPaths, type Switches } from "../configPath.mjs";
25
- import { readMfaSwitch } from "../mfaSwitch.mjs";
24
+ import {
25
+ readOwnLayers,
26
+ readSwitches,
27
+ resolveConfigPaths,
28
+ resolveForBoot,
29
+ type Switches,
30
+ } from "../configPath.mjs";
26
31
  import { templateReference } from "../modules.mjs";
32
+ import { type BothPhases, withSwitches } from "./library-references.fixture.mjs";
27
33
 
28
34
  // config/ is two levels above this test file:
29
35
  // src/__tests__/ → src/ → standalone/ → config/
30
36
  const configDir = fileURLToPath(new URL("../../config", import.meta.url));
31
37
 
32
- // Provide required secrets so AppConfigSchema parse succeeds. These are
38
+ // Provide the secrets the template's files require. These are
33
39
  // test-only values — no real keys are embedded here. SESSION_STORE_SECRET
34
40
  // carries a 256-bit entropy floor, so these clear it (the '.' characters keep
35
41
  // them outside the base64 alphabet, so the UTF-8 length is what counts).
@@ -40,30 +46,32 @@ const testEnv = {
40
46
  };
41
47
 
42
48
  /**
43
- * The three tiers under `env`, parsed with core's schema, beside the
44
- * composition root's `adapters` and `mfaMode` phase one reads from the
45
- * template's own layers.
49
+ * The three tiers under `env`, as `app.mts` reads them: what phase one reads
50
+ * of the template's own layers, laid over what phase two hands boot — the
51
+ * own layers over the reference of every package the chosen modules come
52
+ * from, resolved and unparsed.
46
53
  */
47
- function buildResolvedConfig(env: string, extraEnv: Record<string, string> = {}): Switches {
54
+ function buildResolvedConfig(env: string, extraEnv: Record<string, string> = {}): BothPhases {
48
55
  const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, env);
49
- const libraryReferencePath = fileURLToPath(coreReference());
50
- const resolvedEnv = { ...testEnv, ...extraEnv };
51
- const own = parseFile(envConfPath, { env: resolvedEnv })
52
- .withFallback(parseFile(applicationConfPath, { env: resolvedEnv }))
53
- .withFallback(parseFile(fileURLToPath(templateReference()), { env: resolvedEnv }));
54
- return {
55
- ...validate(
56
- own.withFallback(parseFile(libraryReferencePath, { env: resolvedEnv })),
57
- AppConfigSchema,
58
- ),
59
- adapters: readAdapters(own.toObject() as Record<string, unknown>, resolvedEnv),
60
- mfaMode: readMfaSwitch(own.toObject() as Record<string, unknown>, resolvedEnv),
61
- storeTransport: undefined,
62
- };
56
+ const own = readOwnLayers([envConfPath, applicationConfPath], {
57
+ env: { ...testEnv, ...extraEnv },
58
+ });
59
+ const switches = readSwitches(own);
60
+ return withSwitches(
61
+ resolveForBoot(own, buildModules(switches), switches) as unknown as Record<string, unknown>,
62
+ switches,
63
+ );
64
+ }
65
+
66
+ /** `oauth.resourceIndicator` as the section's owner, the oauth module, parses it at boot. */
67
+ function parsedResourceIndicator(config: AppConfig): { readonly enabled?: unknown } | undefined {
68
+ const schema = oauthEndpointsModule.section?.schema;
69
+ if (schema === undefined) throw new Error("the oauth module declares no section");
70
+ return (schema.parse(config.oauth) as { resourceIndicator?: { enabled?: unknown } })
71
+ .resourceIndicator;
63
72
  }
64
73
 
65
- // The oauth-authorization module's section, which core mirrors for the one
66
- // key read before the modules are chosen (`grants`), kept as written: the
74
+ // The oauth-authorization module's section as resolved, unparsed: the
67
75
  // runtime shape is a string from env substitution, a boolean from a literal.
68
76
  type GrantEntry = { enabled?: unknown };
69
77
  const grants = (config: AppConfig) =>
@@ -71,6 +79,9 @@ const grants = (config: AppConfig) =>
71
79
  | Record<string, GrantEntry | undefined>
72
80
  | undefined) ?? {};
73
81
 
82
+ /** `oauth {}` as the resolution leaves it, read as the shape the oauth module parses. */
83
+ const oauthOf = (config: AppConfig): OAuthSection => config.oauth as OAuthSection;
84
+
74
85
  describe("three-tier HOCON resolution (env → application.conf → reference.conf)", () => {
75
86
  it("template application.conf wins over reference.conf for grant.enabled", () => {
76
87
  const config = buildResolvedConfig("development");
@@ -83,15 +94,15 @@ describe("three-tier HOCON resolution (env → application.conf → reference.co
83
94
  const config = buildResolvedConfig("development");
84
95
  // nonce.maxLength is library-owned in both layers (template doesn't
85
96
  // override it). Verifies precedence falls through.
86
- expect(config.oauth.nonce?.maxLength).toBe(256);
97
+ expect(oauthOf(config).nonce?.maxLength).toBe(256);
87
98
  });
88
99
 
89
100
  it("env var at template layer can disable a template-enabled grant (precedence: env-override line must be repeated)", () => {
90
101
  // The env-override line is repeated at the template layer alongside
91
102
  // `enabled = true`; without it, the package reference's substitution is
92
- // shadowed by the template's literal `true`. The value stays a string:
93
- // core mirrors the section as written, and the module reads the switch
94
- // with its own schema, `"false"` as off.
103
+ // shadowed by the template's literal `true`. The value stays a string
104
+ // until boot: the module reads the switch with its own schema, `"false"`
105
+ // as off.
95
106
  const config = buildResolvedConfig("development", {
96
107
  OAUTH_AUTHORIZATION_GRANTS_AUTHORIZATION_CODE_ENABLED: "false",
97
108
  });
@@ -132,7 +143,8 @@ describe("three-tier HOCON resolution (env → application.conf → reference.co
132
143
  // schema coercion (coerceBooleanFromEnv) can produce a boolean value.
133
144
  // Without the HOCON block the field resolves to undefined.
134
145
  const config = buildResolvedConfig("development");
135
- expect(config.oauth.resourceIndicator?.enabled).toBe(false);
146
+ expect(oauthOf(config).resourceIndicator?.enabled).toBe(false);
147
+ expect(parsedResourceIndicator(config)?.enabled).toBe(false);
136
148
  });
137
149
 
138
150
  it("env var OAUTH_RESOURCE_INDICATOR_ENABLED=true reaches resolved config and coerces to boolean true", () => {
@@ -143,7 +155,8 @@ describe("three-tier HOCON resolution (env → application.conf → reference.co
143
155
  const config = buildResolvedConfig("development", {
144
156
  OAUTH_RESOURCE_INDICATOR_ENABLED: "true",
145
157
  });
146
- expect(config.oauth.resourceIndicator?.enabled).toBe(true);
158
+ expect(oauthOf(config).resourceIndicator?.enabled).toBe("true");
159
+ expect(parsedResourceIndicator(config)?.enabled).toBe(true);
147
160
  });
148
161
 
149
162
  // The selection is the composition root's own, `adapters.auditSink`,
@@ -190,7 +203,7 @@ describe("three-tier HOCON resolution (env → application.conf → reference.co
190
203
 
191
204
  it("resolves oauth.revocation.accessToken to denylist with nothing set", () => {
192
205
  const config = buildResolvedConfig("production");
193
- expect(config.oauth.revocation?.accessToken).toBe("denylist");
206
+ expect(oauthOf(config).revocation?.accessToken).toBe("denylist");
194
207
  });
195
208
 
196
209
  it("picks the replica-safe denylist module for that resolved config", () => {
@@ -221,8 +234,11 @@ describe("three-tier HOCON resolution (env → application.conf → reference.co
221
234
  });
222
235
  const modules = buildModules(config);
223
236
  expect(modules.map((m) => m.name)).toContain("redis-access-token-denylist");
237
+ // Each module's section at its name: a declaration made from the
238
+ // section is answered for it.
239
+ const sections = config as unknown as Record<string, unknown>;
224
240
  for (const module of modules) {
225
- expect(replicaUnsafeReason(module), module.name).toBeUndefined();
241
+ expect(replicaUnsafeReason(module, sections[module.name]), module.name).toBeUndefined();
226
242
  }
227
243
  });
228
244
 
@@ -239,7 +255,7 @@ describe("three-tier HOCON resolution (env → application.conf → reference.co
239
255
  const config = buildResolvedConfig("production", {
240
256
  OAUTH_REVOCATION_ACCESS_TOKEN: "unsupported",
241
257
  });
242
- expect(config.oauth.revocation?.accessToken).toBe("unsupported");
258
+ expect(oauthOf(config).revocation?.accessToken).toBe("unsupported");
243
259
  });
244
260
  });
245
261
  });
@@ -17,52 +17,58 @@
17
17
  /**
18
18
  * The template reads its configuration in two phases, as `app.mts` does:
19
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;
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`);
30
29
  * 2. `resolveForBoot` — its own files over the `reference.conf` of every
31
30
  * package its modules come from, core's last — handed to `createApp`
32
31
  * unparsed, which parses it once with every loaded module's schema.
33
32
  *
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.
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
38
  */
39
39
 
40
40
  import { mkdtempSync, writeFileSync } from "node:fs";
41
41
  import { tmpdir } from "node:os";
42
42
  import { join } from "node:path";
43
43
  import { fileURLToPath, pathToFileURL } from "node:url";
44
- import { AppConfigSchema, coreReference, createApp, type Module } from "@o3co/auth-provider-core";
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";
45
56
  import {
46
57
  redisFederationGrantIntentStoreModule,
47
58
  redisFederationGrantStoreModule,
48
59
  } from "@o3co/auth-provider-redis";
49
- import { parseFile } from "@o3co/ts.hocon";
50
- import { validate } from "@o3co/ts.hocon/zod";
51
60
  import { describe, expect, it } from "vitest";
52
61
  import { ADAPTERS_SECTION } from "../adapters.mjs";
53
62
  import { buildModules } from "../buildModules.mjs";
54
63
  import {
55
- expectedSessionRequirements,
56
64
  readOwnLayers,
57
65
  readSwitches,
58
66
  resolveConfigPaths,
59
67
  resolveForBoot,
60
68
  resolveLayers,
61
- SWITCHES,
62
69
  type Switches,
63
70
  } from "../configPath.mjs";
64
71
  import { MFA_SWITCH } from "../mfaSwitch.mjs";
65
- import { templateReference } from "../modules.mjs";
66
72
 
67
73
  const configDir = fileURLToPath(new URL("../../config", import.meta.url));
68
74
 
@@ -93,22 +99,24 @@ const ENVIRONMENTS: Readonly<Record<string, Readonly<Record<string, string>>>> =
93
99
  },
94
100
  };
95
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
+
96
110
  const ownFiles = (environment: string): string[] => {
97
111
  const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, environment);
98
112
  return [envConfPath, applicationConfPath];
99
113
  };
100
114
 
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
- );
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;
112
120
  }
113
121
 
114
122
  /** The value at a dotted path, or `undefined`. */
@@ -145,27 +153,125 @@ function recording(config: unknown): { readonly config: Switches; readonly reads
145
153
  return { config: wrap(config as object, "") as Switches, reads };
146
154
  }
147
155
 
148
- describe("phase one reads each switch as the template's AppConfigSchema pre-parse read it", () => {
156
+ describe("phase one reads federation-grants.enabled as the federation-grants module reads it", () => {
149
157
  for (const environment of ["development", "production"]) {
150
158
  for (const [name, env] of Object.entries(ENVIRONMENTS)) {
151
159
  it(`${environment}, ${name}`, () => {
152
- const before = preParsed(environment, env);
153
160
  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([]);
161
+ expect(switches["federation-grants"]?.enabled).toBe(ownerReads(environment, env) ?? false);
159
162
  });
160
163
  }
161
164
  }
162
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
+
163
270
  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
271
  for (const [name, env] of Object.entries(ENVIRONMENTS)) {
166
- expect(readSwitches(readOwnLayers(ownFiles("production"), { env })).mfaMode, name).toBe(
167
- env.MFA_MODE ?? "required",
168
- );
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");
169
275
  }
170
276
  });
171
277
  });
@@ -176,26 +282,30 @@ describe("phase one reads its switches and nothing else", () => {
176
282
  >;
177
283
 
178
284
  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
- );
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");
184
288
  expect(() =>
185
289
  readSwitches(readOwnLayers([partial, ...ownFiles("production")], { env })),
186
290
  ).not.toThrow();
187
291
  });
188
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
+
189
301
  it("reads, before boot, only paths among its switches", () => {
190
302
  const { config, reads } = recording(
191
303
  readSwitches(readOwnLayers(ownFiles("production"), { env })),
192
304
  );
193
- // What `app.mts` reads of phase one: the modules, and what the
194
- // composition expects of session admission.
195
- expectedSessionRequirements(config);
305
+ // What `app.mts` reads of phase one before boot: the modules.
196
306
  buildModules(config, { environment: "production" });
197
307
  const covered = (path: string) =>
198
- [...SWITCHES, ADAPTERS_SECTION, MFA_SWITCH, "storeTransport"].some(
308
+ PHASE_ONE_PATHS.some(
199
309
  (switchPath) =>
200
310
  path === switchPath ||
201
311
  path.startsWith(`${switchPath}.`) ||
@@ -205,6 +315,25 @@ describe("phase one reads its switches and nothing else", () => {
205
315
  expect([...reads].filter((path) => !covered(path))).toEqual([]);
206
316
  });
207
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
+
208
337
  it("still refuses a switch it reads that the schema refuses, naming it", () => {
209
338
  const bad = operatorLayer('adapters.rateLimiter = "carrier-pigeon"\n');
210
339
  expect(() => readSwitches(readOwnLayers([bad, ...ownFiles("production")], { env }))).toThrow(
@@ -228,7 +357,7 @@ describe("phase two: what createApp is handed", () => {
228
357
  const reference = join(dir, "reference.conf");
229
358
  writeFileSync(
230
359
  reference,
231
- 'widget { size = 3 }\noauth.revocation.accessToken = "widget-revocation"\noauth.oidcMode = "widget-mode"\n',
360
+ 'widget { size = 3 }\noauth.revocation.accessToken = "widget-revocation"\ncore.tokenBinding.dispatchPolicy = "widget-policy"\n',
232
361
  );
233
362
  return {
234
363
  name: "widget",
@@ -247,7 +376,9 @@ describe("phase two: what createApp is handed", () => {
247
376
  // The template's application.conf wins over a package's reference…
248
377
  expect(resolved.oauth?.revocation).toEqual({ accessToken: "denylist" });
249
378
  // …and a package's reference over core's.
250
- expect(resolved.oauth?.oidcMode).toBe("widget-mode");
379
+ expect(
380
+ (resolved.core?.tokenBinding as { dispatchPolicy?: unknown } | undefined)?.dispatchPolicy,
381
+ ).toBe("widget-policy");
251
382
  });
252
383
 
253
384
  it("layers no reference a loaded module does not declare", () => {
@@ -292,18 +423,29 @@ describe("phase two refuses the Redis grant store's key prefix moved while the i
292
423
  };
293
424
 
294
425
  /** What phase two refused with. */
295
- const refusal = (...args: Parameters<typeof resolve>): RangeError => {
426
+ const refusal = (...args: Parameters<typeof resolve>): BootError => {
296
427
  try {
297
428
  resolve(...args);
298
429
  } catch (err) {
299
- if (err instanceof RangeError) return err;
430
+ if (err instanceof BootError) return err;
300
431
  throw err;
301
432
  }
302
433
  throw new Error("phase two resolved");
303
434
  };
304
435
 
305
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", () => {
306
- const { message } = refusal({ [GRANT]: "t1:fg:" });
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:");
307
449
  expect(message).toContain(`redis-federation-grant-store.keyPrefix (${GRANT})`);
308
450
  expect(message).toContain(`redis-federation-grant-intent-store.keyPrefix (${INTENT})`);
309
451
  expect(message).not.toContain("fg:");
@@ -341,11 +483,23 @@ describe("phase two refuses the Redis grant store's key prefix moved while the i
341
483
 
342
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", () => {
343
485
  const operator = operatorLayer('redisFederationGrantStore.keyPrefix = "t1:fg:"\n');
344
- const { message } = refusal(
486
+ const { message, reason, details } = refusal(
345
487
  { [INTENT]: "t1:fgi:" },
346
488
  [redisFederationGrantIntentStoreModule],
347
489
  [operator, ...ownFiles("development")],
348
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
+ });
349
503
  expect(message).toContain("redisFederationGrantStore.keyPrefix");
350
504
  expect(message).toContain(`redis-federation-grant-intent-store.keyPrefix (${INTENT})`);
351
505
  expect(message).not.toContain("fg:");
@@ -363,25 +517,25 @@ describe("both phases read one snapshot of the composition's own layers", () =>
363
517
 
364
518
  it("sees a file's first contents in both phases, though it is replaced between them", () => {
365
519
  // 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');
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");
369
523
  const own = readOwnLayers([operator, ...ownFiles("production")], { env });
370
- writeFileSync(operator, 'session-store.storage.type = "memory"\n');
524
+ writeFileSync(operator, "federation-grants.enabled = false\n");
371
525
  const switches = readSwitches(own);
372
526
  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");
527
+ expect(valueAt(switches, "federation-grants.enabled")).toBe(true);
528
+ expect(valueAt(resolved, "federation-grants.enabled")).toBe(true);
375
529
  });
376
530
 
377
531
  it("substitutes one snapshot of the environment in both phases", () => {
378
- const changing: Record<string, string> = { ...env, SESSION_STORE_STORAGE_TYPE: "redis" };
532
+ const changing: Record<string, string> = { ...env, FEDERATION_GRANTS_ENABLED: "true" };
379
533
  const own = readOwnLayers(ownFiles("production"), { env: changing });
380
- changing.SESSION_STORE_STORAGE_TYPE = "memory";
534
+ changing.FEDERATION_GRANTS_ENABLED = "false";
381
535
  const switches = readSwitches(own);
382
536
  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");
537
+ expect(valueAt(switches, "federation-grants.enabled")).toBe(true);
538
+ expect(valueAt(resolved, "federation-grants.enabled")).toBe("true");
385
539
  });
386
540
 
387
541
  it("reads the adapters from the same snapshot of the environment", () => {
@@ -401,21 +555,35 @@ describe("both phases read one snapshot of the composition's own layers", () =>
401
555
  const own = readOwnLayers(ownFiles(environment), { env: variables });
402
556
  const switches = readSwitches(own);
403
557
  const modules = buildModules(switches, { environment });
558
+ const resolved = resolveForBoot(own, modules, switches);
404
559
  const handle = await createApp({
405
- modules: [],
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
+ ],
406
571
  bootstrapComponents: {
407
- config: resolveForBoot(own, modules, switches),
572
+ config: resolved,
408
573
  pathResolver: (s: string) => s,
409
574
  ...FEDERATION_STORES,
410
575
  } as never,
411
576
  });
412
577
  const parsed = handle.components.config;
413
578
  await handle.dispose();
414
- const differing = SWITCHES.filter(
415
- (path) =>
416
- JSON.stringify(valueAt(switches, path)) !== JSON.stringify(valueAt(parsed, path)),
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"),
417
586
  );
418
- expect(differing, `${environment}, ${name}`).toEqual([]);
419
587
  }
420
588
  }
421
589
  });
@@ -429,9 +597,7 @@ describe("both phases read one snapshot of the composition's own layers", () =>
429
597
  const FEDERATION_STORES = Object.fromEntries(
430
598
  [
431
599
  "userSessionStore",
432
- "sessionRPRegistry",
433
- "sessionFamilyIndex",
434
- "sessionFederationIndex",
600
+ "sessionLifecycle",
435
601
  "federationTokenStore",
436
602
  "refreshTokenFamilyRevocation",
437
603
  ].map((key) => [key, {}]),