@o3co/create-auth-provider 0.15.0 → 0.17.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.ja.md +139 -48
  2. package/README.md +138 -47
  3. package/dist/index.d.mts +33 -10
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +118 -59
  6. package/dist/internal/template-filter.d.mts +7 -10
  7. package/dist/internal/template-filter.d.mts.map +1 -1
  8. package/dist/internal/template-filter.mjs +7 -10
  9. package/package.json +6 -4
  10. package/templates/standalone/.dockerignore +15 -2
  11. package/templates/standalone/.env.example +69 -24
  12. package/templates/standalone/Dockerfile +7 -3
  13. package/templates/standalone/README.ja.md +677 -184
  14. package/templates/standalone/README.md +699 -238
  15. package/templates/standalone/config/application.conf +203 -325
  16. package/templates/standalone/config/clients.yaml +2 -0
  17. package/templates/standalone/config/clients.yaml.example +4 -0
  18. package/templates/standalone/config/development.conf +13 -4
  19. package/templates/standalone/config/mailpit.conf +8 -0
  20. package/templates/standalone/config/production.conf +5 -6
  21. package/templates/standalone/config/reference.conf +399 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +58 -30
  24. package/templates/standalone/docker-compose.yml +13 -3
  25. package/templates/standalone/gitignore +4 -2
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +65 -32
  28. package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1178 -0
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +256 -0
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1119 -0
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
  33. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  34. package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
  35. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  36. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
  37. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +672 -271
  38. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +480 -78
  39. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +102 -66
  40. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +135 -0
  41. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +49 -18
  42. package/templates/standalone/src/__tests__/google-session.fixture.mts +315 -0
  43. package/templates/standalone/src/__tests__/host-routes.test.mts +134 -0
  44. package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
  45. package/templates/standalone/src/__tests__/listen.test.mts +121 -0
  46. package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
  47. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  48. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
  49. package/templates/standalone/src/__tests__/metrics.test.mts +17 -7
  50. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  51. package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
  52. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
  53. package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
  54. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +183 -0
  55. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +135 -0
  56. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
  57. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  58. package/templates/standalone/src/__tests__/replica-safety.test.mts +166 -145
  59. package/templates/standalone/src/__tests__/router-error-answers.test.mts +146 -0
  60. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
  61. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  62. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
  63. package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
  64. package/templates/standalone/src/__tests__/shutdown.test.mts +319 -115
  65. package/templates/standalone/src/__tests__/smoke.test.mts +179 -233
  66. package/templates/standalone/src/__tests__/store-credential.test.mts +317 -0
  67. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  68. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
  69. package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
  70. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
  71. package/templates/standalone/src/adapters.mts +149 -0
  72. package/templates/standalone/src/app.mts +71 -115
  73. package/templates/standalone/src/buildModules.mts +229 -251
  74. package/templates/standalone/src/configPath.mts +422 -29
  75. package/templates/standalone/src/listen.mts +60 -0
  76. package/templates/standalone/src/logger.mts +31 -57
  77. package/templates/standalone/src/metrics.mts +24 -47
  78. package/templates/standalone/src/mfaSwitch.mts +262 -0
  79. package/templates/standalone/src/modules.mts +480 -464
  80. package/templates/standalone/src/rootRenames.mts +79 -0
  81. package/templates/standalone/src/routes.mts +89 -0
  82. package/templates/standalone/src/sections.mts +366 -0
  83. package/templates/standalone/src/shutdown.mts +54 -126
  84. package/templates/standalone/tests/index.test.js +2 -2
  85. package/templates/standalone/tests/package-lock.json +1615 -2223
  86. package/templates/standalone/tests/package.json +2 -2
  87. package/templates/standalone/tests/vitest.config.mjs +26 -0
  88. package/templates/standalone/vitest.config.mts +8 -7
  89. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  90. package/templates/versions.json +18 -15
  91. package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
  92. package/templates/standalone/src/terminalError.mts +0 -60
@@ -14,43 +14,52 @@
14
14
  * limitations under the License.
15
15
  */
16
16
 
17
- import { createRequire } from "node:module";
17
+ /**
18
+ * Where the template's configuration comes from: the composition's own layers
19
+ * (`{env}.conf` over `application.conf`), read once by `readOwnLayers` under
20
+ * one snapshot of the environment, and what is built from that read: the two
21
+ * phases, `readSwitches` and `resolveForBoot`, and the `logging` module's
22
+ * section the logger is built from, `readLogging` (template README,
23
+ * "Environment-specific config overlay"). So a file or variable changed
24
+ * during startup cannot split a switch phase one reads from the value boot's
25
+ * parse has, which matters because adapter selections have no disagreement
26
+ * guard at boot; `two-phase-config.test.mts` pins this for the shipped
27
+ * environments. A loaded module's own schema may still make something else of
28
+ * a switch at boot.
29
+ */
30
+
18
31
  import path from "node:path";
