@o3co/create-auth-provider 0.16.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 (90) hide show
  1. package/README.ja.md +66 -21
  2. package/README.md +76 -31
  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 +59 -26
  12. package/templates/standalone/Dockerfile +6 -2
  13. package/templates/standalone/README.ja.md +293 -162
  14. package/templates/standalone/README.md +533 -199
  15. package/templates/standalone/config/application.conf +196 -341
  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 +1 -0
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +19 -20
  28. package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +216 -141
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +48 -33
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +77 -58
  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 +656 -269
  38. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +425 -84
  39. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +91 -70
  40. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +7 -5
  41. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +22 -19
  42. package/templates/standalone/src/__tests__/google-session.fixture.mts +53 -42
  43. package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
  44. package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
  45. package/templates/standalone/src/__tests__/listen.test.mts +6 -13
  46. package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
  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 +4 -9
  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 +7 -11
  55. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
  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 +153 -142
  59. package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
  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 +163 -96
  65. package/templates/standalone/src/__tests__/smoke.test.mts +179 -252
  66. package/templates/standalone/src/__tests__/store-credential.test.mts +91 -50
  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 +64 -79
  73. package/templates/standalone/src/buildModules.mts +212 -251
  74. package/templates/standalone/src/configPath.mts +422 -29
  75. package/templates/standalone/src/listen.mts +12 -18
  76. package/templates/standalone/src/logger.mts +31 -60
  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 +458 -476
  80. package/templates/standalone/src/rootRenames.mts +79 -0
  81. package/templates/standalone/src/routes.mts +8 -14
  82. package/templates/standalone/src/sections.mts +366 -0
  83. package/templates/standalone/src/shutdown.mts +45 -123
  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
@@ -15,7 +15,6 @@
15
15
  */
16
16
  import path from "node:path";
