@o3co/create-auth-provider 0.17.0 → 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 (114) 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 -912
  19. package/templates/standalone/README.md +0 -1536
  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 -124
  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 -427
  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 -56
  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 -1188
  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 -355
  39. package/templates/standalone/src/__tests__/backchannel-peer.fixture.mts +0 -64
  40. package/templates/standalone/src/__tests__/config-notices.test.mts +0 -157
  41. package/templates/standalone/src/__tests__/configPath.test.mts +0 -69
  42. package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +0 -48
  43. package/templates/standalone/src/__tests__/core-federations.test.mts +0 -252
  44. package/templates/standalone/src/__tests__/core-sections.test.mts +0 -192
  45. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +0 -95
  46. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +0 -1291
  47. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -787
  48. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -464
  49. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +0 -141
  50. package/templates/standalone/src/__tests__/federation-types.test.mts +0 -239
  51. package/templates/standalone/src/__tests__/frontchannel-logout-csp.test.mts +0 -168
  52. package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -318
  53. package/templates/standalone/src/__tests__/host-routes.test.mts +0 -134
  54. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +0 -29
  55. package/templates/standalone/src/__tests__/library-references.fixture.mts +0 -188
  56. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +0 -217
  57. package/templates/standalone/src/__tests__/listen.test.mts +0 -121
  58. package/templates/standalone/src/__tests__/logger-projection.test.mts +0 -77
  59. package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +0 -113
  60. package/templates/standalone/src/__tests__/mail-sender.test.mts +0 -134
  61. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -393
  62. package/templates/standalone/src/__tests__/metrics.test.mts +0 -253
  63. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +0 -122
  64. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +0 -127
  65. package/templates/standalone/src/__tests__/mfa-switch.test.mts +0 -883
  66. package/templates/standalone/src/__tests__/own-modules.test.mts +0 -1480
  67. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
  68. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -283
  69. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +0 -135
  70. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +0 -214
  71. package/templates/standalone/src/__tests__/redis-stand-in.fixture.mts +0 -98
  72. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
  73. package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -657
  74. package/templates/standalone/src/__tests__/repositories-logger.test.mts +0 -119
  75. package/templates/standalone/src/__tests__/router-error-answers.test.mts +0 -146
  76. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +0 -610
  77. package/templates/standalone/src/__tests__/sections.test.mts +0 -516
  78. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -213
  79. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -305
  80. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +0 -130
  81. package/templates/standalone/src/__tests__/session-requirements.test.mts +0 -382
  82. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +0 -274
  83. package/templates/standalone/src/__tests__/shutdown.test.mts +0 -622
  84. package/templates/standalone/src/__tests__/smoke.test.mts +0 -642
  85. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -164
  86. package/templates/standalone/src/__tests__/store-credential.test.mts +0 -314
  87. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +0 -296
  88. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +0 -265
  89. package/templates/standalone/src/__tests__/two-phase-config.test.mts +0 -605
  90. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +0 -385
  91. package/templates/standalone/src/adapters.mts +0 -162
  92. package/templates/standalone/src/app.mts +0 -127
  93. package/templates/standalone/src/bootRefusal.mts +0 -117
  94. package/templates/standalone/src/buildModules.mts +0 -358
  95. package/templates/standalone/src/configPath.mts +0 -487
  96. package/templates/standalone/src/listen.mts +0 -60
  97. package/templates/standalone/src/logger.mts +0 -80
  98. package/templates/standalone/src/metrics.mts +0 -167
  99. package/templates/standalone/src/mfaSwitch.mts +0 -284
  100. package/templates/standalone/src/modules.mts +0 -705
  101. package/templates/standalone/src/rootRenames.mts +0 -95
  102. package/templates/standalone/src/routes.mts +0 -89
  103. package/templates/standalone/src/sections.mts +0 -378
  104. package/templates/standalone/src/securityHeaders.mts +0 -36
  105. package/templates/standalone/src/shutdown.mts +0 -213
  106. package/templates/standalone/tests/Makefile +0 -5
  107. package/templates/standalone/tests/index.test.js +0 -283
  108. package/templates/standalone/tests/package-lock.json +0 -1617
  109. package/templates/standalone/tests/package.json +0 -16
  110. package/templates/standalone/tests/vitest.config.mjs +0 -26
  111. package/templates/standalone/tsconfig.json +0 -31
  112. package/templates/standalone/vitest.config.mts +0 -31
  113. package/templates/standalone/vitest.supertest-loopback.mts +0 -278
  114. package/templates/versions.json +0 -20