19
32
  import { fileURLToPath } from "node:url";
33
+ import { isDeepStrictEqual } from "node:util";
34
+ import {
35
+ type AppConfig,
36
+ coreReference,
37
+ type Module,
38
+ moduleReferences,
39
+ readTransitionalConfig,
40
+ } from "@o3co/auth-provider-core";
41
+ import {
42
+ redisFederationGrantIntentStoreModule,
43
+ redisFederationGrantStoreModule,
44
+ } from "@o3co/auth-provider-redis";
45
+ import { type Config, empty, parseFile } from "@o3co/ts.hocon";
46
+ import { ADAPTERS_SECTION, readAdapters } from "./adapters.mjs";
47
+ import { MFA_SWITCH, mfaSectionForBoot, oauthForBoot, readMfaSwitch } from "./mfaSwitch.mjs";
48
+ import { loggingModule, templateReference } from "./modules.mjs";
49
+ import { refuseRenamedVariables, SHIPPED_FEDERATION_RENAMES } from "./rootRenames.mjs";
50
+ import {
51
+ type Adapters,
52
+ isPlainSection,
53
+ type LoggingSettings,
54
+ loggingSectionSchema,
55
+ type MfaSwitch,
56
+ } from "./sections.mjs";
20
57
 
21
58
  export interface ResolvedConfigPaths {
22
59
  readonly applicationConfPath: string;
23
60
  readonly envConfPath: string;
24
61
  }
25
62
 
26
- const REFERENCE_CONF_SUBPATH = "@o3co/auth-provider-core/reference.conf";
27
-
28
- /**
29
- * Returns the absolute path to the shipped `reference.conf` inside
30
- * `@o3co/auth-provider-core`. This file is the bottom layer of the
31
- * 3-tier HOCON precedence chain (reference.conf → application.conf → {env}.conf).
32
- *
33
- * Primary path: `import.meta.resolve` (Node.js Stability 1.2 RC, unflagged
34
- * since Node 18.19.0 / 20.6.0 — well below this scope's Node 22 engine floor,
35
- * so it is always available). Fallback: `createRequire(import.meta.url).resolve(...)`. The
36
- * fallback covers two edge cases:
37
- *
38
- * 1. A future Node release deprecates or alters the sync form of
39
- * `import.meta.resolve` (still labelled Stability 1.2 RC per Node docs).
40
- * 2. An exotic runtime / loader where `import.meta.resolve` is not
41
- * available but CommonJS-style resolution still is.
42
- *
43
- * Both APIs read the same `exports` map in `@o3co/auth-provider-core`'s
44
- * `package.json`, so the resolved path is identical.
45
- */
46
- export function resolveLibraryReferenceConfPath(): string {
47
- try {
48
- return fileURLToPath(import.meta.resolve(REFERENCE_CONF_SUBPATH));
49
- } catch {
50
- return createRequire(import.meta.url).resolve(REFERENCE_CONF_SUBPATH);
51
- }
52
- }
53
-
54
63
  export function resolveConfigPaths(configDirPath: string, env: string): ResolvedConfigPaths {
55
64
  // path.resolve strips trailing slashes that fileURLToPath may preserve, so
56
65
  // the containment check below compares equal shapes (path.dirname never
@@ -65,3 +74,387 @@ export function resolveConfigPaths(configDirPath: string, env: string): Resolved
65
74
  }
66
75
  return { applicationConfPath, envConfPath };
67
76
  }
