@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
@@ -28,7 +28,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
28
28
  import { tmpdir } from "node:os";
29
29
  import { join } from "node:path";
30
30
  import { fileURLToPath } from "node:url";
31
- import { defineModule } from "@o3co/auth-provider-core";
31
+ import { BootError, defineModule } from "@o3co/auth-provider-core";
32
32
  import { afterAll, describe, expect, it } from "vitest";
33
33
  import { z } from "zod";
34
34
  import { buildModules } from "#/buildModules.mjs";
@@ -60,12 +60,13 @@ const MFA_OFF: Readonly<Record<string, string>> = { MFA_MODE: "off" };
60
60
  const adaptersFrom = (env: Record<string, string> = {}, hocon?: string) =>
61
61
  readSwitches(readOwnLayers(ownFiles(hocon), { env })).adapters;
62
62
 
63
- /** What phase one refuses under `env` and an operator's `hocon`. */
64
- function refusal(env: Record<string, string> = {}, hocon?: string): Error {
63
+ /** What phase one refuses under `env` and an operator's `hocon`: a `BootError`. */
64
+ function refusal(env: Record<string, string> = {}, hocon?: string): BootError {
65
65
  try {
66
66
  adaptersFrom(env, hocon);
67
67
  } catch (err) {
68
- return err as Error;
68
+ expect(err).toBeInstanceOf(BootError);
69
+ return err as BootError;
69
70
  }
70
71
  throw new Error("phase one read the adapters");
71
72
  }
@@ -141,6 +142,7 @@ describe("the shipped selections", () => {
141
142
  it("reads every selection from the template's reference.conf, at the values the template ships", () => {
142
143
  expect(adaptersFrom()).toEqual({
143
144
  rateLimiter: "memory",
145
+ attemptCounter: "memory",
144
146
  userSessionStores: "memory",
145
147
  accessTokenDenylist: "redis",
146
148
  replaySeenSet: "redis",
@@ -164,14 +166,25 @@ describe("the shipped selections", () => {
164
166
  },
165
167
  );
166
168
 
169
+ it("reads adapters.attemptCounter from ADAPTERS_ATTEMPT_COUNTER", () => {
170
+ expect(adaptersFrom({ ADAPTERS_ATTEMPT_COUNTER: "redis" }).attemptCounter).toBe("redis");
171
+ expect(refusal({ ADAPTERS_ATTEMPT_COUNTER: "memcached" }).message).toMatch(
172
+ /adapters\.attemptCounter/,
173
+ );
174
+ });
175
+
167
176
  it("reads a selection an operator writes in HOCON", () => {
168
177
  expect(adaptersFrom({}, 'adapters.rateLimiter = "redis"\n').rateLimiter).toBe("redis");
169
178
  });
170
179
 
171
180
  it("refuses a value its schema does not know, naming the key", () => {
172
- expect(refusal({ ADAPTERS_RATE_LIMITER: "memcached" }).message).toMatch(
173
- /adapters\.rateLimiter/,
174
- );
181
+ const err = refusal({ ADAPTERS_RATE_LIMITER: "memcached" });
182
+ expect(err.message).toMatch(/adapters\.rateLimiter/);
183
+ expect(err.reason).toBe("config-validation-failed");
184
+ expect(err.details).toMatchObject({
185
+ issues: [{ path: ["adapters", "rateLimiter"] }],
186
+ modules: [{ module: "adapters", schemaPath: "adapters" }],
187
+ });
175
188
  });
176
189
 
177
190
  it("refuses the Store for the MFA transaction store, which holds verification state, and accepts it for the factor store", () => {
@@ -198,9 +211,20 @@ describe("a path a selection moved from, written in the operator's own layer", (
198
211
  "adapters.%s: refused where it was, %s's old path, naming the new path and ADAPTERS_%s",
199
212
  (key, name, oldPath, _oldName, value) => {
200
213
  const err = refusal({}, `${oldPath} = "${value}"\n`);
201
- expect(err).toBeInstanceOf(RangeError);
202
214
  expect(err.message).toContain(`${oldPath} has moved to adapters.${key}`);
203
215
  expect(err.message).toContain(`ADAPTERS_${name}`);
216
+ expect(err.reason).toBe("config-path-relocated");
217
+ expect(err.details).toEqual({
218
+ reason: "config-path-relocated",
219
+ relocated: [
220
+ {
221
+ module: "adapters",
222
+ from: oldPath,
223
+ to: `adapters.${key}`,
224
+ environmentVariable: `ADAPTERS_${name}`,
225
+ },
226
+ ],
227
+ });
204
228
  },
205
229
  );
206
230
 
@@ -212,6 +236,13 @@ describe("a path a selection moved from, written in the operator's own layer", (
212
236
  for (const path of ["rateLimiter.adapter", "consentStore.adapter", "audit.sink.type"]) {
213
237
  expect(err.message).toContain(path);
214
238
  }
239
+ expect(err.details).toMatchObject({
240
+ relocated: [
241
+ { from: "rateLimiter.adapter" },
242
+ { from: "consentStore.adapter" },
243
+ { from: "audit.sink.type" },
244
+ ],
245
+ });
215
246
  });
216
247
  });
217
248
 
@@ -220,9 +251,21 @@ describe("a variable renamed with a selection", () => {
220
251
  "adapters.%s: %s's old variable set alone is refused, naming ADAPTERS_%s",
221
252
  (key, name, _oldPath, oldName, value) => {
222
253
  const err = refusal({ [oldName]: value });
223
- expect(err).toBeInstanceOf(RangeError);
224
254
  expect(err.message).toContain(`${oldName} was renamed ADAPTERS_${name}`);
225
255
  expect(err.message).toContain(`adapters.${key}`);
256
+ expect(err.reason).toBe("environment-variable-renamed");
257
+ expect(err.details).toEqual({
258
+ reason: "environment-variable-renamed",
259
+ renamed: [
260
+ {
261
+ module: "adapters",
262
+ from: oldName,
263
+ to: `ADAPTERS_${name}`,
264
+ path: `adapters.${key}`,
265
+ state: "unset",
266
+ },
267
+ ],
268
+ });
226
269
  },
227
270
  );
228
271
 
@@ -233,6 +276,8 @@ describe("a variable renamed with a selection", () => {
233
276
  expect(err.message).toContain(oldName);
234
277
  expect(err.message).not.toContain("old-value-5e2d");
235
278
  expect(err.message).not.toContain("new-value-c81a");
279
+ expect(err.details).toMatchObject({ renamed: [{ from: oldName, state: "different" }] });
280
+ expect(JSON.stringify(err.details)).not.toMatch(/old-value-5e2d|new-value-c81a/);
236
281
  },
237
282
  );
238
283
 
@@ -277,6 +322,13 @@ describe("the modules phase one chooses by the selections", () => {
277
322
  expect(modules).toContain(installed);
278
323
  expect(modules).not.toContain(absent);
279
324
  });
325
+
326
+ it("ADAPTERS_ATTEMPT_COUNTER=redis installs redis-attempt-counter over the shared Redis socket; memory installs no counter", () => {
327
+ const onRedis = names({ ADAPTERS_ATTEMPT_COUNTER: "redis" });
328
+ expect(onRedis).toContain("redis-attempt-counter");
329
+ expect(onRedis).toContain("redis-clients");
330
+ expect(names({ ADAPTERS_ATTEMPT_COUNTER: "memory" })).not.toContain("redis-attempt-counter");
331
+ });
280
332
  });
281
333
 
282
334
  describe("the federation-grant stores", () => {
@@ -335,24 +387,33 @@ describe("boot and the section", () => {
335
387
  });
336
388
 
337
389
  it.each([
338
- ["named adapters", "adapters", undefined],
339
- ["with its section at adapters.custom", "custom-thing", "adapters.custom"],
340
- ] as const)("refuses a module %s: its section would never reach boot", (_label, name, at) => {
390
+ ["named adapters", "adapters"],
391
+ ["named mfaMode", "mfaMode"],
392
+ ] as const)("refuses a module %s: its section would never reach boot", (_label, name) => {
341
393
  const own = readOwnLayers(ownFiles(), { env: MFA_OFF });
342
394
  const switches = readSwitches(own);
343
395
  const mine = defineModule({
344
396
  name,
345
- section: {
346
- schema: z.object({}).passthrough().optional(),
347
- ...(at === undefined ? {} : { at }),
348
- },
397
+ section: { schema: z.object({}).passthrough().optional() },
349
398
  });
350
- expect(() =>
399
+ const run = () =>
351
400
  resolveForBoot(
352
401
  own,
353
402
  [...buildModules(switches, { environment: "production" }), mine],
354
403
  switches,
355
- ),
356
- ).toThrow(new RegExp(`"${name}".*adapters`));
404
+ );
405
+ expect(run).toThrow(new RegExp(`"${name}".*composition root's own section`));
406
+ const err = (() => {
407
+ try {
408
+ run();
409
+ } catch (thrown) {
410
+ return thrown;
411
+ }
412
+ })();
413
+ expect(err).toBeInstanceOf(BootError);
414
+ expect(err).toMatchObject({
415
+ reason: "module-section-path-invalid",
416
+ details: { module: name, at: name },
417
+ });
357
418
  });
358
419
  });
@@ -0,0 +1,87 @@
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 oauth package's consumers of session admission read the session
19
+ * lifecycle's record the template's session stores provide: a session whose
20
+ * record is closing is refused at `/authorize`, the code exchange and the
21
+ * refresh grant, while its user session is still there.
22
+ */
23
+
24
+ import type { SessionLifecycleStore, UserSessionStore } from "@o3co/auth-provider-core";
25
+ import request from "supertest";
26
+ import { describe, expect, it } from "vitest";
27
+ import {
28
+ authorize,
29
+ basic,
30
+ codeFrom,
31
+ compose,
32
+ login,
33
+ redeem,
34
+ WEB,
35
+ } from "./all-modules-composition.fixture.mjs";
36
+
37
+ /** A JWT's claims, unverified. */
38
+ const claimsOf = (token: string): Record<string, unknown> =>
39
+ JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
40
+
41
+ describe("a session whose lifecycle record is closing", () => {
42
+ it("is refused at /authorize, the code exchange and the refresh grant", async () => {
43
+ const { app, handle } = await compose();
44
+ try {
45
+ const components = handle.components as Record<string, unknown>;
46
+ const store = components.sessionLifecycleStore as SessionLifecycleStore;
47
+ const sessions = components.userSessionStore as UserSessionStore;
48
+ const { cookies } = await login(app);
49
+ const tokens = (await redeem(app, codeFrom(await authorize(app, cookies)))).body as Record<
50
+ string,
51
+ string
52
+ >;
53
+ const sid = String(claimsOf(tokens.id_token ?? "").sid);
54
+ const pendingCode = codeFrom(await authorize(app, cookies));
55
+
56
+ // The session's close commits, its work pending: the user session stays.
57
+ const session = await sessions.get(sid);
58
+ if (session === null) throw new Error("the login wrote no session");
59
+ expect((await store.open(sid, session.sub, session.expiresAt)).outcome).toBe("opened");
60
+ const closing = await store.beginClose(sid, {
61
+ cause: "rp_logout",
62
+ steps: ["held_open"],
63
+ perParticipant: [],
64
+ retainMs: 0,
65
+ });
66
+ expect(closing.outcome).toBe("closing");
67
+ expect(await sessions.get(sid)).not.toBeNull();
68
+
69
+ const again = await authorize(app, cookies);
70
+ // Sent to log in again, with no code.
71
+ expect(again.status).toBe(302);
72
+ expect(String(again.headers.location)).not.toMatch(/[?&]code=/);
73
+
74
+ expect((await redeem(app, pendingCode)).body).toMatchObject({ error: "invalid_grant" });
75
+
76
+ const refreshed = await request(app)
77
+ .post("/oauth/token")
78
+ .set("Authorization", basic(WEB))
79
+ .type("form")
80
+ .send({ grant_type: "refresh_token", refresh_token: tokens.refresh_token });
81
+ expect(refreshed.status).toBe(400);
82
+ expect(refreshed.body).toMatchObject({ error: "invalid_grant" });
83
+ } finally {
84
+ await handle.dispose();
85
+ }
86
+ });
87
+ });
@@ -31,9 +31,9 @@
31
31
  * - the refresh-token family store under `core.deployment.mode = "single"`: memory,
32
32
  * not the shipped Redis, so the single-replica boot opens no sockets;
33
33
  * - upstream identity providers: core's fake OpenID Provider, through the
34
- * `fetch` option the Google and OIDC adapters take; the config bridges'
35
- * values are read as shipped (`googleFederationConfigModule`, the OIDC
36
- * package's reader), with only `fetch` added;
34
+ * `fetch` option the Google and OIDC federation type modules take: each is
35
+ * the module `buildModules` lists, built with that option, and the entries
36
+ * it handles are read as shipped;
37
37
  * - configuration with no environment form (grant connections, a key ring, a
38
38
  * landing URL): one more HOCON layer above the shipped ones.
39
39
  *
@@ -62,12 +62,15 @@ import {
62
62
  terminalErrorHandler,
63
63
  } from "@o3co/auth-provider-core";
64
64
  import { createFakeIdp, type FakeIdp } from "@o3co/auth-provider-core/testing";
65
+ import { googleFederationTypeModule } from "@o3co/auth-provider-federation-google";
66
+ import { oidcFederationTypeModule } from "@o3co/auth-provider-federation-oidc";
65
67
  import express from "express";
66
68
  import helmet from "helmet";
67
69
  import request from "supertest";
68
70
  import { beforeAll, describe, expect, it } from "vitest";
69
71
  import { buildModules } from "#/buildModules.mjs";
70
72
  import {
73
+ configDefaultsFor,
71
74
  type OwnLayers,
72
75
  readOwnLayers,
73
76
  readSwitches,
@@ -75,7 +78,6 @@ import {
75
78
  resolveForBoot,
76
79
  type Switches,
77
80
  } from "#/configPath.mjs";
78
- import { googleFederationConfigModule, oidcFederationConfigModule } from "#/modules.mjs";
79
81
 
80
82
  export const ISSUER = "https://auth.test";
81
83
  const OIDC_ISSUER = "https://idp.test";
@@ -109,6 +111,7 @@ export const SINGLE_ENV: Readonly<Record<string, string>> = {
109
111
  SESSION_STORE_STORAGE_TYPE: "memory",
110
112
  ADAPTERS_USER_SESSION_STORES: "memory",
111
113
  ADAPTERS_RATE_LIMITER: "memory",
114
+ ADAPTERS_ATTEMPT_COUNTER: "memory",
112
115
  ADAPTERS_CODE_REPOSITORY: "memory",
113
116
  ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
114
117
  ADAPTERS_REPLAY_SEEN_SET: "memory",
@@ -150,6 +153,8 @@ export const MULTI_ENV: Readonly<Record<string, string>> = {
150
153
  REDIS_CLIENTS_URL: "redis://redis.test:6379",
151
154
  ADAPTERS_USER_SESSION_STORES: "redis",
152
155
  ADAPTERS_RATE_LIMITER: "redis",
156
+ // The login's attempt counter, shared: per process, `multi` refuses the login.
157
+ ADAPTERS_ATTEMPT_COUNTER: "redis",
153
158
  ADAPTERS_CODE_REPOSITORY: "redis",
154
159
  ADAPTERS_ACCESS_TOKEN_DENYLIST: "redis",
155
160
  ADAPTERS_REPLAY_SEEN_SET: "redis",
@@ -165,19 +170,19 @@ export const CONNECTION = "calendar";
165
170
 
166
171
  const configDir = fileURLToPath(new URL("../../config", import.meta.url));
167
172
 
173
+ const quoted = (value: string) => JSON.stringify(value);
174
+
168
175
  /**
169
- * What has no environment form — the federations' landing page, the grant
170
- * key ring and one grant connection — as an operator writes it: a HOCON
171
- * file of their own, the highest layer. Written once per process.
176
+ * What has no environment form — the federations' landing page and one grant
177
+ * connection — as an operator writes it: a HOCON file of their own, the
178
+ * highest layer. Written once per process.
172
179
  */
173
180
  const OPERATOR_LAYER: string = (() => {
174
181
  const file = join(mkdtempSync(join(tmpdir(), "all-modules-composition-")), "operator.conf");
175
- const quoted = (value: string) => JSON.stringify(value);
176
182
  writeFileSync(
177
183
  file,
178
184
  `core.federations.google.clientUrl = ${quoted(FEDERATION_LANDING)}
179
185
  core.federations.oidc.clientUrl = ${quoted(FEDERATION_LANDING)}
180
- redis-federation-grant-store.encryptionKeys = [{ id = "k-test", key = ${quoted(ENCRYPTION_KEY)} }]
181
186
  federation-grants {
182
187
  connections {
183
188
  ${CONNECTION} {
@@ -201,26 +206,44 @@ function hoconFile(text: string): string {
201
206
  return file;
202
207
  }
203
208
 
204
- /** The composition's own files, highest first: the operator's layer, then the shipped production ones. */
205
- export function ownFiles(): string[] {
209
+ /**
210
+ * The Redis grant store's key ring, which has no environment form either, in
211
+ * a layer of its own: an operator writes it where that store is installed,
212
+ * since written for a store the composition does not load it reaches nothing
213
+ * and boot may name its section. Written once per process.
214
+ */
215
+ const GRANT_KEY_RING_LAYER: string = (() => {
216
+ const file = join(mkdtempSync(join(tmpdir(), "all-modules-composition-")), "grant-keys.conf");
217
+ writeFileSync(
218
+ file,
219
+ `redis-federation-grant-store.encryptionKeys = [{ id = "k-test", key = ${quoted(ENCRYPTION_KEY)} }]\n`,
220
+ );
221
+ return file;
222
+ })();
223
+
224
+ /**
225
+ * The composition's own files under `env`, highest first: the operator's
226
+ * layers — the grant key ring among them where `env` turns federation grants
227
+ * on and puts their store on Redis — then the shipped production ones.
228
+ */
229
+ export function ownFiles(env: Readonly<Record<string, string>> = SINGLE_ENV): string[] {
206
230
  const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
207
- return [OPERATOR_LAYER, envConfPath, applicationConfPath];
231
+ const grantStoreOnRedis =
232
+ env.FEDERATION_GRANTS_ENABLED === "true" && env.ADAPTERS_FEDERATION_GRANT_STORE === "redis";
233
+ const keyRing = grantStoreOnRedis ? [GRANT_KEY_RING_LAYER] : [];
234
+ return [OPERATOR_LAYER, ...keyRing, envConfPath, applicationConfPath];
208
235
  }
209
236
 
210
237
  /**
211
- * Phase one, as `app.mts` reads it: the switches `buildModules` chooses
212
- * the modules by — and `reads`, what a module added to the composition reads
213
- * when it is built — from the composition's own files under `env` over core's
214
- * `reference.conf`. What the composition expects of session admission is
215
- * derived from it (`resolveForBoot`), after `config` adjusts it as an
216
- * operator's layer would.
238
+ * Phase one, as `app.mts` reads it: the switches `buildModules` chooses the
239
+ * modules by, from the composition's own files under `env` over the
240
+ * template's `reference.conf`.
217
241
  */
218
242
  export function resolveConfig(
219
243
  env: Readonly<Record<string, string>>,
220
- reads: readonly string[] = [],
221
- own: OwnLayers = readOwnLayers(ownFiles(), { env }),
244
+ own: OwnLayers = readOwnLayers(ownFiles(env), { env }),
222
245
  ): Switches {
223
- return readSwitches(own, { reads });
246
+ return readSwitches(own);
224
247
  }
225
248
 
226
249
  // ---------------------------------------------------------------------------
@@ -345,15 +368,41 @@ let upstreams: Promise<{ fakes: Upstreams; reset: () => void }> | undefined;
345
368
  * run if one did.
346
369
  */
347
370
  export async function sharedUpstreams(): Promise<Upstreams> {
371
+ const { fakes, reset } = await madeUpstreams();
372
+ reset();
373
+ return fakes;
374
+ }
375
+
376
+ /** The two fake upstreams, made on first use and never reset here. */
377
+ function madeUpstreams(): Promise<{ fakes: Upstreams; reset: () => void }> {
348
378
  upstreams ??= createUpstreams().then((fakes) => ({
349
379
  fakes,
350
380
  reset: resettable(fakes.oidc, fakes.google),
351
381
  }));
352
- const { fakes, reset } = await upstreams;
353
- reset();
354
- return fakes;
382
+ return upstreams;
355
383
  }
356
384
 
385
+ /** A `fetch` that reaches the shared fake upstream `name`. */
386
+ const upstreamFetch =
387
+ (name: keyof Upstreams): typeof fetch =>
388
+ async (input, init) =>
389
+ (await madeUpstreams()).fakes[name].fetch(input, init);
390
+
391
+ /**
392
+ * The federation type modules `buildModules` lists, by name, each built as
393
+ * the template builds it but with a `fetch` that reaches its fake upstream.
394
+ */
395
+ const UPSTREAM_TYPE_MODULES: ReadonlyMap<string, () => Module> = new Map([
396
+ [
397
+ googleFederationTypeModule().name,
398
+ () => googleFederationTypeModule({ fetch: upstreamFetch("google") }),
399
+ ],
400
+ [
401
+ oidcFederationTypeModule().name,
402
+ () => oidcFederationTypeModule({ fetch: upstreamFetch("oidc") }),
403
+ ],
404
+ ]);
405
+
357
406
  /**
358
407
  * Snapshots each fake's settings (every property that is not a function) and
359
408
  * returns what puts them back: primitives reassigned, objects restored in
@@ -432,52 +481,6 @@ async function createUpstreams(): Promise<Upstreams> {
432
481
  };
433
482
  }
434
483
 
435
- /**
436
- * What one of the template's config bridges provides for `config`. Read
437
- * through a record, not the typed slot: a program that loads this file without
438
- * a federation package's ComponentMap augmentation (`tools/composition` does)
439
- * has no key to name.
440
- */
441
- const bridged = <T,>(module: Module, slot: string, config: AppConfig): T => {
442
- const provider = (module.provides as Record<string, unknown> | undefined)?.[slot];
443
- if (typeof provider !== "function") throw new Error(`${module.name} provides no ${slot}`);
444
- return (provider as (deps: { config: AppConfig }) => T)({ config });
445
- };
446
-
447
- /**
448
- * The two federation config slots, read by the template's own bridges
449
- * (`oidcFederationConfigModule`, `googleFederationConfigModule`), with each
450
- * adapter's `fetch` pointed at its fake upstream. Only for the federations the
451
- * config enables — which is when `buildModules` lists the bridges.
452
- */
453
- async function federationOverrides(
454
- config: Switches,
455
- upstreams: Upstreams,
456
- ): Promise<Record<string, unknown>> {
457
- const overrides: Record<string, unknown> = {};
458
- const modules = buildModules(config).map((m) => m.name);
459
- if (modules.includes(oidcFederationConfigModule.name)) {
460
- const oidc = bridged<Record<string, object>>(
461
- oidcFederationConfigModule,
462
- "oidcFederationConfigs",
463
- config,
464
- );
465
- overrides.oidcFederationConfigs = Object.fromEntries(
466
- Object.entries(oidc).map(([name, entry]) => [
467
- name,
468
- { ...entry, fetch: upstreams.oidc.fetch },
469
- ]),
470
- );
471
- }
472
- if (modules.includes(googleFederationConfigModule.name)) {
473
- overrides.googleFederationConfig = {
474
- ...bridged<object>(googleFederationConfigModule, "googleFederationConfig", config),
475
- fetch: upstreams.google.fetch,
476
- };
477
- }
478
- return overrides;
479
- }
480
-
481
484
  // ---------------------------------------------------------------------------
482
485
  // Logger spy
483
486
  // ---------------------------------------------------------------------------
@@ -588,30 +591,24 @@ export interface ComposeOptions {
588
591
  readonly environment?: string;
589
592
  /** HOCON an operator writes above the composition's own files, read in both phases. */
590
593
  readonly operatorHocon?: string;
591
- /**
592
- * Paths read before boot beside the template's switches: what a module
593
- * `extraModules` adds reads when it is built (`readSwitches`'s `reads`).
594
- */
595
- readonly reads?: readonly string[];
596
594
  /** The deployment's own mail sender modules, handed to `buildModules` as `mailSenderModules`. */
597
595
  readonly mailSenderModules?: readonly Module[];
598
596
  /** Modules added after the template's own, before the order and the outage apply. */
599
597
  readonly extraModules?: (config: Switches) => readonly Module[];
600
- /** Components laid over the boot's, beside the federation config slots. */
598
+ /** Components laid over the boot's (`overrideComponents`). */
601
599
  readonly extraOverrides?: (config: Switches) => Record<string, unknown>;
602
600
  /** Client registrations beside the fixture's own, as `ClientEntrySchema` input. */
603
601
  readonly extraClients?: Readonly<Record<string, Record<string, unknown>>>;
604
602
  /** Users beside the fixture's own, keyed by username. */
605
603
  readonly extraUsers?: Readonly<Record<string, Record<string, unknown>>>;
606
604
  /**
607
- * Adjust the configuration before anything reads it, as an operator's own
608
- * layer would: applied to phase one's switches, and to what `createApp` is
609
- * handed, as resolved.
605
+ * Adjust what `createApp` is handed, as resolved, as an operator's own
606
+ * layer would.
610
607
  */
611
- readonly config?: (config: Switches) => Switches;
608
+ readonly config?: (config: AppConfig) => AppConfig;
612
609
  /**
613
- * Adjust phase one's switches alone, after `config`: what only phase one
614
- * reads, such as the Store transport settings a hand-built root passes.
610
+ * Adjust phase one's switches: what only phase one reads, such as the
611
+ * Store transport settings a hand-built root passes.
615
612
  */
616
613
  readonly switches?: (switches: Switches) => Switches;
617
614
  readonly order?: ModuleOrder;
@@ -639,7 +636,7 @@ export function composedModules(config: Switches, options: ComposeOptions = {}):
639
636
  ...(options.shippedRefreshTokenFamilyStore
640
637
  ? {}
641
638
  : { refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule] }),
642
- }),
639
+ }).map((module) => UPSTREAM_TYPE_MODULES.get(module.name)?.() ?? module),
643
640
  ...(options.extraModules?.(config) ?? []),
644
641
  ];
645
642
  if (options.outage) modules = withOutage(modules, options.outage.slot, options.outage.outage);
@@ -667,35 +664,32 @@ export interface Composition {
667
664
  */
668
665
  export async function compose(options: ComposeOptions = {}): Promise<Composition> {
669
666
  const env = options.env ?? SINGLE_ENV;
670
- const adjust = <C extends AppConfig>(config: C): C =>
671
- options.config ? (options.config(config as unknown as Switches) as unknown as C) : config;
672
667
  // The composition's own layers, read once for both phases, as `app.mts`
673
668
  // reads them. Phase one: the switches the modules are chosen by.
674
669
  const own = readOwnLayers(
675
670
  options.operatorHocon === undefined
676
- ? ownFiles()
677
- : [hoconFile(options.operatorHocon), ...ownFiles()],
671
+ ? ownFiles(env)
672
+ : [hoconFile(options.operatorHocon), ...ownFiles(env)],
678
673
  { env },
679
674
  );
680
- const switches = resolveConfig(env, options.reads, own);
681
- const config = options.switches ? options.switches(adjust(switches)) : adjust(switches);
675
+ const switches = resolveConfig(env, own);
676
+ const config = options.switches ? options.switches(switches) : switches;
682
677
  const fakes = await sharedUpstreams();
683
678
  const modules = composedModules(config, options);
684
679
  const logger = createRecordingLogger();
685
680
  // Phase two: the configuration as resolved over every loaded package's
686
- // reference.conf, which createApp parses once.
687
- const resolved = adjust(resolveForBoot(own, modules, config));
681
+ // reference.conf, which createApp parses once, and its defaults.
682
+ const forBoot = resolveForBoot(own, modules, config);
683
+ const resolved = options.config ? options.config(forBoot) : forBoot;
688
684
  const handle = await createApp({
689
685
  modules,
690
686
  bootstrapComponents: {
691
687
  config: resolved,
688
+ configDefaults: configDefaultsFor(modules),
692
689
  pathResolver: (s) => s,
693
690
  logger,
694
691
  },
695
- overrideComponents: {
696
- ...(await federationOverrides(config, fakes)),
697
- ...options.extraOverrides?.(config),
698
- } as never,
692
+ overrideComponents: { ...options.extraOverrides?.(config) } as never,
699
693
  });
700
694
  const parsed = handle.components.config;
701
695
  if (parsed === undefined) throw new Error("createApp booted without the parsed configuration");
@@ -1056,6 +1050,11 @@ export interface OutageCase<C extends Composition = Composition> {
1056
1050
  readonly storeFieldNotRequired?: string;
1057
1051
  /** The predicates the composition breaks today, each naming its defect. */
1058
1052
  readonly defects?: Partial<Record<OutagePredicate, string>>;
1053
+ /**
1054
+ * The warn lines a `no-warn` defect writes, pinned exactly while it stands,
1055
+ * so the defect covers those lines and no other.
1056
+ */
1057
+ readonly defectWarns?: readonly string[];
1059
1058
  }
1060
1059
 
1061
1060
  /** The same defect text for every predicate that depends on the one missing line. */
@@ -1086,6 +1085,14 @@ export function describeOutages<C extends Composition>(
1086
1085
  ): void {
1087
1086
  describe(title, () => {
1088
1087
  for (const c of cases) {
1088
+ if (
1089
+ c.defectWarns !== undefined &&
1090
+ (c.defects?.["no-warn"] === undefined || c.defectWarns.length === 0)
1091
+ ) {
1092
+ throw new Error(
1093
+ `${c.module}: ${c.slot} down at ${c.surface}: defectWarns pins the lines of a no-warn defect, so it needs defects["no-warn"] and at least one line`,
1094
+ );
1095
+ }
1089
1096
  describe(`${c.module}: ${c.slot} down at ${c.surface}`, () => {
1090
1097
  let res: request.Response;
1091
1098
  let lines: LogLine[] = [];
@@ -1165,13 +1172,20 @@ export function describeOutages<C extends Composition>(
1165
1172
  expect(err).toMatchObject({ name: expect.any(String) });
1166
1173
  expect(err).not.toBeInstanceOf(Error);
1167
1174
  });
1168
- check("no-warn", "writes no warn line for it", () => {
1169
- const warns = lines
1175
+ const outageWarns = () =>
1176
+ lines
1170
1177
  .filter((line) => line.level === "warn")
1171
1178
  .map((line) => (typeof line.args[1] === "string" ? line.args[1] : String(line.args[0])))
1172
1179
  .filter((event) => !(c.unrelatedWarns ?? []).includes(event));
1173
- expect(warns).toEqual([]);
1180
+ check("no-warn", "writes no warn line for it", () => {
1181
+ expect(outageWarns()).toEqual([]);
1174
1182
  });
1183
+ if (c.defectWarns !== undefined) {
1184
+ const pinned = c.defectWarns;
1185
+ it(`writes exactly the warn lines its no-warn defect names: ${pinned.join(", ")}`, () => {
1186
+ expect(outageWarns()).toEqual(pinned);
1187
+ });
1188
+ }
1175
1189
  });
1176
1190
  }
1177
1191
  });