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

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