77
+
78
+ export interface ResolveOptions {
79
+ /**
80
+ * The environment `${?VAR}` substitutions read — a snapshot of the
81
+ * process's when unset. Tests pass their own.
82
+ */
83
+ readonly env?: Readonly<Record<string, string>>;
84
+ }
85
+
86
+ /**
87
+ * The composition's own layers, read once: its files parsed, and the
88
+ * environment snapshot every layer's `${?VAR}` substitutes. Both phases are
89
+ * built from this one read.
90
+ */
91
+ export interface OwnLayers {
92
+ /** The composition's own files, highest first, parsed and layered. */
93
+ readonly config: Config;
94
+ /** The environment the files were substituted with; the references are too. */
95
+ readonly env: Readonly<Record<string, string>>;
96
+ }
97
+
98
+ /**
99
+ * Read the composition's own files — highest first, `{env}.conf` then
100
+ * `application.conf` — once, under one snapshot of the environment
101
+ * (`options.env`, or the process's as it is now).
102
+ */
103
+ export function readOwnLayers(
104
+ ownFiles: readonly string[],
105
+ options: ResolveOptions = {},
106
+ ): OwnLayers {
107
+ const env: Readonly<Record<string, string>> = {
108
+ ...(options.env ??
109
+ Object.fromEntries(
110
+ Object.entries(process.env).filter(
111
+ (entry): entry is [string, string] => entry[1] !== undefined,
112
+ ),
113
+ )),
114
+ };
115
+ const config = ownFiles.reduce<Config>(
116
+ (layered, file) => layered.withFallback(parseFile(file, { env: { ...env } })),
117
+ empty(),
118
+ );
119
+ return { config, env };
120
+ }
121
+
122
+ /**
123
+ * The composition's configuration, resolved to plain data and parsed by
124
+ * nothing: its own layers, read once (`readOwnLayers`), over
125
+ * `references`, the `reference.conf` files of the packages it loads, in the
126
+ * order `moduleReferences` answers them (core's last), substituted with the
127
+ * same environment. A path set in two layers takes the higher one's value.
128
+ */
129
+ export function resolveLayers(own: OwnLayers, references: readonly URL[]): Record<string, unknown> {
130
+ const layered = references.reduce<Config>(
131
+ (config, reference) =>
132
+ config.withFallback(parseFile(fileURLToPath(reference), { env: { ...own.env } })),
133
+ own.config,
134
+ );
135
+ return layered.toObject() as Record<string, unknown>;
136
+ }
137
+
138
+ /**
139
+ * What the template reads before it knows its modules, through core's
140
+ * reader: the switches `buildModules` and its module factories choose by, and
141
+ * the configuration's `core.sessionRequirements`, which
142
+ * `expectedSessionRequirements` reads (the log level is `readLogging`'s). A
143
+ * module a deployment adds that reads its configuration when it is built adds
144
+ * those paths here, or passes them to `readSwitches` as `reads`.
145
+ * `two-phase-config.test.mts` holds this list to what the template reads.
146
+ *
147
+ * Every path here and in `reads` must be one core's transitional base
148
+ * declares (a section core's schema has, or mirrors for a package), or
149
+ * `readSwitches` refuses it. A deployment module reading, when it is built, a
150
+ * key of its own that core does not mirror gets it as written: raw, an
151
+ * environment variable's string and all, without what only a package's
152
+ * `reference.conf` sets. Parse it in the module, or read it after boot.
153
+ */
154
+ export const SWITCHES: readonly string[] = [
155
+ "core.sessionRequirements",
156
+ "core.federations",
157
+ "federation-grants.enabled",
158
+ "session-store.storage",
159
+ "oauth-session.enabled",
160
+ "oauth-authorization.grants",
161
+ "oauth.accessToken",
162
+ ];
163
+
164
+ /** `core.sessionRequirements`, as the composition declares it to boot. */
165
+ export type SessionRequirements = NonNullable<AppConfig["core"]>["sessionRequirements"];
166
+
167
+ export interface SwitchesOptions {
168
+ /** Paths read beside `SWITCHES`: what a module a deployment adds reads when it is built. */
169
+ readonly reads?: readonly string[];
170
+ }
171
+
172
+ /**
173
+ * What phase one answers: the switches core's reader parsed; the composition
174
+ * root's own `adapters` and `mfaMode`, parsed with the template's schema;
175
+ * and the Store transport settings — the user repository's HTTP settings,
176
+ * `repositories.user.http`, as the template's own layers hold them, unparsed
177
+ * — which the Store-backed MFA factor store is built over.
178
+ */
179
+ export type Switches = AppConfig & {
180
+ readonly adapters: Adapters;
181
+ readonly mfaMode: MfaSwitch;
182
+ readonly storeTransport: unknown;
183
+ };
184
+
185
+ /**
186
+ * Phase one: the switches (`SWITCHES`, and `reads`) from the composition's
187
+ * own layers over core's `reference.conf` alone, read with core's
188
+ * `readTransitionalConfig`: each parsed with the schema core declares at its
189
+ * path, everything else as written; and, from its own layers over the
190
+ * template's `config/reference.conf`, the composition root's own `adapters`
191
+ * (`readAdapters`), which refuses a selection at the path it moved from and a
192
+ * variable renamed with one, its own MFA switch `mfaMode` (`readMfaSwitch`),
193
+ * and the Store transport settings. A variable the template bound for a
194
+ * federation it ships, renamed after the entry's path under
195
+ * `core.federations` (`SHIPPED_FEDERATION_RENAMES`), is refused here too.
196
+ * Use it for those choices only. A switch whose
197
+ * default only a package ships reads as unset here; set it in the template's
198
+ * own files.
199
+ */
200
+ export function readSwitches(own: OwnLayers, options: SwitchesOptions = {}): Switches {
201
+ const template = resolveLayers(own, [templateReference()]);
202
+ const adapters = readAdapters(template, own.env);
203
+ const mfaMode = readMfaSwitch(template, own.env);
204
+ refuseRenamedVariables(own.env, SHIPPED_FEDERATION_RENAMES);
205
+ const switches = readTransitionalConfig(resolveLayers(own, [coreReference()]), [
206
+ ...SWITCHES,
207
+ ...(options.reads ?? []),
208
+ ]);
209
+ return { ...switches, adapters, mfaMode, storeTransport: storeTransportOf(template) };
210
+ }
211
+
212
+ /** `repositories.user.http` in `resolved`, as written; `undefined` when absent. */
213
+ function storeTransportOf(resolved: Readonly<Record<string, unknown>>): unknown {
214
+ const repositories = resolved.repositories;
215
+ const user = isPlainSection(repositories) ? repositories.user : undefined;
216
+ return isPlainSection(user) ? user.http : undefined;
217
+ }
218
+
219
+ /**
220
+ * The `logging` module's section, read before boot for the logger: the own
221
+ * layers over the module's reference and core's, parsed with the module's
222
+ * schema. A refused value is a `RangeError` naming its path under `logging`.
223
+ */
224
+ export function readLogging(own: OwnLayers): LoggingSettings {
225
+ const resolved = resolveLayers(own, moduleReferences([loggingModule]));
226
+ const result = loggingSectionSchema.safeParse(resolved.logging);
227
+ if (!result.success) {
228
+ const issues = result.error.issues;
229
+ throw new RangeError(
230
+ `Config validation failed — ${issues.length} issue(s) found: ${issues
231
+ .map((issue) => `${["logging", ...issue.path.map(String)].join(".")}: ${issue.message}`)
232
+ .join("; ")}`,
233
+ { cause: result.error },
234
+ );
235
+ }
236
+ return result.data;
237
+ }
238
+
239
+ /**
240
+ * What this composition expects of session admission, from phase one: the
241
+ * configuration's `core.sessionRequirements` as written, with `mfa` appended
242
+ * to `expected` when the template's MFA switch, `mfaMode`, is not `off` and
243
+ * the list does not name it, and `secondFactorAuthority` written as `mfa`
244
+ * beside it — the switch installs the MFA module, whose requirement registers
245
+ * `mfa` as the second-factor authority, and boot refuses a declared name
246
+ * nothing registers (`session-requirement-missing`) and a declared authority
247
+ * the requirement of that name does not declare. With the switch on, a
248
+ * written `secondFactorAuthority` other than `mfa` is a `RangeError` naming
249
+ * the key, `mfaMode` and `MFA_MODE`, quoting nothing. With the switch `off`
250
+ * the section is as written, and with nothing written nothing is declared:
251
+ * boot's rule for an unwritten key applies. Computed here because HOCON has
252
+ * no conditional.
253
+ */
254
+ export function expectedSessionRequirements(
255
+ switches: Pick<Switches, "core" | "mfaMode">,
256
+ ): SessionRequirements {
257
+ const written = switches.core?.sessionRequirements;
258
+ if (switches.mfaMode === "off") {
259
+ return written === undefined ? undefined : { ...written, expected: [...written.expected] };
260
+ }
261
+ const authority = written?.secondFactorAuthority;
262
+ if (authority !== undefined && authority !== "mfa") {
263
+ throw new RangeError(
264
+ `core.sessionRequirements.secondFactorAuthority is written and names a requirement other than the one ${MFA_SWITCH} (MFA_MODE) installs as the second-factor authority: remove it, or write mfa`,
265
+ );
266
+ }
267
+ const declared = [...(written?.expected ?? [])];
268
+ return {
269
+ expected: declared.includes("mfa") ? declared : [...declared, "mfa"],
270
+ secondFactorAuthority: "mfa",
271
+ };
272
+ }
273
+
274
+ /**
275
+ * Whether a module in `modules` owns the top-level section `name`: its
276
+ * section sits there or under it, or it moved from there or under it.
277
+ */
278
+ function ownsSection(modules: readonly Module[], name: string): boolean {
279
+ const topOf = (path: string): string | undefined => path.split(".")[0];
280
+ return modules.some((module) => {
281
+ const section = module.section;
282
+ if (section === undefined) return false;
283
+ if ((section.at === undefined ? module.name : topOf(section.at)) === name) return true;
284
+ const from = section.relocatedFrom;
285
+ const old = from === undefined ? [] : Array.isArray(from) ? from : Object.keys(from);
286
+ return old.some((path) => topOf(path) === name);
287
+ });
288
+ }
289
+
290
+ /** A section's `keyPrefix`, when the section is one of keys and the prefix a string. */
291
+ function keyPrefixOf(
292
+ config: Readonly<Record<string, unknown>>,
293
+ module: Module,
294
+ ): string | undefined {
295
+ const section = config[module.name];
296
+ const prefix = isPlainSection(section) ? section.keyPrefix : undefined;
297
+ return typeof prefix === "string" ? prefix : undefined;
298
+ }
299
+
300
+ /**
301
+ * Refuses, with a `RangeError`, a Redis intent store left on the default key
302
+ * prefix where the grant store's was moved, when `modules` load the Redis
303
+ * intent store. The intent store's prefix is its own key, so a deployment
304
+ * that moved only the grant store's would keep acquisition's records in the
305
+ * default namespace, shared with every deployment on the same Redis database
306
+ * that left it there. Refused:
307
+ *
308
+ * - `redis-federation-grant-store.keyPrefix` off its default while
309
+ * `redis-federation-grant-intent-store.keyPrefix` is at its own, whether or
310
+ * not the Redis grant store is loaded: the package's `reference.conf`, which
311
+ * binds both, is layered with the intent store;
312
+ * - with the Redis grant store not loaded, `redisFederationGrantStore.keyPrefix`
313
+ * written at all: the intent store read that key, and no loaded module
314
+ * relocates it.
315
+ *
316
+ * The defaults are what the package's `reference.conf` sets with no
317
+ * environment. The message names the keys and variables, and quotes no value.
318
+ */
319
+ function refuseIntentPrefixLeftAtDefault(
320
+ resolved: Readonly<Record<string, unknown>>,
321
+ modules: readonly Module[],
322
+ ): void {
323
+ const grantStore = redisFederationGrantStoreModule;
324
+ const intentStore = redisFederationGrantIntentStoreModule;
325
+ const loaded = (module: Module) => modules.some((m) => m.name === module.name);
326
+ const reference = intentStore.section?.reference;
327
+ if (!loaded(intentStore) || reference === undefined) return;
328
+ const intentKey =
329
+ "redis-federation-grant-intent-store.keyPrefix (REDIS_FEDERATION_GRANT_INTENT_STORE_KEY_PREFIX)";
330
+ const oldSection = resolved.redisFederationGrantStore;
331
+ if (!loaded(grantStore) && isPlainSection(oldSection) && Object.hasOwn(oldSection, "keyPrefix")) {
332
+ throw new RangeError(
333
+ "redisFederationGrantStore.keyPrefix is set, and no installed module reads it: the Redis " +
334
+ `intent store's prefix is its own key, ${intentKey}. Set that instead, and delete ` +
335
+ "redisFederationGrantStore.keyPrefix",
336
+ );
337
+ }
338
+ const defaults = resolveLayers({ config: empty(), env: {} }, [reference]);
339
+ const grant = keyPrefixOf(resolved, grantStore);
340
+ const intent = keyPrefixOf(resolved, intentStore);
341
+ if (grant === undefined || grant === keyPrefixOf(defaults, grantStore)) return;
342
+ if (intent === undefined || intent !== keyPrefixOf(defaults, intentStore)) return;
343
+ const grantKey =
344
+ "redis-federation-grant-store.keyPrefix (REDIS_FEDERATION_GRANT_STORE_KEY_PREFIX)";
345
+ throw new RangeError(
346
+ `${grantKey} is set off its default key prefix, and ${intentKey} is left at its ` +
347
+ "default. The Redis intent store's prefix is its own: acquisition's records would " +
348
+ "stay in the default namespace, shared with every deployment on the same Redis " +
349
+ `database that left it there. Set ${intentKey} as well — to the grant store's prefix ` +
350
+ "to keep the two together, or to one of its own",
351
+ );
352
+ }
353
+
354
+ /**
355
+ * The composition root's own keys, which phase one consumes and boot is never
356
+ * handed: the `adapters` section and the `mfaMode` switch.
357
+ */
358
+ const ROOT_SECTIONS: readonly string[] = [ADAPTERS_SECTION, MFA_SWITCH];
359
+
360
+ /**
361
+ * The sections the template's own `config/reference.conf` sets that no module
362
+ * in `modules` owns and that nothing has changed: as that file sets them with
363
+ * no environment. Boot is not handed them, so a module of the template's the
364
+ * composition does not load names nothing at boot; a section an operator's
365
+ * layer or the environment changed is handed on, and boot names it once as a
366
+ * section nothing owns.
367
+ */
368
+ function unownedTemplateDefaults(
369
+ resolved: Readonly<Record<string, unknown>>,
370
+ modules: readonly Module[],
371
+ ): readonly string[] {
372
+ const defaults = resolveLayers({ config: empty(), env: {} }, [templateReference()]);
373
+ return Object.keys(defaults).filter(
374
+ (name) =>
375
+ name !== RENAMED_VARIABLES &&
376
+ !ROOT_SECTIONS.includes(name) &&
377
+ !ownsSection(modules, name) &&
378
+ isDeepStrictEqual(resolved[name], defaults[name]),
379
+ );
380
+ }
381
+
382
+ /**
383
+ * Refuses, with a `RangeError` naming it, a module whose section is at or
384
+ * under one of the composition root's own sections, which phase one consumes
385
+ * and boot is never handed: the module would read nothing its operator wrote.
386
+ */
387
+ function refuseModuleAtRootSections(modules: readonly Module[]): void {
388
+ for (const module of modules) {
389
+ const section = module.section;
390
+ if (section === undefined) continue;
391
+ const path = section.at ?? module.name;
392
+ const top = path.split(".")[0] ?? path;
393
+ if (ROOT_SECTIONS.includes(top)) {
394
+ throw new RangeError(
395
+ `Module "${module.name}" has its section at ${path}, under ${top}: the composition root's own section, which boot is not handed. Give the module a section of another name.`,
396
+ );
397
+ }
398
+ }
399
+ }
400
+
401
+ /** The section a resolution captures renamed variables in: never left out. */
402
+ const RENAMED_VARIABLES = "renamed-variables";
403
+
404
+ /**
405
+ * Phase two: what `createApp` parses once, with every loaded module's schema:
406
+ * the composition's own layers, the same read phase one had, over the
407
+ * `reference.conf` of every package `modules` come from, core's last,
408
+ * resolved and unparsed, with `core.sessionRequirements` — what phase one
409
+ * says the composition expects (`expectedSessionRequirements`) — written over
410
+ * the resolved section when there is one to write, and the `mfa` section and
411
+ * the `oauth` acr table as the template's MFA switch decides them
412
+ * (`mfaSectionForBoot`, `oauthForBoot`). Left out:
413
+ * `adapters` and `mfaMode`, the composition root's own keys, which phase one
414
+ * consumed, and a section the template's own `reference.conf` sets for a
415
+ * module the composition does not load, left as that file sets it
416
+ * (`unownedTemplateDefaults`).
417
+ *
418
+ * Refuses, with a `RangeError`, a module whose section is under a section of
419
+ * the composition root's (`refuseModuleAtRootSections`), what
420
+ * `mfaSectionForBoot` refuses of the `mfa` section, and the Redis intent store left
421
+ * on its default key prefix where the grant store's was moved
422
+ * (`refuseIntentPrefixLeftAtDefault`).
423
+ *
424
+ * Typed `AppConfig` because that is the `config` slot's type; read the parsed
425
+ * configuration from `handle.components.config`, not from this.
426
+ */
427
+ export function resolveForBoot(
428
+ own: OwnLayers,
429
+ modules: readonly Module[],
430
+ switches: Pick<Switches, "core" | "mfaMode" | "adapters">,
431
+ ): AppConfig {
432
+ refuseModuleAtRootSections(modules);
433
+ const all = resolveLayers(own, moduleReferences(modules));
434
+ refuseIntentPrefixLeftAtDefault(all, modules);
435
+ const unowned = new Set([...ROOT_SECTIONS, ...unownedTemplateDefaults(all, modules)]);
436
+ const layered = Object.fromEntries(Object.entries(all).filter(([name]) => !unowned.has(name)));
437
+ const { mfa: _decided, ...resolved } = layered;
438
+ const mfa = mfaSectionForBoot({
439
+ mode: switches.mfaMode,
440
+ written: (own.config.toObject() as Record<string, unknown>).mfa,
441
+ resolved: all,
442
+ owned: ownsSection(modules, "mfa"),
443
+ storeCalled:
444
+ switches.adapters.userRepository === "http" || switches.adapters.mfaFactorStore === "store",
445
+ env: own.env,
446
+ });
447
+ const sessionRequirements = expectedSessionRequirements(switches);
448
+ return {
449
+ ...resolved,
450
+ ...(mfa === undefined ? {} : { mfa }),
451
+ ...(resolved.oauth === undefined
452
+ ? {}
453
+ : { oauth: oauthForBoot(switches.mfaMode, resolved.oauth) }),
454
+ ...(sessionRequirements === undefined
455
+ ? {}
456
+ : {
457
+ core: { ...(resolved.core as Record<string, unknown> | undefined), sessionRequirements },
458
+ }),
459
+ } as unknown as AppConfig;
460
+ }
@@ -0,0 +1,60 @@
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
+ import { createServer, type Server } from "node:http";
17
+ import type { AddressInfo } from "node:net";
18
+ import { type Logger, loggableError } from "@o3co/auth-provider-core";
19
+ import type { Express } from "express";
20
+
21
+ /**
22
+ * Start the HTTP listener, and say so once it is bound.
23
+ *
24
+ * Resolves with the server once its socket is bound, after logging
25
+ * `server_listening` (info) with the port the socket holds (the configured
26
+ * one, or the one the OS picked for `0`). Rejects with the server's error when
27
+ * the socket cannot be bound (`EADDRINUSE`, `EACCES`), and logs nothing: the
28
+ * composition root awaits this, so the error ends boot like any other boot
29
+ * failure.
30
+ *
31
+ * The server is built (`http.createServer(app)`, as `app.listen` does) and its
32
+ * events wired here, so nothing rests on how an Express version wires them;
33
+ * Express 5 hands an `app.listen` callback the bind error too. Until bound,
34
+ * one `error` listener turns a bind failure into the rejection. Once bound, it
35
+ * is replaced by the one the server keeps: a later `error` (an `accept` that
36
+ * fails with EMFILE) is logged as `server_error` (error, `err`: core's
37
+ * `loggableError` projection) and the process keeps running. Without a
38
+ * listener, such an error would be thrown out of the process.
39
+ */
40
+ export function listen(app: Express, port: number, logger: Logger): Promise<Server> {
41
+ const server = createServer(app);
42
+ return new Promise((resolve, reject) => {
43
+ const refuse = (err: Error): void => {
44
+ server.off("listening", bound);
45
+ reject(err);
46
+ };
47
+ const bound = (): void => {
48
+ server.off("error", refuse);
49
+ server.on("error", (serverErr: Error) => {
50
+ logger.error({ err: loggableError(serverErr) }, "server_error");
51
+ });
52
+ const address = server.address() as AddressInfo | null;
53
+ logger.info({ port: address?.port ?? port }, "server_listening");
54
+ resolve(server);
55
+ };
56
+ server.once("error", refuse);
57
+ server.once("listening", bound);
58
+ server.listen(port);
59
+ });
60
+ }
@@ -13,65 +13,42 @@
13
13
  * See the License for the specific language governing permissions and