17
17
  import {
18
- type AppConfig,
19
18
  consoleLogger,
20
19
  createAuditSinkFactory,
21
20
  createFederationTokenStoreFactory,
@@ -28,6 +27,7 @@ import {
28
27
  createKeyStoreFactory,
29
28
  createRepositoryFactories,
30
29
  defineModule,
30
+ federationsOf,
31
31
  type LifecycleRegistrar,
32
32
  type Logger,
33
33
  loggableError,
@@ -46,25 +46,29 @@ import {
46
46
  makeIoredisFederationGrantStoreClient,
47
47
  } from "@o3co/auth-provider-redis/ioredis";
48
48
  import { extractFederationSection } from "@o3co/auth-provider-session";
49
- // Named import is required here, not default. ioredis is CJS and its entry
50
- // does `module.exports = Redis` with the class re-exported as both `default`
51
- // and `Redis`; under `module: "nodenext"` with esModuleInterop the default
52
- // import therefore resolves to the module-exports namespace object rather
53
- // than the class, and `new Redis(...)` raises TS2351 "not constructable".
54
- // Still true on ioredis 6 — verified against 6.0.0, not inherited from the
55
- // ioredis 5 era this note was first written in.
56
- // The 11 default-import sites elsewhere in the repo all live under
57
- // `packages/redis/__tests__/` which `packages/redis/tsconfig.json`
58
- // explicitly excludes from strict tsc build (vitest's vue-tsc is more
59
- // permissive about CJS interop). Standalone production source is built
60
- // with strict nodenext, so the named import is the right shape here.
49
+ // Named import, not default: ioredis is CJS (`module.exports = Redis`, the
50
+ // class re-exported as both `default` and `Redis`), so under `module:
51
+ // "nodenext"` with esModuleInterop the default import resolves to the
52
+ // module-exports namespace object and `new Redis(...)` raises TS2351 "not
53
+ // constructable". Verified against ioredis 6.0.0. The repo's default-import
54
+ // sites live under `packages/redis/__tests__/`, which the strict tsc build
55
+ // excludes.
61
56
  import { Redis } from "ioredis";
62
57
  import { createAuditLogger, createLoggerAuditSink } from "./logger.mjs";
58
+ import {
59
+ type Adapters,
60
+ auditSinkSectionSchema,
61
+ httpSectionSchema,
62
+ inMemoryCodeRepositorySectionSchema,
63
+ keyStoreSectionSchema,
64
+ loggingSectionSchema,
65
+ redisClientsSectionSchema,
66
+ repositoriesSectionSchemaFor,
67
+ } from "./sections.mjs";
63
68
 
64
69
  /**
65
- * Helper: turn a v0.4.x { type, [type]: {...} } adapter-config slice into the
66
- * flat `{ type, ...rest }` shape consumed by AdapterFactory<T>.create().
67
- *
70
+ * Turn a `{ type, [type]: {...} }` adapter-config slice into the flat
71
+ * `{ type, ...rest }` shape `AdapterFactory<T>.create()` consumes.
68
72
  * Composition-root concern; not a library export.
69
73
  */
70
74
  function flattenAdapterConfig(
@@ -85,188 +89,266 @@ function flattenAdapterConfig(
85
89
  }
86
90
 
87
91
  /**
88
- * KeyStore module — provides the JWT signing KeyStore from config.
89
- *
90
- * Per A2-γ §4 worked example. Composition-root-local: the standalone template
91
- * uses the built-in local/jwks adapters; alternative deployments wire their
92
- * own KeyStore via a different module of the same shape.
92
+ * The template's `config/reference.conf`, resolved from this file, which sits
93
+ * one directory under the template's root in `src/` and in `dist/` alike.
94
+ */
95
+ const TEMPLATE_REFERENCE_HREF: string = new URL("../config/reference.conf", import.meta.url).href;
96
+
97
+ /**
98
+ * The template's `config/reference.conf`, which every module here that owns a
99
+ * section declares. A new `URL` per call: a shared one could be changed in place.
100
+ */
101
+ export function templateReference(): URL {
102
+ return new URL(TEMPLATE_REFERENCE_HREF);
103
+ }
104
+
105
+ /**
106
+ * Logging module: owns `logging {}`. It provides nothing: the logger is built
107
+ * before boot from this section (`readLogging`) and handed in as a bootstrap
108
+ * component, since the template logs while it chooses its modules.
109
+ * `LOG_LEVEL` is renamed after the path, `LOGGING_LEVEL`.
110
+ */
111
+ export const loggingModule = defineModule({
112
+ name: "logging",
113
+ section: {
114
+ schema: loggingSectionSchema,
115
+ reference: templateReference(),
116
+ renamedVariables: { LOG_LEVEL: "logging.level" },
117
+ },
118
+ });
119
+
120
+ /** What the host process reads of `http {}`, beside what the `httpSettings` slot carries. */
121
+ export interface HttpHostSettings {
122
+ /** The port the server listens on; `0` lets the OS pick one. */
123
+ readonly port: number;
124
+ /** The per-probe deadline of `/readyz` and the metrics scrape, in milliseconds. */
125
+ readonly readinessTimeoutMs: number;
126
+ }
127
+
128
+ declare module "@o3co/auth-provider-core" {
129
+ interface ComponentMap {
130
+ /** What the host process reads of `http {}`: provided by the `http` module. */
131
+ readonly httpHostSettings?: HttpHostSettings;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * HTTP module: owns `http {}`, its CORS list (`http.cors`) included, and
137
+ * provides core's `httpSettings`, authoritative, and the host's
138
+ * `httpHostSettings`, both eager: only `app.mts` reads the host's, and no
139
+ * module requires it. The list moved from `cors`, and `CORS_ALLOWED_ORIGINS`
140
+ * with it.
141
+ */
142
+ export const httpModule = defineModule({
143
+ name: "http",
144
+ section: {
145
+ schema: httpSectionSchema,
146
+ reference: templateReference(),
147
+ relocatedFrom: { cors: "cors" },
148
+ renamedVariables: { CORS_ALLOWED_ORIGINS: "cors.allowedOrigins" },
149
+ },
150
+ provides: {
151
+ httpSettings: ({ section }) =>
152
+ Object.freeze({
153
+ trustProxy: section.trustProxy,
154
+ cors: Object.freeze({ allowedOrigins: Object.freeze([...section.cors.allowedOrigins]) }),
155
+ }),
156
+ httpHostSettings: ({ section }): HttpHostSettings =>
157
+ Object.freeze({ port: section.port, readinessTimeoutMs: section.readinessTimeoutMs }),
158
+ },
159
+ authoritative: ["httpSettings"],
160
+ lifecycle: { httpSettings: { eager: true }, httpHostSettings: { eager: true } },
161
+ });
162
+
163
+ /**
164
+ * KeyStore module: owns `key-store {}` and provides the JWT signing KeyStore
165
+ * from it through the built-in local adapter. The section moved from
166
+ * `oauth.jwt.signingKey`, and the variables bound to it are renamed after
167
+ * their paths (`KEY_STORE_*`). Other deployments wire their own KeyStore
168
+ * through a module of the same shape.
93
169
  */
94
170
  export const keyStoreModule: Module = defineModule({
95
- name: "standalone:key-store",
96
- requires: ["config"] as const,
171
+ name: "key-store",
172
+ section: {
173
+ schema: keyStoreSectionSchema,
174
+ reference: templateReference(),
175
+ relocatedFrom: { "oauth.jwt.signingKey": "" },
176
+ renamedVariables: {
177
+ OAUTH_JWT_SIGNING_KEY_PROVIDER: "oauth.jwt.signingKey.provider",
178
+ OAUTH_JWT_ALGORITHM: "oauth.jwt.signingKey.local.algorithm",
179
+ OAUTH_JWT_KID: "oauth.jwt.signingKey.local.kid",
180
+ OAUTH_JWT_SECRET: "oauth.jwt.signingKey.local.secret",
181
+ OAUTH_JWT_PRIVATE_KEY_PATH: "oauth.jwt.signingKey.local.privateKeyPath",
182
+ OAUTH_JWT_PUBLIC_KEY_PATH: "oauth.jwt.signingKey.local.publicKeyPath",
183
+ OAUTH_JWT_PRIVATE_KEY: "oauth.jwt.signingKey.local.privateKey",
184
+ OAUTH_JWT_PUBLIC_KEY: "oauth.jwt.signingKey.local.publicKey",
185
+ },
186
+ },
97
187
  provides: {
98
- keyStore: async ({ config }) => {
188
+ keyStore: async ({ section }) => {
99
189
  const factory = createKeyStoreFactory();
100
190
  registerBuiltinKeyStores(factory);
101
- return factory.create(flattenAdapterConfig((config as AppConfig).oauth.jwt.signingKey));
191
+ return factory.create(flattenAdapterConfig(section));
102
192
  },
103
193
  },
104
194
  });
105
195
 
196
+ /** Which adapter fills each repository slot the `repositories` module provides. */
197
+ export interface RepositorySelection {
198
+ readonly client: Adapters["clientRepository"];
199
+ readonly user: Adapters["userRepository"];
200
+ }
201
+
106
202
  /**
107
- * Repositories module — provides client / user repositories from
108
- * `config.repositories.*` slices using the built-in adapter factories.
109
- *
110
- * Pre-OR-9 this module also provided `codeRepository` — split out into
111
- * `inMemoryCodeRepositoryModule` (memory branch) / `redisCodeRepositoryModule`
112
- * (redis branch, from `@o3co/auth-provider-redis`) so the
113
- * `oauth.code.adapter` switch can wire mutually-exclusive providers without
114
- * a slot collision. Same pattern as the Wave-5d userSessionStores split.
203
+ * `block`, the settings `repositories.<repository>.<adapter>` holds. The
204
+ * section's schema (`repositoriesSectionSchemaFor`) has refused a selected
205
+ * `static` without its block, so an absent one is a composition that built
206
+ * the section otherwise: an `Error` naming its path.
115
207
  */
116
- export const repositoriesModule: Module = defineModule({
117
- name: "standalone:repositories",
118
- requires: ["config"] as const,
119
- // D-5 / OR-2 / IH-11: forward `lifecycleRegistrar` into the repository
120
- // factories so client/user adapters can register disposal callbacks
121
- // (e.g. file-watch closers). Without this, builders' `ctx.lifecycle?.register`
122
- // is a no-op and the leaks remain.
123
- optional: ["lifecycleRegistrar"] as const,
124
- provides: {
125
- clientRepository: async ({ config, lifecycleRegistrar }) => {
126
- const ctx = { lifecycle: lifecycleRegistrar };
127
- const { clientFactory, userFactory } = createRepositoryFactories(ctx);
128
- registerBuiltinAdapters({ userFactory });
129
- const slice = flattenAdapterConfig(
130
- (config as AppConfig).repositories.client as { type: string } & Record<string, unknown>,
131
- );
132
- if (typeof slice.path === "string") {
133
- slice.path = path.resolve(process.cwd(), slice.path);
134
- }
135
- return clientFactory.create(slice);
208
+ function requiredBlock<B>(block: B | undefined, repository: string, adapter: string): B {
209
+ if (block === undefined) {
210
+ throw new Error(
211
+ `repositories.${repository}.${adapter}.path must be set when adapters.${repository}Repository is "${adapter}"`,
212
+ );
213
+ }
214
+ return block;
215
+ }
216
+
217
+ /**
218
+ * Repositories module: owns `repositories {}` and provides the client and
219
+ * user repositories the composition root's `adapters` select, through the
220
+ * built-in adapter factories: the YAML client registry, and the YAML or the
221
+ * Store's HTTP user repository (`@o3co/auth-provider-foundation`); core's
222
+ * `static`, an alias of `yaml`, reads a block of its own. The
223
+ * variables bound to its keys are renamed after their paths
224
+ * (`REPOSITORIES_*`). `codeRepository` comes from the in-process or the Redis
225
+ * code repository module instead.
226
+ */
227
+ export function repositoriesModuleFor(selection: RepositorySelection): Module {
228
+ return defineModule({
229
+ name: "repositories",
230
+ section: {
231
+ schema: repositoriesSectionSchemaFor(selection),
232
+ reference: templateReference(),
233
+ renamedVariables: {
234
+ CLIENT_PATH: "repositories.client.yaml.path",
235
+ CLIENT_USER_PATH: "repositories.user.yaml.path",
236
+ CLIENT_USER_AUTHENTICATE_URL: "repositories.user.http.authenticateUrl",
237
+ CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL: "repositories.user.http.authenticateByTokenUrl",
238
+ CLIENT_USER_LINK_FEDERATED_IDENTITY_URL: "repositories.user.http.linkFederatedIdentityUrl",
239
+ CLIENT_USER_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL:
240
+ "repositories.user.http.findSubjectByFederatedIdentityUrl",
241
+ CLIENT_USER_BEARER_TOKEN: "repositories.user.http.bearerToken",
242
+ CLIENT_USER_TIMEOUT: "repositories.user.http.timeout",
243
+ CLIENT_USER_MAX_RESPONSE_BYTES: "repositories.user.http.maxResponseBytes",
244
+ },
136
245
  },
137
- userRepository: async ({ config, lifecycleRegistrar }) => {
138
- const ctx = { lifecycle: lifecycleRegistrar };
139
- const { userFactory } = createRepositoryFactories(ctx);
140
- registerBuiltinAdapters({ userFactory });
141
- return userFactory.create(
142
- flattenAdapterConfig(
143
- (config as AppConfig).repositories.user as { type: string } & Record<string, unknown>,
144
- ),
145
- );
246
+ // Forwarded into the repository factories so client/user adapters can
247
+ // register disposal callbacks (file-watch closers, say). Without it, a
248
+ // builder's `ctx.lifecycle?.register` is a no-op and those resources leak.
249
+ optional: ["lifecycleRegistrar"] as const,
250
+ provides: {
251
+ clientRepository: async ({ section, lifecycleRegistrar }) => {
252
+ const { clientFactory } = createRepositoryFactories({ lifecycle: lifecycleRegistrar });
253
+ const file = requiredBlock(section.client[selection.client], "client", selection.client);
254
+ return clientFactory.create({
255
+ type: selection.client,
256
+ path: path.resolve(process.cwd(), file.path),
257
+ });
258
+ },
259
+ userRepository: async ({ section, lifecycleRegistrar }) => {
260
+ const { userFactory } = createRepositoryFactories({ lifecycle: lifecycleRegistrar });
261
+ registerBuiltinAdapters({ userFactory });
262
+ return userFactory.create(
263
+ flattenAdapterConfig({
264
+ type: selection.user,
265
+ [selection.user]: section.user[selection.user],
266
+ }),
267
+ );
268
+ },
146
269
  },
147
- },
148
- });
270
+ });
271
+ }
149
272
 
150
273
  /**
151
- * In-memory CodeRepository module — wired by `buildModules` only when
152
- * `oauth.code.adapter = "memory"`. The redis branch swaps in
274
+ * In-memory CodeRepository module, wired by `buildModules` only when
275
+ * `adapters.codeRepository = "memory"`; the Redis choice swaps in
153
276
  * `redisCodeRepositoryModule` from `@o3co/auth-provider-redis` (mutually
154
- * exclusive — both modules provide the same `codeRepository` slot).
155
- *
156
- * Per OR-9 (Wave 5d) split: pre-OR-9 the codeRepository slot was provided
157
- * inside `repositoriesModule` via the AdapterFactory pattern; that path
158
- * coupled the slot to `repositories.code.type` which is now superseded by
159
- * `oauth.code.adapter`.
277
+ * exclusive: both provide the `codeRepository` slot). It reads its own
278
+ * section, which moved from `repositories.code.memory`; the
279
+ * `repositories.code.redis` block is removed.
160
280
  */
161
281
  export const inMemoryCodeRepositoryModule: Module = defineModule({
162
- name: "standalone:in-memory-code-repository",
163
- // #455: the replica-safety guard reads this off the manifest. Before it
164
- // did, this module booted under `deployment.mode = "multi"` — the guard
165
- // keyed on core's module names and had never heard of this one.
282
+ name: "standalone-in-memory-code-repository",
283
+ section: {
284
+ schema: inMemoryCodeRepositorySectionSchema,
285
+ reference: templateReference(),
286
+ relocatedFrom: { "repositories.code.memory": "", "repositories.code.redis": null },
287
+ renamedVariables: {
288
+ CLIENT_CODE_DEFAULT_EXPIRES_IN: "repositories.code.memory.defaultExpiresIn",
289
+ CLIENT_CODE_ENDPOINT_URI: "repositories.code.redis.endpointUri",
290
+ CLIENT_CODE_PASSWORD: "repositories.code.redis.password",
291
+ },
292
+ },
293
+ // The replica-safety guard reads this off the manifest, not by module name.
166
294
  replicaSafety: {
167
295
  unsafe: true,
168
296
  reason:
169
297
  "authorization codes fork per replica — a code issued by the replica that served /authorize is unknown to the replica that receives the token request, so the exchange fails with invalid_grant everywhere but one replica, and a code redeemed on one replica can be redeemed again on another",
170
298
  },
171
- requires: ["config"] as const,
172
299
  optional: ["lifecycleRegistrar"] as const,
173
300
  provides: {
174
- codeRepository: async ({ config, lifecycleRegistrar }) => {
175
- const ctx = { lifecycle: lifecycleRegistrar };
176
- const { codeFactory } = createRepositoryFactories(ctx);
177
- // `codeFactory.register("memory", ...)` is wired automatically by
178
- // `createRepositoryFactories` itself (see core/repositories/RepositoryFactory.mts).
179
- // `registerBuiltinAdapters` from `@o3co/auth-provider-foundation` only
180
- // registers the HTTP user adapter on `userFactory`; it does NOT touch
181
- // `codeFactory`, so calling it here would be misleading and unnecessary.
182
- //
183
- // The flattened slice MAY still have `type = "redis"` when only the
184
- // legacy `repositories.code.type` is set — `buildModules`' adapter
185
- // resolution already chose memory by the time we get here, so
186
- // override the type explicitly to keep this module robust against
187
- // legacy HOCON shapes.
188
- const slice = flattenAdapterConfig(
189
- (config as AppConfig).repositories.code as { type: string } & Record<string, unknown>,
190
- );
191
- return codeFactory.create({ ...slice, type: "memory" });
301
+ codeRepository: async ({ section, lifecycleRegistrar }) => {
302
+ const { codeFactory } = createRepositoryFactories({ lifecycle: lifecycleRegistrar });
303
+ return codeFactory.create({ type: "memory", defaultExpiresIn: section.defaultExpiresIn });
192
304
  },
193
305
  },
194
306
  });
195
307
 
196
308
  /**
197
- * In-memory user-session stores module — provides the four-store
198
- * user-session split (userSessionStore, sessionRPRegistry,
199
- * sessionFamilyIndex, sessionFederationIndex). This module is wired by
200
- * `buildModules` only when `userSessionStores.adapter = "memory"`. The
201
- * Redis branch swaps in `redisSessionStoresModule` from
202
- * `@o3co/auth-provider-redis`.
203
- *
204
- * Pre-Wave-5d this module also provided `federationTokenStore` (now
205
- * extracted into a separate always-wired module so the redis session
206
- * branch doesn't drop the federation-token-store provider — Copilot
207
- * review on PR #121).
309
+ * In-memory user-session stores module: the four-store user-session split
310
+ * (userSessionStore, sessionRPRegistry, sessionFamilyIndex,
311
+ * sessionFederationIndex) and the subject-level revocation pair. Wired by
312
+ * `buildModules` only when `adapters.userSessionStores = "memory"`; the Redis
313
+ * branch swaps in `redisSessionStoresModule` from `@o3co/auth-provider-redis`.
208
314
  */
209
315
  export const inMemorySessionStoresModule: Module = defineModule({
210
- name: "standalone:in-memory-session-stores",
211
- // #455: the replica-safety guard reads this off the manifest. Before it
212
- // did, `DEPLOYMENT_MODE=multi` with `USER_SESSION_STORES_ADAPTER=memory`
213
- // booted — the guard keyed on core's `memorySessionStores`, and this
214
- // module provides the same six slots under a name it had never heard of.
316
+ name: "standalone-in-memory-session-stores",
215
317
  replicaSafety: {
216
318
  unsafe: true,
217
319
  reason:
218
- "user sessions, RP registrations, family indexes and the subject-level revocation pair fork per replica — back-channel logout reaches only the replica that received it, so a logged-out session stays valid on the others, and a credential change enumerates and watermarks only the replica that handled it (#321)",
320
+ "user sessions, RP registrations, family indexes and the subject-level revocation pair fork per replica — back-channel logout reaches only the replica that received it, so a logged-out session stays valid on the others, and a credential change enumerates and watermarks only the replica that handled it",
219
321
  },
220
322
  provides: {
221
323
  userSessionStore: () => createInMemoryUserSessionStore(),
222
324
  sessionRPRegistry: () => createInMemorySessionRPRegistry(),
223
325
  sessionFamilyIndex: () => createInMemorySessionFamilyIndex(),
224
326
  sessionFederationIndex: () => createInMemorySessionFederationIndex(),
225
- // #406: the two subject-level revocation slots. They were missing here
226
- // on both branches, so a scaffolded deployment got
227
- // `subjectRevocation: undefined` — `verifyJwt` skipped the watermark,
228
- // the #376 refresh gate was inert, and `revokeAllForSubject` reported
229
- // `unavailable`. Nothing failed and nothing warned, which is what #406
230
- // is about; the boot guard now refuses that state, and the scaffold's
231
- // answer is to wire them rather than to declare the capability absent.
232
- //
233
- // Single-process only, like every other store on this branch. The
234
- // redis branch swaps in `redisSessionStoresModule`, which has provided
235
- // both since #321 — before that there was no distributed adapter to
236
- // point at, which is why this gap outlived #296.
327
+ // The subject-level revocation slots. Without them `subjectRevocation`
328
+ // is undefined: `verifyJwt` skips the watermark, the refresh gate is
329
+ // inert and `revokeAllForSubject` reports `unavailable`, all silently.
330
+ // The boot guard refuses that state; the scaffold wires them rather than
331
+ // declaring the capability absent. Single-process only, like every
332
+ // other store on this branch.
237
333
  subjectSessionIndex: () => createInMemorySubjectSessionIndex(),
238
334
  subjectRevocation: () => createInMemorySubjectRevocation(),
239
335
  },
240
336
  });
241
337
 
242
338
  /**
243
- * In-memory federation token store module — wired by `buildModules` only
244
- * when `federationTokenStore.type = "memory"` (the default). The redis
245
- * branch swaps in `redisFederationTokenStoreModule` from
246
- * `@o3co/auth-provider-redis` (mutually exclusive — both provide the same
247
- * `federationTokenStore` slot), off the shared ioredis socket.
339
+ * In-memory federation token store module, wired by `buildModules` only when
340
+ * `adapters.federationTokenStore = "memory"` (the default). The redis branch
341
+ * swaps in `redisFederationTokenStoreModule` off the shared ioredis socket
342
+ * (mutually exclusive: both provide the `federationTokenStore` slot). One
343
+ * module per adapter, so the replica-safety guard can tell them apart and the
344
+ * Redis one can require its client. The slot is always wired, independent of
345
+ * the `adapters.userSessionStores` selection.
248
346
  *
249
- * Until #455 / #456 this was one module for both adapters
250
- * (`standalone:federation-token-store`), choosing at runtime through the
251
- * adapter factory. That shape failed twice over: the replica-safety guard
252
- * could not tell the memory store from the Redis one by name, so
253
- * `deployment.mode = "multi"` booted with the memory store; and the Redis
254
- * branch handed the builder no client, so `federationTokenStore.type =
255
- * "redis"` — documented in the README — was a boot failure rather than a
256
- * Redis-backed store. One module per adapter, like the session stores.
257
- *
258
- * The slot is always wired, independent of the `userSessionStores.adapter`
259
- * switch: pre-Wave-5d it was bundled into the larger `storesModule`, and the
260
- * redis session-stores branch dropped the provider entirely (Copilot review
261
- * on PR #121).
262
- *
263
- * The memory adapter still comes through core's factory, unchanged: that is
264
- * where its "dev/test only" boot warning lives.
347
+ * The memory adapter comes through core's factory: that is where its
348
+ * "dev/test only" boot warning lives.
265
349
  */
266
350
  export const inMemoryFederationTokenStoreModule: Module = defineModule({
267
- name: "standalone:in-memory-federation-token-store",
268
- // #455: read by the replica-safety guard. Before the split this module
269
- // carried one name for both adapters, so no name could have carried it.
351
+ name: "standalone-in-memory-federation-token-store",
270
352
  replicaSafety: {
271
353
  unsafe: true,
272
354
  reason:
@@ -282,75 +364,52 @@ export const inMemoryFederationTokenStoreModule: Module = defineModule({
282
364
  });
283
365
 
284
366
  /**
285
- * Audit-sink module — fills the `auditSink` slot every route that emits a
286
- * security event reads (#287).
287
- *
288
- * The slot is `optional` on `oauthModule`, `sessionModule` and `webauthnModule`,
289
- * and `emitAuditEvent` is a no-op when it is empty. That combination is why the
290
- * gap was invisible: the scaffold wired no sink, so `token.issued.failure`,
291
- * `authorize.rejected` / `authorize.granted`, `logout.cascade_failed` and the
292
- * shared rate-limit guard's `rate_limit.unavailable` were all discarded by the
293
- * deployable artifact, with nothing failing and nothing warning. This module is
294
- * always in the manifest for that reason — there is no "no audit sink" branch,
295
- * the way there is a memory/redis branch for the state stores.
296
- *
297
- * Two sink kinds are registered:
298
- *
299
- * - `"logger"` (the template's default) — one NDJSON line per event on stdout,
300
- * in the same pino envelope as every other line this template emits, on a
301
- * stream named `audit` whose level is fixed. See `createAuditLogger`.
302
- * - `"console"` — core's built-in, from `registerBuiltinAuditSinks`: the bare
303
- * event as one JSON object per line on stdout, with no log envelope. For a
304
- * pipeline that wants the event and nothing else.
305
- *
306
- * A deployment with a real sink (SIEM, log pipeline, message bus) registers its
307
- * builder here and names it in `audit.sink.type`; its options ride along in the
308
- * same config block (`flattenAdapterConfig` unwraps `sink { type = "x", x { … } }`).
367
+ * Audit-sink module: owns `audit-sink {}` and fills the `auditSink` slot every
368
+ * route that emits a security event reads, with the sink the composition
369
+ * root's `adapters.auditSink` names: the template's `"logger"` (its default)
370
+ * or one of core's built-ins, with that sink's options from
371
+ * `audit-sink.<name>`. The slot is `optional` on `oauthModule`,
372
+ * `sessionModule` and `webauthnModule`, and `emitAuditEvent` is a no-op when
373
+ * it is empty, so this module is always in the manifest; a name no builder is
374
+ * registered under refuses boot. The sink's options moved from `audit.sink`.
309
375
  *
310
- * One thing to know before writing such a builder: `createAuditSinkFactory()`
311
- * takes no `BuilderContext`, so a sink builder receives `{}` and the
312
- * `ctx.lifecycle?.register(…)` / `ctx.readiness?.register(…)` calls that
313
- * CONTRIBUTING.md requires of a builder opening a connection are silent no-ops
314
- * here. A sink holding a socket must own its cleanup another way until that
315
- * factory takes a context.
316
- *
317
- * There is deliberately no `"none"`. An unknown type — including the `"none"`
318
- * an operator might reasonably guess at — fails boot naming the sinks that
319
- * exist, rather than producing a deployment with no audit trail (#304).
376
+ * `createAuditSinkFactory()` takes no `BuilderContext`, so a sink builder
377
+ * receives `{}` and the `ctx.lifecycle?.register(…)` /
378
+ * `ctx.readiness?.register(…)` calls CONTRIBUTING.md requires of a builder
379
+ * opening a connection are silent no-ops: a sink holding a socket must own
380
+ * its cleanup another way.
320
381
  */
321
- export const auditSinkModule: Module = defineModule({
322
- name: "standalone:audit-sink",
323
- requires: ["config"] as const,
324
- provides: {
325
- auditSink: async ({ config }) => {
326
- const factory = createAuditSinkFactory();
327
- registerBuiltinAuditSinks(factory);
328
- factory.register("logger", () => createLoggerAuditSink(createAuditLogger()));
329
- const slice = (config as AppConfig).audit?.sink;
330
- // Absence lands on the default sink rather than on `undefined`: a
331
- // config that says nothing about auditing is not a config asking for
332
- // the events to be dropped. The literal default lives in HOCON
333
- // (`reference.conf` / `application.conf`); this is the floor under a
334
- // hand-built config that never met either.
335
- return factory.create(
336
- slice
337
- ? flattenAdapterConfig(slice as { type: string } & Record<string, unknown>)
338
- : { type: "logger" },
339
- );
382
+ export function auditSinkModuleFor(sink: string): Module {
383
+ return defineModule({
384
+ name: "audit-sink",
385
+ section: {
386
+ schema: auditSinkSectionSchema,
387
+ reference: templateReference(),
388
+ relocatedFrom: {
389
+ "audit.sink": { to: "", environmentVariable: null },
390
+ "audit.sink.type": null,
391
+ },
340
392
  },
341
- },
342
- });
393
+ provides: {
394
+ auditSink: async ({ section }) => {
395
+ const factory = createAuditSinkFactory();
396
+ registerBuiltinAuditSinks(factory);
397
+ factory.register("logger", () => createLoggerAuditSink(createAuditLogger()));
398
+ return factory.create(flattenAdapterConfig({ type: sink, [sink]: section?.[sink] }));
399
+ },
400
+ },
401
+ });
402
+ }
343
403
 
344
404
  /**
345
- * @deprecated since Wave 5d (F4 PR2): split into `inMemorySessionStoresModule`
346
- * + `inMemoryFederationTokenStoreModule`. Re-exported for backward
347
- * compatibility with consumers that imported `storesModule` from this file.
348
- * New code should use the two split modules directly.
405
+ * @deprecated Split into `inMemorySessionStoresModule` and
406
+ * `inMemoryFederationTokenStoreModule`; use those. Kept for consumers that
407
+ * imported `storesModule` from this file.
349
408
  */
350
409
  export const storesModule: Module = defineModule({
351
- name: "standalone:stores",
352
- // #455: everything this bundle provides lives in process memory, so a
353
- // composition still on it is refused under `deployment.mode = "multi"`
410
+ name: "stores",
411
+ // Everything this bundle provides lives in process memory, so a
412
+ // composition still on it is refused under `core.deployment.mode = "multi"`
354
413
  // like the split modules it stands in for.
355
414
  replicaSafety: {
356
415
  unsafe: true,
@@ -365,289 +424,231 @@ export const storesModule: Module = defineModule({
365
424
  });
366
425
 
367
426
  /**
368
- * Shared ioredis clients module — opens ONE long-lived ioredis connection
369
- * per replica, wraps it via `makeIoredisClients()` (returns 16 typed
370
- * per-purpose clients), and exposes the slots consumed by the standalone's
371
- * Redis-backed adapters (refresh-token-family + 4 user-session stores +
372
- * subject-level revocation index + watermark + rate limiter + code
373
- * repository + access-token denylist + replay seen-set + federation token
374
- * store + device-code store + WebAuthn challenge store + the consent stores),
375
- * and the two federation-grant stores' clients from their own factories.
376
- * Every store module `@o3co/auth-provider-redis` ships finds its client
377
- * slot here, including the ones this template does not select — pinned by
378
- * `all-modules-composition.multi.test.mts`.
379
- *
380
- * Per F4 PR1 (D-2 v2) + Wave 5d unification: the previous design opened a
381
- * separate ioredis socket per Redis-backed module (3+ sockets per replica).
382
- * This module consolidates them — `makeIoredisClients(io)` was always
383
- * designed to derive multiple per-purpose typed clients from a single
384
- * connection; using one socket realises that intent and minimises
385
- * connection-pool pressure on the upstream Redis.
427
+ * Shared ioredis clients module: opens ONE long-lived ioredis connection per
428
+ * replica and derives every per-purpose client from it (`makeIoredisClients`,
429
+ * plus the two federation-grant store clients from their own factories),
430
+ * rather than a socket per Redis-backed module, to keep connection-pool
431
+ * pressure off the upstream Redis. Every store module
432
+ * `@o3co/auth-provider-redis` ships finds its client slot here, including
433
+ * those this template does not select; `all-modules-composition.multi.test.mts`
434
+ * pins that.
386
435
  *
387
- * Connection-config: reuses `refreshTokenFamilyStore.redis.{url, password}`
388
- * (declared in `fullSectionsSchema` by D-2 v2 §1). No new top-level config
389
- * key is introduced — the connection is shared so its config is too. Future
390
- * splits (per-store distinct Redis instances) are operator-side via custom
391
- * composition roots, not standalone configuration.
436
+ * The connection's URL and password are the module's own section,
437
+ * `redis-clients`, which moved from `refreshTokenFamilyStore.redis` with its
438
+ * variables (`REDIS_CLIENTS_*`). Per-store Redis instances belong in a
439
+ * custom composition root. An empty URL throws rather than falling back to
440
+ * localhost. `io.quit()` is registered once with `lifecycleRegistrar`, so
441
+ * `handle.dispose()` closes the connection.
392
442
  *
393
- * Fails fast (throws) when the Redis URL is absent. The HOCON default in
394
- * `application.conf` covers single-instance / dev scenarios; an operator
395
- * who deliberately removes the section in production sees an explicit
396
- * error instead of a silent localhost fallback (the multi-replica failure
397
- * mode OR-1 was supposed to close).
398
- *
399
- * Lifecycle: registers `io.quit()` once with the boot-planner-pre-seeded
400
- * `lifecycleRegistrar` (D-5 ComponentMap slot) so `handle.dispose()` quits
401
- * the single connection cleanly.
402
- *
403
- * Conditional inclusion: `buildModules` only adds this module to the
404
- * manifest when at least one composed module needs a Redis client
405
- * (`usingRedisAnywhere` there): an adapter switch set to `"redis"` — the two
406
- * federation-grant store switches only while `federationGrants.enabled` —
407
- * and the refresh-token family store, which is Redis-backed unless a test
408
- * override replaces it. Adding the module unconditionally would open an
409
- * ioredis socket for memory-only compositions.
443
+ * `buildModules` adds this module only when some composed module needs a
444
+ * Redis client (`usingRedisAnywhere`), so memory-only compositions open no
445
+ * socket.
410
446
  */
411
447
  export const standaloneRedisClientsModule: Module = defineModule({
412
- name: "standalone:redis-clients",
413
- requires: ["config"] as const,
448
+ name: "redis-clients",
449
+ section: {
450
+ schema: redisClientsSectionSchema,
451
+ reference: templateReference(),
452
+ relocatedFrom: { "refreshTokenFamilyStore.redis": "" },
453
+ renamedVariables: {
454
+ REFRESH_TOKEN_FAMILY_STORE_REDIS_URL: "refreshTokenFamilyStore.redis.url",
455
+ REFRESH_TOKEN_FAMILY_STORE_REDIS_PASSWORD: "refreshTokenFamilyStore.redis.password",
456
+ },
457
+ },
414
458
  optional: ["lifecycleRegistrar", "readinessRegistrar", "logger"] as const,
415
459
  provides: {
416
460
  refreshTokenFamilyClient: async ({
417
- config,
461
+ section,
418
462
  lifecycleRegistrar,
419
463
  readinessRegistrar,
420
464
  logger,
421
465
  }) => {
422
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
466
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
423
467
  .refreshTokenFamilyClient;
424
468
  },
425
- userSessionStoreClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
426
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
469
+ userSessionStoreClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
470
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
427
471
  .userSessionStoreClient;
428
472
  },
429
- sessionRPRegistryClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
430
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
473
+ sessionRPRegistryClient: async ({
474
+ section,
475
+ lifecycleRegistrar,
476
+ readinessRegistrar,
477
+ logger,
478
+ }) => {
479
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
431
480
  .sessionRPRegistryClient;
432
481
  },
433
482
  sessionFamilyIndexClient: async ({
434
- config,
483
+ section,
435
484
  lifecycleRegistrar,
436
485
  readinessRegistrar,
437
486
  logger,
438
487
  }) => {
439
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
488
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
440
489
  .sessionFamilyIndexClient;
441
490
  },
442
491
  sessionFederationIndexClient: async ({
443
- config,
492
+ section,
444
493
  lifecycleRegistrar,
445
494
  readinessRegistrar,
446
495
  logger,
447
496
  }) => {
448
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
497
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
449
498
  .sessionFederationIndexClient;
450
499
  },
451
- // #593 slice 7: the grant store and the intent store, off the same
452
- // socket. Consumed by the Redis grant modules when their switches say
453
- // "redis"; provided whenever this module is installed, as every slot
454
- // here is, because a slot is cheap and the socket is the cost.
500
+ // The grant store and the intent store, consumed by the Redis grant
501
+ // modules when their switches say "redis". Provided whenever this module
502
+ // is installed, as every slot here is: a slot is cheap, the socket is
503
+ // the cost.
455
504
  federationGrantStoreClient: async ({
456
- config,
505
+ section,
457
506
  lifecycleRegistrar,
458
507
  readinessRegistrar,
459
508
  logger,
460
509
  }) => {
461
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
510
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
462
511
  .federationGrantStoreClient;
463
512
  },
464
513
  federationGrantIntentStoreClient: async ({
465
- config,
514
+ section,
466
515
  lifecycleRegistrar,
467
516
  readinessRegistrar,
468
517
  logger,
469
518
  }) => {
470
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
519
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
471
520
  .federationGrantIntentStoreClient;
472
521
  },
473
- // #484: the replay seen-set behind private_key_jwt client assertions.
474
- replaySeenSetClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
475
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
522
+ // The replay seen-set behind private_key_jwt client assertions.
523
+ replaySeenSetClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
524
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
476
525
  .replaySeenSetClient;
477
526
  },
478
- rateLimiterClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
479
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
527
+ rateLimiterClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
528
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
480
529
  .rateLimiterClient;
481
530
  },
482
- // OR-9: code-repository client wired off the same shared ioredis
483
- // socket. `redisCodeRepositoryModule` consumes this slot when
484
- // `oauth.code.adapter = "redis"`. Codes are short-TTL (60-600s)
485
- // high-volume — sharing the connection avoids opening a separate
486
- // socket while the per-purpose typed wrapper keeps the Redis
487
- // command surface consumed by RedisCodeRepository explicit.
488
- codeRepositoryClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
489
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
531
+ // `redisCodeRepositoryModule` consumes this slot when
532
+ // `adapters.codeRepository = "redis"`.
533
+ codeRepositoryClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
534
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
490
535
  .codeRepositoryClient;
491
536
  },
492
- // #277: access-token denylist client, off the same shared socket. A
493
- // denylist is only worth having if every replica reads the same one —
494
- // that is the entire reason `redisAccessTokenDenylistModule` exists — so
495
- // it belongs on the connection the rest of the shared state already uses
496
- // rather than opening a second one.
537
+ // A denylist is only worth having if every replica reads the same one,
538
+ // so it belongs on the connection the rest of the shared state uses
539
+ // rather than a second one.
497
540
  accessTokenDenylistClient: async ({
498
- config,
541
+ section,
499
542
  lifecycleRegistrar,
500
543
  readinessRegistrar,
501
544
  logger,
502
545
  }) => {
503
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
546
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
504
547
  .accessTokenDenylistClient;
505
548
  },
506
- // #321: the two subject-level revocation client slots, off the same
507
- // shared socket. `redisSessionStoresModule` requires both — the index
508
- // enumerates what a credential change cascades over and the watermark
509
- // refuses what the cascade missed — so without them the scaffold's
510
- // Redis branch had no provider for the slots and stage-1 boot refused
511
- // the manifest with `missing-required-component`. Nothing in the
512
- // standalone consumes these slots directly; they exist so the module
513
- // that does can be selected.
549
+ // The subject-level revocation pair, both required by
550
+ // `redisSessionStoresModule`: the index enumerates what a credential
551
+ // change cascades over, the watermark refuses what the cascade missed.
552
+ // Nothing in the standalone consumes them directly; they exist so that
553
+ // module can be selected.
514
554
  subjectSessionIndexClient: async ({
515
- config,
555
+ section,
516
556
  lifecycleRegistrar,
517
557
  readinessRegistrar,
518
558
  logger,
519
559
  }) => {
520
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
560
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
521
561
  .subjectSessionIndexClient;
522
562
  },
523
- subjectRevocationClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
524
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
563
+ subjectRevocationClient: async ({
564
+ section,
565
+ lifecycleRegistrar,
566
+ readinessRegistrar,
567
+ logger,
568
+ }) => {
569
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
525
570
  .subjectRevocationClient;
526
571
  },
527
- // #433: the device-code store client, off the same shared socket.
528
- // `redisDeviceCodeStoreModule` requires it. This template does not
529
- // mount the device grant, so nothing in `buildModules` selects that
530
- // module today; the slot is provided anyway, because a deployment that
531
- // adds `deviceGrantModule` to this manifest and picks the Redis store
532
- // must not hit the `missing-required-component` boot failure the
533
- // subject-level slots above already paid for (#439) — a Redis-branch
534
- // module whose client slot this module did not provide, caught only by
535
- // the umbrella e2e.
536
- deviceCodeStoreClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
537
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
572
+ // Required by `redisDeviceCodeStoreModule`. This template does not mount
573
+ // the device grant; the slot is provided anyway, so a deployment that
574
+ // adds `deviceGrantModule` with the Redis store is not refused at boot
575
+ // (`missing-required-component`) for a client slot nothing provided.
576
+ deviceCodeStoreClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
577
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
538
578
  .deviceCodeStoreClient;
539
579
  },
540
- // The WebAuthn challenge store's client, off the same shared socket, for
541
- // the reason the device-code slot above is here: this template does not
542
- // mount WebAuthn, and a deployment that adds `webauthnModule` with
543
- // `redisChallengeStoreModule` would otherwise be refused at boot for a
544
- // `challengeStoreClient` nothing provided.
545
- challengeStoreClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
546
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
580
+ // The WebAuthn challenge store's client, for the device-code slot's
581
+ // reason: this template does not mount WebAuthn, and a deployment that
582
+ // adds `webauthnModule` with `redisChallengeStoreModule` would otherwise
583
+ // be refused at boot for a `challengeStoreClient` nothing provided.
584
+ challengeStoreClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
585
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
547
586
  .challengeStoreClient;
548
587
  },
549
- // #561: the consent stores' clients, off the same shared socket.
550
- // `redisConsentStoreModule` requires both — it provides the consent
551
- // records and the parked requests together — and `buildModules` selects
552
- // it under `consentStore.adapter = "redis"`.
553
- consentStoreClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
554
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
588
+ // The consent stores' clients. `redisConsentStoreModule` requires both
589
+ // (it provides the consent records and the parked requests together),
590
+ // and `buildModules` selects it under `adapters.consentStore = "redis"`.
591
+ consentStoreClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
592
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
555
593
  .consentStoreClient;
556
594
  },
557
595
  pendingConsentStoreClient: async ({
558
- config,
596
+ section,
559
597
  lifecycleRegistrar,
560
598
  readinessRegistrar,
561
599
  logger,
562
600
  }) => {
563
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
601
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
564
602
  .pendingConsentStoreClient;
565
603
  },
566
- // #456: the federation-token-store client, off the same shared socket.
567
- // `redisFederationTokenStoreModule` requires it, and nothing provided
568
- // it — which is why the Redis branch of the federation store could not
569
- // boot in this template while the README said it could.
604
+ // Required by `redisFederationTokenStoreModule`.
570
605
  federationTokenStoreClient: async ({
571
- config,
606
+ section,
572
607
  lifecycleRegistrar,
573
608
  readinessRegistrar,
574
609
  logger,
575
610
  }) => {
576
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
611
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
577
612
  .federationTokenStoreClient;
578
613
  },
614
+ // The two MFA stores' clients, required by `redisMfaFactorStoreModule`
615
+ // and `redisMfaTransactionStoreModule`, which `buildModules` selects
616
+ // with MFA on and `adapters.mfaFactorStore` / `.mfaTransactionStore`
617
+ // = "redis". Each module checks the server's eviction policy and
618
+ // persistence when it boots.
619
+ mfaFactorStoreClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
620
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
621
+ .mfaFactorStoreClient;
622
+ },
623
+ mfaTransactionStoreClient: async ({
624
+ section,
625
+ lifecycleRegistrar,
626
+ readinessRegistrar,
627
+ logger,
628
+ }) => {
629
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
630
+ .mfaTransactionStoreClient;
631
+ },
579
632
  },
580
633
  });
581
634
 
582
635
  /**
583
- * Failure-timing options for the one shared ioredis socket (#286).
584
- *
585
- * On the driver's defaults this connection had no `commandTimeout` at all, so
586
- * a partition did not produce errors — it produced *waiting*. Every `/token`
587
- * request that touched Redis parked in the offline queue while the socket
588
- * reconnected, and because nothing threw, the `rateLimit.failMode = "closed"`
589
- * policy (`createRateLimitGuard`, OR-5) never engaged: it only fires when
590
- * `limiter.check()` rejects. The load the policy exists to shed kept arriving
591
- * and kept accumulating, and memory and sockets grew until ioredis's 20th
592
- * reconnect attempt finally flushed the queue — tens of seconds later.
593
- *
594
- * The values below are chosen against the actual command surface of this
595
- * socket, which is entirely O(1) primitives and small paged scans
596
- * (`SET`/`GET`/`DEL`/`EXISTS`/`PTTL`/`INCR`/`EVAL`/`HSCAN`/`SSCAN`/`ZADD`/…).
597
- * There is no blocking command here, so no legitimate command has any reason
598
- * to take a meaningful fraction of a second.
636
+ * Failure-timing options for the one shared ioredis socket. Each value's
637
+ * reason, and the trade the offline queue buys, is in
638
+ * `docs/operator-runbook.md`, "Failure timing on the shared socket". In
639
+ * short: on the driver's defaults a partition produces *waiting*, not errors,
640
+ * so the Redis limiter's `redis-rate-limiter.failMode = "closed"` policy, which the
641
+ * guard (`createRateLimitGuard`) applies only when `limiter.check()` rejects,
642
+ * never engages.
643
+ * `commandTimeout` bounds every command, queued or on the wire, zombie socket
644
+ * included; `maxRetriesPerRequest` bounds the offline queue's depth; the
645
+ * queue stays on because the option is per connection and this socket also
646
+ * carries sessions, codes and refresh rotation.
599
647
  *
600
- * - `commandTimeout: 1000` — the hard ceiling on any single command. ioredis
601
- * arms this timer in `sendCommand`, *before* the writability check, so it
602
- * bounds a command that went into the offline queue exactly as it bounds one
603
- * already on the wire. That is what makes the queued case fail rather than
604
- * hang, and it is also the only guard that survives the nastiest partition
605
- * shape: a zombie TCP connection where no `close` event ever fires, so the
606
- * reconnect path described next is never entered at all.
607
- * - `maxRetriesPerRequest: 3` — on the fourth reconnect attempt (roughly
608
- * 0.4–1 s in on ioredis 6's jittered exponential backoff) the whole queue is
609
- * failed in one go with `MaxRetriesPerRequestError`, rather than on the
610
- * twentieth as the default has it. `commandTimeout` alone bounds each command's
611
- * *latency*, but the queue would still grow to (request rate × 1 s) entries
612
- * for as long as the outage lasted; this bounds its *depth*. Three rather
613
- * than one so an ordinary sub-second reconnect blip is ridden out silently.
614
- * - `connectTimeout: 5000` — halves the driver's 10 s default. A dropped SYN
615
- * (a partition that black-holes packets rather than refusing them) is the
616
- * only case that reaches this ceiling; a real connect on any deployment
617
- * topology this template targets completes in well under a second.
618
- * - `enableOfflineQueue: true` — the driver default, declared explicitly
619
- * because it is a decision, not an inherited value. See below.
620
- * - `lazyConnect: false` — also the driver default (still, on ioredis 6),
621
- * declared so the boot-time-connect contract survives a future flip.
622
- *
623
- * **Why the offline queue stays on.** Turning it off is the sharper answer for
624
- * the rate limiter specifically: a command issued while the socket is down
625
- * rejects immediately instead of after `commandTimeout`, so the fail-closed
626
- * policy sheds load at the first request rather than one second per request
627
- * later. But `enableOfflineQueue` is a per-**connection** option, and this
628
- * template runs every purpose off one socket — refresh-token families, the
629
- * four user-session stores, the authorization-code repository, the
630
- * access-token denylist and the rate limiter all come out of a single
631
- * `makeIoredisClients(io)` call (pinned by "one connection in, one connection
632
- * used" in `packages/redis/__tests__/ioredis.test.mts`). There is no way to
633
- * express "off here, on there" without opening a second socket, which is the
634
- * connection-pool pressure the shared-socket design was built to avoid.
635
- *
636
- * So the choice is one setting for all eleven purposes, and `false` is the
637
- * wrong one to pick for all eleven: it converts every reconnect — including
638
- * the sub-second blip of a routine managed-Redis failover, during which
639
- * ioredis would have recovered transparently — into hard errors on session
640
- * lookup, code redemption and refresh rotation. A failed refresh rotation is
641
- * not a retryable blip to the end user; it is a re-login.
642
- *
643
- * `true` costs the rate limiter up to `commandTimeout` of delay before it
644
- * starts shedding, and that is the honest trade. It is affordable precisely
645
- * because `commandTimeout` exists: the pile-up is now bounded by
646
- * (request rate × 1 s) and self-draining, rather than unbounded and growing.
647
- * A deployment that wants the sharper behavior gives the rate limiter its own
648
- * connection in its own composition root — the per-purpose client interfaces
649
- * exist for exactly that, and it is a deliberate second socket rather than a
650
- * silent global.
648
+ * One second is ample: the socket's commands are O(1) primitives and small
649
+ * paged scans (`SET`/`GET`/`DEL`/`EXISTS`/`PTTL`/`INCR`/`EVAL`/`HSCAN`/
650
+ * `SSCAN`/`ZADD`/…), none blocking. `lazyConnect: false` is the driver
651
+ * default, declared so the boot-time-connect contract survives a future flip.
651
652
  */
652
653
  const SHARED_REDIS_TIMEOUTS = {
653
654
  commandTimeout: 1_000,
@@ -657,26 +658,12 @@ const SHARED_REDIS_TIMEOUTS = {
657
658
  lazyConnect: false,
658
659
  } as const;
659
660
 
660
- // Module-scoped cache so each `provides.*` factory invocation reuses the
661
- // same `Redis` instance (and its `makeIoredisClients()` derivation) within
662
- // a single createApp() invocation. The boot planner calls each `provides`
663
- // in dependency order; without this, every consumed slot would create its
664
- // own Redis socket, defeating the unification purpose.
665
- //
666
- // Cache key: the `lifecycleRegistrar` IDENTITY (which is per-boot — each
667
- // `createApp()` invocation seeds a fresh registrar via the boot planner).
668
- // Keying solely on `config` would incorrectly share connections across
669
- // boots when the same config object is reused with a new registrar (and
670
- // only the FIRST boot's registrar would receive disposal — Copilot review
671
- // on PR #121). When `lifecycleRegistrar` is undefined (test scenarios
672
- // that don't seed it), each call creates a fresh client; tests are
673
- // isolated and don't need cross-slot sharing.
674
661
  /**
675
662
  * Every per-purpose client off the one socket: the general bundle, plus the
676
- * two the federation-grant stores need (#593 slice 7). Those two have their
677
- * own factories in the Redis package, so a Cluster deployment can give the
678
- * grants a connection of their own; this template shares the socket, as it
679
- * does for every other store.
663
+ * two the federation-grant stores need. Those two have their own factories in
664
+ * the Redis package, so a Cluster deployment can give the grants a
665
+ * connection of their own; this template shares the socket, as it does for
666
+ * every other store.
680
667
  */
681
668
  type StandaloneRedisClients = ReturnType<typeof makeIoredisClients> & {
682
669
  readonly federationGrantStoreClient: ReturnType<typeof makeIoredisFederationGrantStoreClient>;
@@ -684,9 +671,15 @@ type StandaloneRedisClients = ReturnType<typeof makeIoredisClients> & {
684
671
  typeof makeIoredisFederationGrantIntentStoreClient
685
672
  >;
686
673
  };
674
+ // So every `provides.*` factory reuses one `Redis` instance (and its clients)
675
+ // within a createApp() call, rather than opening a socket per consumed slot.
676
+ // Keyed on the `lifecycleRegistrar` identity, fresh per boot: keying on
677
+ // `config` would share connections across boots that reuse a config object,
678
+ // and only the first boot's registrar would dispose them. Without a
679
+ // registrar (tests that seed none), each call creates a fresh client.
687
680
  const clientsCache = new WeakMap<LifecycleRegistrar, StandaloneRedisClients>();
688
681
  function getOrCreateClients(
689
- config: AppConfig,
682
+ section: { readonly url: string; readonly password?: string | undefined },
690
683
  lifecycleRegistrar: LifecycleRegistrar | undefined,
691
684
  readinessRegistrar?: ReadinessRegistrar,
692
685
  injectedLogger?: Logger,
@@ -695,18 +688,16 @@ function getOrCreateClients(
695
688
  const cached = lifecycleRegistrar ? clientsCache.get(lifecycleRegistrar) : undefined;
696
689
  if (cached) return cached;
697
690
 
698
- const cfg = config.refreshTokenFamilyStore?.redis;
699
- if (typeof cfg?.url !== "string" || cfg.url.length === 0) {
691
+ if (section.url.length === 0) {
700
692
  throw new Error(
701
- "standaloneRedisClientsModule: `refreshTokenFamilyStore.redis.url` is required when any " +
702
- "Redis-backed adapter is selected. Set REFRESH_TOKEN_FAMILY_STORE_REDIS_URL or " +
703
- "restore the `refreshTokenFamilyStore.redis` block in application.conf. Multi-replica " +
704
- "deployments require a shared Redis 7.2+ instance.",
693
+ "standaloneRedisClientsModule: `redis-clients.url` is required when any " +
694
+ "Redis-backed adapter is selected. Set REDIS_CLIENTS_URL, or the " +
695
+ "key in a configuration layer, to a non-empty URL. Multi-replica deployments require " +
696
+ "a shared Redis 7.2+ instance.",
705
697
  );
706
698
  }
707
- const password = typeof cfg.password === "string" ? cfg.password : undefined;
708
699
 
709
- const io = new Redis(cfg.url, { password, ...SHARED_REDIS_TIMEOUTS });
700
+ const io = new Redis(section.url, { password: section.password, ...SHARED_REDIS_TIMEOUTS });
710
701
 
711
702
  // Attach an error handler so unhandled "error" events do not crash the
712
703
  // process. Initial connection failures surface here; downstream adapter
@@ -721,9 +712,9 @@ function getOrCreateClients(
721
712
  await io.quit();
722
713
  });
723
714
 
724
- // One probe for the one socket. Registering per consumed slot would report
725
- // the same connection six times; the cache above means this runs once per
726
- // boot, on the call that actually constructs the client.
715
+ // One probe for the one socket, not one per consumed slot: the cache
716
+ // above means this runs once per boot, on the call that constructs the
717
+ // client.
727
718
  readinessRegistrar?.register({
728
719
  name: "redis",
729
720
  check: () => io.ping(),
@@ -742,13 +733,11 @@ function getOrCreateClients(
742
733
  }
743
734
 
744
735
  /**
745
- * Reads an optional string field off a federation slice.
746
- *
747
- * A field the operator did not set stays absent rather than becoming
748
- * `undefined`, so `"sessionDomain" in config` still distinguishes the two. A
749
- * field that is present but not a string throws: HOCON hands through whatever
750
- * shape the file holds, and silently ignoring `sessionDomain = 42` would leave
751
- * the redirect policy running with one fewer constraint than the operator
736
+ * Reads an optional string field off a federation slice. An unset field stays
737
+ * absent rather than becoming `undefined`, so `"sessionDomain" in config`
738
+ * still distinguishes the two. A present non-string throws: HOCON hands
739
+ * through whatever the file holds, and silently ignoring `sessionDomain = 42`
740
+ * would run the redirect policy with one fewer constraint than the operator
752
741
  * wrote down.
753
742
  */
754
743
  function optionalString(
@@ -758,20 +747,19 @@ function optionalString(
758
747
  const value = slice[field];
759
748
  if (value === undefined || value === null) return {};
760
749
  if (typeof value !== "string") {
761
- throw new Error(`federations.google.${field} must be a string when present`);
750
+ throw new Error(`core.federations.google.${field} must be a string when present`);
762
751
  }
763
752
  return { [field]: value };
764
753
  }
765
754
 
766
755
  /**
767
- * An optional boolean from the `federations.google` slice. HOCON gives a
768
- * boolean; an environment override (`${?VAR}`) gives the string, in the
769
- * spellings core's `coerceBooleanFromEnv` accepts: "true" / "false" / "1" /
770
- * "0", trimmed and in any case. One deliberate difference: that coercion reads
771
- * an empty value as false, and this refuses it. HOCON substitutes an
772
- * exported-but-empty variable as "", and for a security switch that must not
773
- * quietly turn the check off. Anything else is refused too — a typo that
774
- * silently reads as either value is worse than a boot failure.
756
+ * An optional boolean from the `core.federations.google` entry: a HOCON boolean, or
757
+ * from an environment override (`${?VAR}`) a string in the spellings core's
758
+ * `coerceBooleanFromEnv` accepts ("true" / "false" / "1" / "0", trimmed, any
759
+ * case). Unlike that coercion, an empty value is refused, not read as false:
760
+ * HOCON substitutes an exported-but-empty variable as "", and a security
761
+ * switch must not quietly turn the check off. Anything else is refused too,
762
+ * so a typo fails boot rather than reading as either value.
775
763
  */
776
764
  function optionalBoolean(
777
765
  slice: Record<string, unknown>,
@@ -786,12 +774,12 @@ function optionalBoolean(
786
774
  if (normalized === "false" || normalized === "0") return { [field]: false };
787
775
  }
788
776
  throw new Error(
789
- `federations.google.${field} must be one of true, false, "true", "false", "1" or "0" when present`,
777
+ `core.federations.google.${field} must be one of true, false, "true", "false", "1" or "0" when present`,
790
778
  );
791
779
  }
792
780
 
793
781
  /**
794
- * `accessType` from the `federations.google` slice: `"offline"` or `"online"`,
782
+ * `accessType` from the `core.federations.google` entry: `"offline"` or `"online"`,
795
783
  * exactly, or absent. `federation-google` refuses any other value too; this
796
784
  * refuses it first, naming the key, as the fields above do.
797
785
  */
@@ -801,35 +789,30 @@ function optionalAccessType(
801
789
  const value = slice.accessType;
802
790
  if (value === undefined || value === null) return {};
803
791
  if (value === "offline" || value === "online") return { accessType: value };
804
- throw new Error('federations.google.accessType must be "offline" or "online" when present');
792
+ throw new Error('core.federations.google.accessType must be "offline" or "online" when present');
805
793
  }
806
794
 
807
795
  /**
808
- * Google federation config bridge — supplies the typed `googleFederationConfig`
809
- * ComponentMap slot from the `config.federations.google` slice.
810
- *
811
- * Per `@o3co/auth-provider-federation-google` README. Per-federation modules
812
- * (Phase 7 A5) consume this slot; the bridge is the standalone composition
813
- * root's responsibility because the slot's content is consumer-specific.
796
+ * Google federation config bridge: supplies the typed `googleFederationConfig`
797
+ * slot from the `core.federations.google` entry. The bridge is the
798
+ * composition root's responsibility because the slot's content is
799
+ * consumer-specific (see the `@o3co/auth-provider-federation-google` README).
814
800
  *
815
- * #278: this used to return `{ clientId, clientSecret, callbackURL }` and stop
816
- * there, dropping `redirectAllowlist` / `sessionDomain` / `authCallbackUrl` /
817
- * `clientUrl` on the floor. Nothing complained — `googleFederationModule` hands
818
- * this same object to `createFederationRedirectPolicy`, so the redirect policy
819
- * was built from a config with none of the fields it reads, and a deployment
820
- * that had configured them ran as though it had not. The generated app is the
821
- * shape most operators start from, so the safe path has to be the one that
822
- * works by filling in the config file.
801
+ * It carries every field the provider reads, not only the credentials:
802
+ * `googleFederationModule` hands this same object to
803
+ * `createFederationRedirectPolicy`, so a dropped `redirectAllowlist` /
804
+ * `sessionDomain` / `authCallbackUrl` / `clientUrl` would silently run the
805
+ * policy without the constraints the operator configured.
823
806
  */
824
807
  export const googleFederationConfigModule: Module = defineModule({
825
- name: "standalone:google-federation-config",
808
+ name: "google-federation-config",
826
809
  requires: ["config"] as const,
827
810
  provides: {
828
811
  googleFederationConfig: ({ config }): GoogleProviderConfig => {
829
- const slice = extractFederationSection((config as AppConfig).federations, "google");
812
+ const slice = extractFederationSection(federationsOf(config), "google");
830
813
  if (!slice) {
831
814
  throw new Error(
832
- "federations.google must be enabled with credentials when googleFederationModule is in the manifest",
815
+ "core.federations.google must be enabled with credentials when googleFederationModule is in the manifest",
833
816
  );
834
817
  }
835
818
  const clientId = slice.clientId;
@@ -841,7 +824,7 @@ export const googleFederationConfigModule: Module = defineModule({
841
824
  typeof callbackURL !== "string"
842
825
  ) {
843
826
  throw new Error(
844
- "federations.google requires clientId, clientSecret, callbackURL when enabled",
827
+ "core.federations.google requires clientId, clientSecret, callbackURL when enabled",
845
828
  );
846
829
  }
847
830
 
@@ -855,7 +838,7 @@ export const googleFederationConfigModule: Module = defineModule({
855
838
  if (rawAllowlist !== undefined && rawAllowlist !== null) {
856
839
  if (!Array.isArray(rawAllowlist) || rawAllowlist.some((e) => typeof e !== "string")) {
857
840
  throw new Error(
858
- "federations.google.redirectAllowlist must be a list of URL strings, " +
841
+ "core.federations.google.redirectAllowlist must be a list of URL strings, " +
859
842
  'e.g. ["https://app.example.com/welcome"]',
860
843
  );
861
844
  }
@@ -870,7 +853,7 @@ export const googleFederationConfigModule: Module = defineModule({
870
853
  ...optionalString(slice, "sessionDomain"),
871
854
  ...optionalString(slice, "authCallbackUrl"),
872
855
  ...optionalString(slice, "clientUrl"),
873
- // #597: absent means the provider's default, which is to require it.
856
+ // Absent means the provider's default, which is to require it.
874
857
  ...optionalBoolean(slice, "requireAuthorizationResponseIss"),
875
858
  // Absent means the provider's default, "offline": consent on every
876
859
  // sign-in, and a refresh token for every session.
@@ -881,19 +864,18 @@ export const googleFederationConfigModule: Module = defineModule({
881
864
  });
882
865
 
883
866
  /**
884
- * OIDC federation config bridge (#524) — supplies the `oidcFederationConfigs`
867
+ * OIDC federation config bridge: supplies the `oidcFederationConfigs`
885
868
  * slot every `oidcFederationModule(<name>)` in the manifest reads its entry
886
869
  * from. One bridge for all instances: `readOidcFederationConfigs` walks
887
- * `config.federations` and reads every enabled section of type `oidc`,
888
- * refusing a malformed field by `federations.<name>.<field>` at boot.
870
+ * `core.federations` and reads every enabled entry of type `oidc`,
871
+ * refusing a malformed field by `core.federations.<name>.<field>` at boot.
889
872
  * `buildModules` lists this module only when at least one such section
890
873
  * exists.
891
874
  */
892
875
  export const oidcFederationConfigModule: Module = defineModule({
893
- name: "standalone:oidc-federation-config",
876
+ name: "oidc-federation-config",
894
877
  requires: ["config"] as const,
895
878
  provides: {
896
- oidcFederationConfigs: ({ config }) =>
897
- readOidcFederationConfigs((config as AppConfig).federations),
879
+ oidcFederationConfigs: ({ config }) => readOidcFederationConfigs(federationsOf(config)),
898
880
  },
899
881
  });