@@ -1,487 +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
- * 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. Beside phase two it builds the configuration's defaults,
29
- * which boot names the sections nothing loaded reads by
30
- * (`configDefaultsFor`): the same references, with no file of the
31
- * composition's and no environment.
32
- */
33
-
34
- import path from "node:path";
35
- import { fileURLToPath } from "node:url";
36
- import {
37
- type AppConfig,
38
- type CoreSection,
39
- coerceBooleanFromEnv,
40
- type Module,
41
- moduleReferences,
42
- } from "@o3co/auth-provider-core";
43
- import {
44
- redisFederationGrantIntentStoreModule,
45
- redisFederationGrantStoreModule,
46
- } from "@o3co/auth-provider-redis";
47
- import { type Config, empty, parseFile } from "@o3co/ts.hocon";
48
- import { ADAPTERS_SECTION, readAdapters } from "./adapters.mjs";
49
- import {
50
- configRefused,
51
- issuesAt,
52
- keyRefused,
53
- pathsRelocated,
54
- sectionPathRefused,
55
- } from "./bootRefusal.mjs";
56
- import { MFA_SWITCH, mfaSectionForBoot, oauthForBoot, readMfaSwitch } from "./mfaSwitch.mjs";
57
- import { loggingModule, templateReference } from "./modules.mjs";
58
- import { refuseRenamedVariables, SHIPPED_FEDERATION_RENAMES } from "./rootRenames.mjs";
59
- import {
60
- type Adapters,
61
- isPlainSection,
62
- type LoggingSettings,
63
- loggingSectionSchema,
64
- type MfaSwitch,
65
- } from "./sections.mjs";
66
-
67
- export interface ResolvedConfigPaths {
68
- readonly applicationConfPath: string;
69
- readonly envConfPath: string;
70
- }
71
-
72
- export function resolveConfigPaths(configDirPath: string, env: string): ResolvedConfigPaths {
73
- // path.resolve strips trailing slashes that fileURLToPath may preserve, so
74
- // the containment check below compares equal shapes (path.dirname never
75
- // returns a trailing separator).
76
- const normalizedConfigDir = path.resolve(configDirPath);
77
- const applicationConfPath = path.join(normalizedConfigDir, "application.conf");
78
- const envConfPath = path.resolve(normalizedConfigDir, `${env}.conf`);
79
- if (path.dirname(envConfPath) !== normalizedConfigDir) {
80
- throw new Error(
81
- `Invalid config environment name: "${env}" resolves outside ${normalizedConfigDir}`,
82
- );
83
- }
84
- return { applicationConfPath, envConfPath };
85
- }
86
-
87
- export interface ResolveOptions {
88
- /**
89
- * The environment `${?VAR}` substitutions read — a snapshot of the
90
- * process's when unset. Tests pass their own.
91
- */
92
- readonly env?: Readonly<Record<string, string>>;
93
- }
94
-
95
- /**
96
- * The composition's own layers, read once: its files parsed, and the
97
- * environment snapshot every layer's `${?VAR}` substitutes. Both phases are
98
- * built from this one read.
99
- */
100
- export interface OwnLayers {
101
- /** The composition's own files, highest first, parsed and layered. */
102
- readonly config: Config;
103
- /** The environment the files were substituted with; the references are too. */
104
- readonly env: Readonly<Record<string, string>>;
105
- }
106
-
107
- /**
108
- * Read the composition's own files — highest first, `{env}.conf` then
109
- * `application.conf` — once, under one snapshot of the environment
110
- * (`options.env`, or the process's as it is now).
111
- */
112
- export function readOwnLayers(
113
- ownFiles: readonly string[],
114
- options: ResolveOptions = {},
115
- ): OwnLayers {
116
- const env: Readonly<Record<string, string>> = {
117
- ...(options.env ??
118
- Object.fromEntries(
119
- Object.entries(process.env).filter(
120
- (entry): entry is [string, string] => entry[1] !== undefined,
121
- ),
122
- )),
123
- };
124
- const config = ownFiles.reduce<Config>(
125
- (layered, file) => layered.withFallback(parseFile(file, { env: { ...env } })),
126
- empty(),
127
- );
128
- return { config, env };
129
- }
130
-
131
- /**
132
- * The composition's configuration, resolved to plain data and parsed by
133
- * nothing: its own layers, read once (`readOwnLayers`), over
134
- * `references`, the `reference.conf` files of the packages it loads, in the
135
- * order `moduleReferences` answers them (core's last), substituted with the
136
- * same environment. A path set in two layers takes the higher one's value.
137
- */
138
- export function resolveLayers(own: OwnLayers, references: readonly URL[]): Record<string, unknown> {
139
- const layered = references.reduce<Config>(
140
- (config, reference) =>
141
- config.withFallback(parseFile(fileURLToPath(reference), { env: { ...own.env } })),
142
- own.config,
143
- );
144
- return layered.toObject() as Record<string, unknown>;
145
- }
146
-
147
- /** `core.sessionRequirements`, as the composition declares it to boot. */
148
- export type SessionRequirements = NonNullable<CoreSection["sessionRequirements"]>;
149
-
150
- /**
151
- * What phase one answers, from the composition's own layers over the
152
- * template's `config/reference.conf` alone: the composition root's own
153
- * `adapters` and `mfaMode`, parsed with the template's schema; the Store
154
- * transport settings — the user repository's HTTP settings,
155
- * `repositories.user.http`, unparsed — which the Store-backed MFA factor
156
- * store is built over; and whether the federation-grants modules are
157
- * installed (`federationGrantsInstalled`). Nothing else of the configuration
158
- * is read before boot: a module reads its own section, `deps.section`, at
159
- * boot.
160
- */
161
- export type Switches = {
162
- readonly adapters: Adapters;
163
- readonly mfaMode: MfaSwitch;
164
- readonly storeTransport: unknown;
165
- readonly "federation-grants"?: { readonly enabled?: boolean };
166
- };
167
-
168
- /**
169
- * Phase one: from the composition's own layers over the template's
170
- * `config/reference.conf`, the composition root's own `adapters`
171
- * (`readAdapters`), which refuses a selection at the path it moved from and a
172
- * variable renamed with one, its own MFA switch `mfaMode` (`readMfaSwitch`),
173
- * the Store transport settings, and whether federation grants are installed
174
- * (`federationGrantsInstalled`). A variable the template bound for a
175
- * federation it ships, renamed after the entry's path under
176
- * `core.federations` (`SHIPPED_FEDERATION_RENAMES`), is refused here too.
177
- * Use it for those choices only. A default only a package's reference ships
178
- * reads as unset here; the template's own files set what it reads.
179
- */
180
- export function readSwitches(own: OwnLayers): Switches {
181
- const template = resolveLayers(own, [templateReference()]);
182
- const adapters = readAdapters(template, own.env);
183
- const mfaMode = readMfaSwitch(template, own.env);
184
- refuseRenamedVariables(own.env, SHIPPED_FEDERATION_RENAMES);
185
- return {
186
- adapters,
187
- mfaMode,
188
- storeTransport: storeTransportOf(template),
189
- "federation-grants": { enabled: federationGrantsInstalled(template) },
190
- };
191
- }
192
-
193
- /**
194
- * Whether `resolved` installs the federation-grants modules: unless
195
- * `federation-grants.enabled` reads as `false` with core's
196
- * `coerceBooleanFromEnv`, or is not written at all. A value that does not
197
- * parse installs them, so that boot refuses it at `federation-grants.enabled`
198
- * rather than the feature reading as off; so does a section written as
199
- * something other than a section, and any setting written at the section's
200
- * old path, `federationGrants`, which boot refuses naming the new path.
201
- */
202
- function federationGrantsInstalled(resolved: Readonly<Record<string, unknown>>): boolean {
203
- if (setsAnything(resolved.federationGrants)) return true;
204
- const section = resolved["federation-grants"];
205
- if (section === undefined) return false;
206
- if (!isPlainSection(section)) return true;
207
- if (section.enabled === undefined) return false;
208
- const enabled = coerceBooleanFromEnv.safeParse(section.enabled);
209
- return !(enabled.success && enabled.data === false);
210
- }
211
-
212
- /** Whether `value` sets anything: a value, or a section with one somewhere under it. */
213
- function setsAnything(value: unknown): boolean {
214
- if (value === undefined) return false;
215
- if (typeof value !== "object" || value === null || Array.isArray(value)) return true;
216
- return Object.values(value).some(setsAnything);
217
- }
218
-
219
- /** `repositories.user.http` in `resolved`, as written; `undefined` when absent. */
220
- function storeTransportOf(resolved: Readonly<Record<string, unknown>>): unknown {
221
- const repositories = resolved.repositories;
222
- const user = isPlainSection(repositories) ? repositories.user : undefined;
223
- return isPlainSection(user) ? user.http : undefined;
224
- }
225
-
226
- /**
227
- * The `logging` module's section, read before boot for the logger: the own
228
- * layers over the module's reference and core's, parsed with the module's
229
- * schema. A refused value is a `config-validation-failed` `BootError` naming
230
- * its path under `logging`.
231
- */
232
- export function readLogging(own: OwnLayers): LoggingSettings {
233
- const resolved = resolveLayers(own, moduleReferences([loggingModule]));
234
- const result = loggingSectionSchema.safeParse(resolved.logging);
235
- if (!result.success) {
236
- const issues = issuesAt([loggingModule.name], result.error.issues);
237
- throw configRefused(
238
- `Config validation failed — ${issues.length} issue(s) found: ${issues
239
- .map((issue) => `${issue.path.map(String).join(".")}: ${issue.message}`)
240
- .join("; ")}`,
241
- issues,
242
- [{ module: loggingModule.name, schemaPath: loggingModule.name }],
243
- );
244
- }
245
- return result.data;
246
- }
247
-
248
- /**
249
- * What this composition expects of session admission: what to write over
250
- * the configuration's `core.sessionRequirements` — `written`, as resolved —
251
- * when the template's MFA switch, `mfaMode`, is not `off`: the configuration's
252
- * `expected` with `mfa` appended when the list does not name it, and
253
- * `secondFactorAuthority` written as `mfa` beside it — the switch installs the
254
- * MFA module, whose requirement registers `mfa` as the second-factor
255
- * authority, and boot refuses a declared name nothing registers
256
- * (`session-requirement-missing`) and a declared authority the requirement of
257
- * that name does not declare. Every other key written there is kept, for
258
- * boot to refuse. With nothing written, `mfa` alone. With the switch on, a
259
- * written `secondFactorAuthority` other than `mfa` is a
260
- * `config-validation-failed` `BootError` naming the key, `mfaMode` and
261
- * `MFA_MODE`, quoting nothing.
262
- *
263
- * `undefined` hands the section on as written: with the switch `off`, and
264
- * where `written` is not a section or its `expected` is not a list of names,
265
- * which boot refuses at its path. Computed here because HOCON has no
266
- * conditional.
267
- */
268
- export function expectedSessionRequirements(
269
- written: unknown,
270
- mfaMode: MfaSwitch,
271
- ): SessionRequirements | undefined {
272
- if (mfaMode === "off") return undefined;
273
- if (written === undefined) return { expected: ["mfa"], secondFactorAuthority: "mfa" };
274
- if (!isPlainSection(written)) return undefined;
275
- const authority = written.secondFactorAuthority;
276
- if (authority !== undefined && authority !== "mfa") {
277
- throw keyRefused(
278
- `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`,
279
- "core",
280
- ["core", "sessionRequirements", "secondFactorAuthority"],
281
- );
282
- }
283
- const declared = written.expected;
284
- if (!isListOfNames(declared)) return undefined;
285
- return {
286
- ...written,
287
- expected: declared.includes("mfa") ? [...declared] : [...declared, "mfa"],
288
- secondFactorAuthority: "mfa",
289
- };
290
- }
291
-
292
- /** Whether `value` is a list of strings. */
293
- function isListOfNames(value: unknown): value is readonly string[] {
294
- return Array.isArray(value) && value.every((name) => typeof name === "string");
295
- }
296
-
297
- /**
298
- * Whether a module in `modules` owns the top-level section `name`: its
299
- * section sits there, at the module's name, or it moved from there or under
300
- * it.
301
- */
302
- function ownsSection(modules: readonly Module[], name: string): boolean {
303
- const topOf = (path: string): string | undefined => path.split(".")[0];
304
- return modules.some((module) => {
305
- const section = module.section;
306
- if (section === undefined) return false;
307
- if (module.name === name) return true;
308
- const from = section.relocatedFrom;
309
- const old = from === undefined ? [] : Array.isArray(from) ? from : Object.keys(from);
310
- return old.some((path) => topOf(path) === name);
311
- });
312
- }
313
-
314
- /** A section's `keyPrefix`, when the section is one of keys and the prefix a string. */
315
- function keyPrefixOf(
316
- config: Readonly<Record<string, unknown>>,
317
- module: Module,
318
- ): string | undefined {
319
- const section = config[module.name];
320
- const prefix = isPlainSection(section) ? section.keyPrefix : undefined;
321
- return typeof prefix === "string" ? prefix : undefined;
322
- }
323
-
324
- /**
325
- * Refuses, with a `BootError`, a Redis intent store left on the default key
326
- * prefix where the grant store's was moved, when `modules` load the Redis
327
- * intent store. The intent store's prefix is its own key, so a deployment
328
- * that moved only the grant store's would keep acquisition's records in the
329
- * default namespace, shared with every deployment on the same Redis database
330
- * that left it there. Refused:
331
- *
332
- * - `redis-federation-grant-store.keyPrefix` off its default while
333
- * `redis-federation-grant-intent-store.keyPrefix` is at its own, whether or
334
- * not the Redis grant store is loaded: the package's `reference.conf`, which
335
- * binds both, is layered with the intent store;
336
- * - with the Redis grant store not loaded, `redisFederationGrantStore.keyPrefix`
337
- * written at all: the intent store read that key, and no loaded module
338
- * relocates it.
339
- *
340
- * The first is `config-validation-failed` at the intent store's key; the
341
- * second `config-path-relocated`, from the old key to the intent store's. The
342
- * defaults are what the package's `reference.conf` sets with no environment.
343
- * The message names the keys and variables, and quotes no value.
344
- */
345
- function refuseIntentPrefixLeftAtDefault(
346
- resolved: Readonly<Record<string, unknown>>,
347
- modules: readonly Module[],
348
- ): void {
349
- const grantStore = redisFederationGrantStoreModule;
350
- const intentStore = redisFederationGrantIntentStoreModule;
351
- const loaded = (module: Module) => modules.some((m) => m.name === module.name);
352
- const reference = intentStore.section?.reference;
353
- if (!loaded(intentStore) || reference === undefined) return;
354
- const intentPath = `${intentStore.name}.keyPrefix`;
355
- const intentVariable = "REDIS_FEDERATION_GRANT_INTENT_STORE_KEY_PREFIX";
356
- const intentKey = `${intentPath} (${intentVariable})`;
357
- const oldSection = resolved.redisFederationGrantStore;
358
- if (!loaded(grantStore) && isPlainSection(oldSection) && Object.hasOwn(oldSection, "keyPrefix")) {
359
- throw pathsRelocated(
360
- "redisFederationGrantStore.keyPrefix is set, and no installed module reads it: the Redis " +
361
- `intent store's prefix is its own key, ${intentKey}. Set that instead, and delete ` +
362
- "redisFederationGrantStore.keyPrefix",
363
- [
364
- {
365
- module: intentStore.name,
366
- from: "redisFederationGrantStore.keyPrefix",
367
- to: intentPath,
368
- environmentVariable: intentVariable,
369
- },
370
- ],
371
- );
372
- }
373
- const defaults = resolveLayers({ config: empty(), env: {} }, [reference]);
374
- const grant = keyPrefixOf(resolved, grantStore);
375
- const intent = keyPrefixOf(resolved, intentStore);
376
- if (grant === undefined || grant === keyPrefixOf(defaults, grantStore)) return;
377
- if (intent === undefined || intent !== keyPrefixOf(defaults, intentStore)) return;
378
- const grantKey =
379
- "redis-federation-grant-store.keyPrefix (REDIS_FEDERATION_GRANT_STORE_KEY_PREFIX)";
380
- throw keyRefused(
381
- `${grantKey} is set off its default key prefix, and ${intentKey} is left at its ` +
382
- "default. The Redis intent store's prefix is its own: acquisition's records would " +
383
- "stay in the default namespace, shared with every deployment on the same Redis " +
384
- `database that left it there. Set ${intentKey} as well — to the grant store's prefix ` +
385
- "to keep the two together, or to one of its own",
386
- intentStore.name,
387
- [intentStore.name, "keyPrefix"],
388
- );
389
- }
390
-
391
- /**
392
- * The composition root's own keys, which phase one consumes and boot is never
393
- * handed: the `adapters` section and the `mfaMode` switch.
394
- */
395
- const ROOT_SECTIONS: readonly string[] = [ADAPTERS_SECTION, MFA_SWITCH];
396
-
397
- /**
398
- * Refuses, with a `module-section-path-invalid` `BootError` naming it, a
399
- * module whose section — at the module's name — is one of the composition
400
- * root's own sections, which phase one consumes and boot is never handed:
401
- * the module would read nothing its operator wrote.
402
- */
403
- function refuseModuleAtRootSections(modules: readonly Module[]): void {
404
- for (const module of modules) {
405
- if (module.section === undefined || !ROOT_SECTIONS.includes(module.name)) continue;
406
- const problem = "the composition root's own section, which boot is not handed, is read there";
407
- throw sectionPathRefused(
408
- `Module "${module.name}" has its section at ${module.name}: the composition root's own section, which boot is not handed. Give the module a section of another name.`,
409
- module.name,
410
- module.name,
411
- problem,
412
- );
413
- }
414
- }
415
-
416
- /**
417
- * Phase two: what `createApp` parses once, with every loaded module's schema:
418
- * the composition's own layers, the same read phase one had, over the
419
- * `reference.conf` of every package `modules` come from, core's last,
420
- * resolved and unparsed, with `core.sessionRequirements` — what the
421
- * composition expects under the MFA switch (`expectedSessionRequirements`) —
422
- * written over the resolved section when there is one to write, and the
423
- * `mfa` section and the `oauth` acr table as the template's MFA switch
424
- * decides them (`mfaSectionForBoot`, `oauthForBoot`). Left out: `adapters`
425
- * and `mfaMode`, the composition root's own keys, which phase one consumed.
426
- * A section a loaded package's `reference.conf` sets for a module the
427
- * composition does not load is handed on as resolved: boot tells it apart by
428
- * the configuration's defaults (`configDefaultsFor`).
429
- *
430
- * Refuses, each with a `BootError` (`bootRefusal.mts`), a module whose
431
- * section is a section of the composition root's
432
- * (`refuseModuleAtRootSections`), what
433
- * `expectedSessionRequirements` refuses of `core.sessionRequirements`, what
434
- * `mfaSectionForBoot` refuses of the `mfa` section, and the Redis intent
435
- * store left on its default key prefix where the grant store's was moved
436
- * (`refuseIntentPrefixLeftAtDefault`).
437
- *
438
- * Typed `AppConfig` because that is the `config` slot's type; read the parsed
439
- * configuration from `handle.components.config`, not from this.
440
- */
441
- export function resolveForBoot(
442
- own: OwnLayers,
443
- modules: readonly Module[],
444
- switches: Pick<Switches, "mfaMode" | "adapters">,
445
- ): AppConfig {
446
- refuseModuleAtRootSections(modules);
447
- const all = resolveLayers(own, moduleReferences(modules));
448
- refuseIntentPrefixLeftAtDefault(all, modules);
449
- const layered = Object.fromEntries(
450
- Object.entries(all).filter(([name]) => !ROOT_SECTIONS.includes(name)),
451
- );
452
- const { mfa: _decided, ...resolved } = layered;
453
- const mfa = mfaSectionForBoot({
454
- mode: switches.mfaMode,
455
- written: (own.config.toObject() as Record<string, unknown>).mfa,
456
- resolved: all,
457
- owned: ownsSection(modules, "mfa"),
458
- storeCalled:
459
- switches.adapters.userRepository === "http" || switches.adapters.mfaFactorStore === "store",
460
- env: own.env,
461
- });
462
- // `core` written as something other than a section is handed on as
463
- // written, for boot to refuse.
464
- const core = isPlainSection(resolved.core) ? resolved.core : undefined;
465
- const sessionRequirements =
466
- resolved.core === undefined || core !== undefined
467
- ? expectedSessionRequirements(core?.sessionRequirements, switches.mfaMode)
468
- : undefined;
469
- return {
470
- ...resolved,
471
- ...(mfa === undefined ? {} : { mfa }),
472
- ...(resolved.oauth === undefined
473
- ? {}
474
- : { oauth: oauthForBoot(switches.mfaMode, resolved.oauth) }),
475
- ...(sessionRequirements === undefined ? {} : { core: { ...core, sessionRequirements } }),
476
- } as unknown as AppConfig;
477
- }
478
-
479
- /**
480
- * The configuration's defaults, which boot names the sections nothing loaded
481
- * reads by (`bootstrapComponents.configDefaults`): the `reference.conf` of
482
- * every package `modules` come from, layered as `resolveForBoot` layers them
483
- * (core's last), with no file of the composition's and no environment.
484
- */
485
- export function configDefaultsFor(modules: readonly Module[]): Record<string, unknown> {
486
- return resolveLayers({ config: empty(), env: {} }, moduleReferences(modules));
487
- }
@@ -1,60 +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
- 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
- }
@@ -1,80 +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
- import type { AuditSink, Logger } from "@o3co/auth-provider-core";
17
- import { pino, stdSerializers } from "pino";
18
- import type { LoggingSettings } from "./sections.mjs";
19
-
20
- export type { LoggingSettings } from "./sections.mjs";
21
-
22
- /**
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".
29
- */
30
- export function createAppLogger(logging: LoggingSettings): Logger {
31
- return pino({
32
- name: "provider",
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 `{}`.
39
- serializers: { err: stdSerializers.err },
40
- });
41
- }
42
-
43
- /**
44
- * The stream the audit trail is written on: `name: "audit"`, newline-delimited
45
- * JSON on stdout in the same pino envelope as every other line.
46
- *
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.
52
- */
53
- export function createAuditLogger(): Logger {
54
- return pino({
55
- name: "audit",
56
- level: "info",
57
- serializers: { err: stdSerializers.err },
58
- });
59
- }
60
-
61
- /**
62
- * `AuditSink` writing each event through `auditLogger`: the `"logger"` sink
63
- * kind this template registers and ships as its default.
64
- *
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.
72
- */
73
- export function createLoggerAuditSink(auditLogger: Logger): AuditSink {
74
- return {
75
- kind: "logger",
76
- async record(event) {
77
- auditLogger.info({ audit: event }, event.type);
78
- },
79
- };
80
- }