14
14
  * limitations under the License.
15
15
  */
16
- import type { AppConfig, AuditSink, Logger } from "@o3co/auth-provider-core";
16
+ import type { AuditSink, Logger } from "@o3co/auth-provider-core";
17
17
  import { pino, stdSerializers } from "pino";
18
+ import type { LoggingSettings } from "./sections.mjs";
19
+
20
+ export type { LoggingSettings } from "./sections.mjs";
18
21
 
19
22
  /**
20
- * The composition root's logger, and the value wired into the `logger`
21
- * ComponentMap slot that every module reads.
22
- *
23
- * pino, emitting newline-delimited JSON on stdout — the shape every log
24
- * aggregator ingests without a parser, and the reason `Logger` was given
25
- * pino's two-overload call signature in the first place. A pino instance
26
- * therefore satisfies `Logger` structurally, with no adapter.
27
- *
28
- * Two things this closes:
29
- *
30
- * - **The slot was never filled.** Only a value satisfying `Logger` can occupy
31
- * it, and the logger this template used before exposes four methods with a
32
- * narrower call shape. So every module that declares `optional: ["logger"]`
33
- * — oauth, session, dpop, mtls, token-exchange — silently fell back to its
34
- * own `consoleLogger` default, and nothing an operator configured here
35
- * reached any of them.
36
- * - **`logging.level` is honoured.** pino drops sub-threshold calls before
37
- * formatting, so `trace` / `debug` cost nothing in production rather than
38
- * being emitted and filtered downstream.
39
- *
40
- * `silent` is pino's own name for "emit nothing", so the config vocabulary
41
- * maps across unchanged.
23
+ * The composition root's logger, and the value wired into the `logger` slot
24
+ * every module reads: pino, newline-delimited JSON on stdout, at the
25
+ * `logging` section's level. `Logger` has pino's two-overload call signature, so a pino
26
+ * instance satisfies it with no adapter, and `silent` is pino's own name for
27
+ * "emit nothing", so the config vocabulary maps across unchanged. See the
28
+ * template README, "Logging".
42
29
  */
