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