43
- export function createAppLogger(config: AppConfig): Logger {
30
+ export function createAppLogger(logging: LoggingSettings): Logger {
44
31
  return pino({
45
32
  name: "provider",
46
- level: config.logging.level,
47
- // `err` is pino's conventional key for an Error, and every structured
48
- // event in this stack uses it — `logger.error({ err }, "…_error")`.
49
- // Without the serialiser an Error stringifies to `{}` and the stack is
50
- // lost exactly where it is needed.
33
+ level: logging.level,
34
+ // `err` is the key every structured event in this stack uses
35
+ // (`logger.error({ err: loggableError(err) }, "…_error")`). Core's
36
+ // projection is plain data with no `message`, so this serialiser hands it
37
+ // through as it is, every cause's fields included. It is here for an
38
+ // Error handed in as it is, which would otherwise stringify to `{}`.
51
39
  serializers: { err: stdSerializers.err },
52
40
  });
53
41
  }
54
42
 
55
43
  /**
56
44
  * The stream the audit trail is written on: `name: "audit"`, newline-delimited
57
- * JSON on stdout, in the same pino envelope as every other line this template
58
- * emits. One output style, so an aggregator ingests security events and
59
- * application logs through one parser and separates them on `name`.
60
- *
61
- * **It takes no config, and its level is fixed.** That is the point rather
62
- * than an oversight. `logging.level` is a diagnostics knob — `warn` is an
63
- * ordinary production setting and `silent` a legitimate one — and an audit
64
- * trail is evidence, not diagnostics. Routing audit events through the
65
- * application logger would mean either of those settings silently deleting
66
- * the record of who authenticated, what was issued, and what was refused:
67
- * the same drop #287 is about, reached from the operator's side instead of
68
- * the scaffold's. Where audit events go is chosen by `audit.sink.type`, and
69
- * that selector has no "none" (#304).
45
+ * JSON on stdout in the same pino envelope as every other line.
70
46
  *
71
- * A second pino instance rather than a child of the app logger: a child
72
- * inherits its parent's level, and depending on a level-override subtlety to
73
- * keep the audit trail alive is exactly the kind of thing that stops being
74
- * true in a later refactor without anything failing.
47
+ * **It takes no config, and its level is fixed**, on purpose: an audit trail
48
+ * is evidence, not diagnostics, and `logging.level` (where `warn` is an
49
+ * ordinary production setting and `silent` a legitimate one) must not delete
50
+ * it. See the template README, "Audit trail". A second pino instance rather
51
+ * than a child of the app logger, because a child inherits its parent's level.
75
52
  */
76
53
  export function createAuditLogger(): Logger {
77
54
  return pino({
@@ -82,19 +59,16 @@ export function createAuditLogger(): Logger {
82
59
  }
83
60
 
84
61
  /**
85
- * `AuditSink` writing each event through `auditLogger` — the `"logger"` sink
62
+ * `AuditSink` writing each event through `auditLogger`: the `"logger"` sink
86
63
  * kind this template registers and ships as its default.
87
64
  *
88
- * The event is nested under `audit` rather than spread at the top level:
89
- * `level`, `time`, `name` and `msg` belong to the log envelope, and a future
90
- * audit field colliding with one of them would corrupt the line for every
91
- * consumer. The event type doubles as the message so operators alert on the
92
- * name — `token.issued.failure`, `authorize.rejected` — the same way they
93
- * already alert on `session_store_redis_error`.
94
- *
95
- * Errors are core's problem, not this function's: `emitAuditEvent` dispatches
96
- * without awaiting and swallows rejections, because audit recording must never
97
- * add latency to (or fail) an auth flow.
65
+ * The event is nested under `audit`, not spread at the top level: `level`,
66
+ * `time`, `name` and `msg` belong to the log envelope, and an audit field
67
+ * colliding with one would corrupt the line for every consumer. The event
68
+ * type is the message, so operators alert on the name (`token.issued.failure`,
69
+ * `authorize.rejected`) as on `session_store_redis_error`. Errors are core's:
70
+ * `emitAuditEvent` dispatches without awaiting and swallows rejections,
71
+ * because audit recording must never add latency to (or fail) an auth flow.
98
72
  */
99
73
  export function createLoggerAuditSink(auditLogger: Logger): AuditSink {
100
74
  return {