@o3co/create-auth-provider 0.16.0 → 0.17.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) 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 -29
  12. package/templates/standalone/Dockerfile +6 -2
  13. package/templates/standalone/README.ja.md +324 -169
  14. package/templates/standalone/README.md +618 -208
  15. package/templates/standalone/config/application.conf +211 -352
  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 +418 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +63 -32
  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__/adapters.test.mts +419 -0
  28. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +287 -198
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +71 -35
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +148 -94
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +84 -81
  33. package/templates/standalone/src/__tests__/config-notices.test.mts +154 -0
  34. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  35. package/templates/standalone/src/__tests__/core-federations.test.mts +249 -0
  36. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  37. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +10 -15
  38. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +970 -288
  39. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +421 -86
  40. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +87 -72
  41. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +16 -8
  42. package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
  43. package/templates/standalone/src/__tests__/google-session.fixture.mts +97 -93
  44. package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
  45. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
  46. package/templates/standalone/src/__tests__/library-references.fixture.mts +188 -0
  47. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
  48. package/templates/standalone/src/__tests__/listen.test.mts +6 -13
  49. package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
  50. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  51. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +326 -0
  52. package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
  53. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
  54. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  55. package/templates/standalone/src/__tests__/mfa-switch.test.mts +825 -0
  56. package/templates/standalone/src/__tests__/own-modules.test.mts +1486 -0
  57. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +10 -14
  58. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
  59. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +116 -81
  60. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  61. package/templates/standalone/src/__tests__/replica-safety.test.mts +265 -146
  62. package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
  63. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +278 -103
  64. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  65. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
  66. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +69 -61
  67. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
  68. package/templates/standalone/src/__tests__/session-requirements.test.mts +382 -0
  69. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +270 -0
  70. package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
  71. package/templates/standalone/src/__tests__/smoke.test.mts +176 -258
  72. package/templates/standalone/src/__tests__/store-credential.test.mts +88 -52
  73. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  74. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +131 -102
  75. package/templates/standalone/src/__tests__/two-phase-config.test.mts +604 -0
  76. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +382 -0
  77. package/templates/standalone/src/adapters.mts +162 -0
  78. package/templates/standalone/src/app.mts +65 -79
  79. package/templates/standalone/src/bootRefusal.mts +117 -0
  80. package/templates/standalone/src/buildModules.mts +220 -309
  81. package/templates/standalone/src/configPath.mts +449 -29
  82. package/templates/standalone/src/listen.mts +12 -18
  83. package/templates/standalone/src/logger.mts +31 -60
  84. package/templates/standalone/src/metrics.mts +24 -47
  85. package/templates/standalone/src/mfaSwitch.mts +285 -0
  86. package/templates/standalone/src/modules.mts +423 -629
  87. package/templates/standalone/src/rootRenames.mts +96 -0
  88. package/templates/standalone/src/routes.mts +8 -14
  89. package/templates/standalone/src/sections.mts +367 -0
  90. package/templates/standalone/src/shutdown.mts +45 -123
  91. package/templates/standalone/tests/index.test.js +6 -5
  92. package/templates/standalone/tests/package-lock.json +1615 -2223
  93. package/templates/standalone/tests/package.json +2 -2
  94. package/templates/standalone/tests/vitest.config.mjs +26 -0
  95. package/templates/standalone/vitest.config.mts +8 -7
  96. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  97. package/templates/versions.json +18 -15
  98. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -135
  99. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -199
  100. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +0 -138
@@ -15,13 +15,10 @@
15
15
  */
16
16
  import path from "node:path";
17
17
  import {
18
- type AppConfig,
19
18
  consoleLogger,
20
19
  createAuditSinkFactory,
21
20
  createFederationTokenStoreFactory,
22
- createInMemorySessionFamilyIndex,
23
- createInMemorySessionFederationIndex,
24
- createInMemorySessionRPRegistry,
21
+ createInMemorySessionLifecycleStore,
25
22
  createInMemorySubjectRevocation,
26
23
  createInMemorySubjectSessionIndex,
27
24
  createInMemoryUserSessionStore,
@@ -37,34 +34,35 @@ import {
37
34
  registerBuiltinFederationTokenStores,
38
35
  registerBuiltinKeyStores,
39
36
  } from "@o3co/auth-provider-core";
40
- import type { GoogleProviderConfig } from "@o3co/auth-provider-federation-google";
41
- import { readOidcFederationConfigs } from "@o3co/auth-provider-federation-oidc";
42
37
  import { registerBuiltinAdapters } from "@o3co/auth-provider-foundation";
43
38
  import {
44
39
  makeIoredisClients,
45
40
  makeIoredisFederationGrantIntentStoreClient,
46
41
  makeIoredisFederationGrantStoreClient,
47
42
  } from "@o3co/auth-provider-redis/ioredis";
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.
43
+ // Named import, not default: ioredis is CJS (`module.exports = Redis`, the
44
+ // class re-exported as both `default` and `Redis`), so under `module:
45
+ // "nodenext"` with esModuleInterop the default import resolves to the
46
+ // module-exports namespace object and `new Redis(...)` raises TS2351 "not
47
+ // constructable". Verified against ioredis 6.0.0. The repo's default-import
48
+ // sites live under `packages/redis/__tests__/`, which the strict tsc build
49
+ // excludes.
61
50
  import { Redis } from "ioredis";
62
51
  import { createAuditLogger, createLoggerAuditSink } from "./logger.mjs";
52
+ import {
53
+ type Adapters,
54
+ auditSinkSectionSchema,
55
+ httpSectionSchema,
56
+ inMemoryCodeRepositorySectionSchema,
57
+ keyStoreSectionSchema,
58
+ loggingSectionSchema,
59
+ redisClientsSectionSchema,
60
+ repositoriesSectionSchemaFor,
61
+ } from "./sections.mjs";
63
62
 
64
63
  /**
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
- *
64
+ * Turn a `{ type, [type]: {...} }` adapter-config slice into the flat
65
+ * `{ type, ...rest }` shape `AdapterFactory<T>.create()` consumes.
68
66
  * Composition-root concern; not a library export.
69
67
  */
70
68
  function flattenAdapterConfig(
@@ -85,188 +83,265 @@ function flattenAdapterConfig(
85
83
  }
86
84
 
87
85
  /**
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.
86
+ * The template's `config/reference.conf`, resolved from this file, which sits
87
+ * one directory under the template's root in `src/` and in `dist/` alike.
88
+ */
89
+ const TEMPLATE_REFERENCE_HREF: string = new URL("../config/reference.conf", import.meta.url).href;
90
+
91
+ /**
92
+ * The template's `config/reference.conf`, which every module here that owns a
93
+ * section declares. A new `URL` per call: a shared one could be changed in place.
94
+ */
95
+ export function templateReference(): URL {
96
+ return new URL(TEMPLATE_REFERENCE_HREF);
97
+ }
98
+
99
+ /**
100
+ * Logging module: owns `logging {}`. It provides nothing: the logger is built
101
+ * before boot from this section (`readLogging`) and handed in as a bootstrap
102
+ * component, since the template logs while it chooses its modules.
103
+ * `LOG_LEVEL` is renamed after the path, `LOGGING_LEVEL`.
104
+ */
105
+ export const loggingModule = defineModule({
106
+ name: "logging",
107
+ section: {
108
+ schema: loggingSectionSchema,
109
+ reference: templateReference(),
110
+ renamedVariables: { LOG_LEVEL: "logging.level" },
111
+ },
112
+ });
113
+
114
+ /** What the host process reads of `http {}`, beside what the `httpSettings` slot carries. */
115
+ export interface HttpHostSettings {
116
+ /** The port the server listens on; `0` lets the OS pick one. */
117
+ readonly port: number;
118
+ /** The per-probe deadline of `/readyz` and the metrics scrape, in milliseconds. */
119
+ readonly readinessTimeoutMs: number;
120
+ }
121
+
122
+ declare module "@o3co/auth-provider-core" {
123
+ interface ComponentMap {
124
+ /** What the host process reads of `http {}`: provided by the `http` module. */
125
+ readonly httpHostSettings?: HttpHostSettings;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * HTTP module: owns `http {}`, its CORS list (`http.cors`) included, and
131
+ * provides core's `httpSettings`, authoritative, and the host's
132
+ * `httpHostSettings`, both eager: only `app.mts` reads the host's, and no
133
+ * module requires it. The list moved from `cors`, and `CORS_ALLOWED_ORIGINS`
134
+ * with it.
135
+ */
136
+ export const httpModule = defineModule({
137
+ name: "http",
138
+ section: {
139
+ schema: httpSectionSchema,
140
+ reference: templateReference(),
141
+ relocatedFrom: { cors: "cors" },
142
+ renamedVariables: { CORS_ALLOWED_ORIGINS: "cors.allowedOrigins" },
143
+ },
144
+ provides: {
145
+ httpSettings: ({ section }) =>
146
+ Object.freeze({
147
+ trustProxy: section.trustProxy,
148
+ cors: Object.freeze({ allowedOrigins: Object.freeze([...section.cors.allowedOrigins]) }),
149
+ }),
150
+ httpHostSettings: ({ section }): HttpHostSettings =>
151
+ Object.freeze({ port: section.port, readinessTimeoutMs: section.readinessTimeoutMs }),
152
+ },
153
+ authoritative: ["httpSettings"],
154
+ lifecycle: { httpSettings: { eager: true }, httpHostSettings: { eager: true } },
155
+ });
156
+
157
+ /**
158
+ * KeyStore module: owns `key-store {}` and provides the JWT signing KeyStore
159
+ * from it through the built-in local adapter. The section moved from
160
+ * `oauth.jwt.signingKey`, and the variables bound to it are renamed after
161
+ * their paths (`KEY_STORE_*`). Other deployments wire their own KeyStore
162
+ * through a module of the same shape.
93
163
  */
94
164
  export const keyStoreModule: Module = defineModule({
95
- name: "standalone:key-store",
96
- requires: ["config"] as const,
165
+ name: "key-store",
166
+ section: {
167
+ schema: keyStoreSectionSchema,
168
+ reference: templateReference(),
169
+ relocatedFrom: { "oauth.jwt.signingKey": "" },
170
+ renamedVariables: {
171
+ OAUTH_JWT_SIGNING_KEY_PROVIDER: "oauth.jwt.signingKey.provider",
172
+ OAUTH_JWT_ALGORITHM: "oauth.jwt.signingKey.local.algorithm",
173
+ OAUTH_JWT_KID: "oauth.jwt.signingKey.local.kid",
174
+ OAUTH_JWT_SECRET: "oauth.jwt.signingKey.local.secret",
175
+ OAUTH_JWT_PRIVATE_KEY_PATH: "oauth.jwt.signingKey.local.privateKeyPath",
176
+ OAUTH_JWT_PUBLIC_KEY_PATH: "oauth.jwt.signingKey.local.publicKeyPath",
177
+ OAUTH_JWT_PRIVATE_KEY: "oauth.jwt.signingKey.local.privateKey",
178
+ OAUTH_JWT_PUBLIC_KEY: "oauth.jwt.signingKey.local.publicKey",
179
+ },
180
+ },
97
181
  provides: {
98
- keyStore: async ({ config }) => {
182
+ keyStore: async ({ section }) => {
99
183
  const factory = createKeyStoreFactory();
100
184
  registerBuiltinKeyStores(factory);
101
- return factory.create(flattenAdapterConfig((config as AppConfig).oauth.jwt.signingKey));
185
+ return factory.create(flattenAdapterConfig(section));
102
186
  },
103
187
  },
104
188
  });
105
189
 
190
+ /** Which adapter fills each repository slot the `repositories` module provides. */
191
+ export interface RepositorySelection {
192
+ readonly client: Adapters["clientRepository"];
193
+ readonly user: Adapters["userRepository"];
194
+ }
195
+
106
196
  /**
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.
197
+ * `block`, the settings `repositories.<repository>.<adapter>` holds. The
198
+ * section's schema (`repositoriesSectionSchemaFor`) has refused a selected
199
+ * `static` without its block, so an absent one is a composition that built
200
+ * the section otherwise: an `Error` naming its path.
115
201
  */
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);
202
+ function requiredBlock<B>(block: B | undefined, repository: string, adapter: string): B {
203
+ if (block === undefined) {
204
+ throw new Error(
205
+ `repositories.${repository}.${adapter}.path must be set when adapters.${repository}Repository is "${adapter}"`,
206
+ );
207
+ }
208
+ return block;
209
+ }
210
+
211
+ /**
212
+ * Repositories module: owns `repositories {}` and provides the client and
213
+ * user repositories the composition root's `adapters` select, through the
214
+ * built-in adapter factories: the YAML client registry, and the YAML or the
215
+ * Store's HTTP user repository (`@o3co/auth-provider-foundation`); core's
216
+ * `static`, an alias of `yaml`, reads a block of its own. The
217
+ * variables bound to its keys are renamed after their paths
218
+ * (`REPOSITORIES_*`). `codeRepository` comes from the in-process or the Redis
219
+ * code repository module instead.
220
+ */
221
+ export function repositoriesModuleFor(selection: RepositorySelection): Module {
222
+ return defineModule({
223
+ name: "repositories",
224
+ section: {
225
+ schema: repositoriesSectionSchemaFor(selection),
226
+ reference: templateReference(),
227
+ renamedVariables: {
228
+ CLIENT_PATH: "repositories.client.yaml.path",
229
+ CLIENT_USER_PATH: "repositories.user.yaml.path",
230
+ CLIENT_USER_AUTHENTICATE_URL: "repositories.user.http.authenticateUrl",
231
+ CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL: "repositories.user.http.authenticateByTokenUrl",
232
+ CLIENT_USER_LINK_FEDERATED_IDENTITY_URL: "repositories.user.http.linkFederatedIdentityUrl",
233
+ CLIENT_USER_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL:
234
+ "repositories.user.http.findSubjectByFederatedIdentityUrl",
235
+ CLIENT_USER_BEARER_TOKEN: "repositories.user.http.bearerToken",
236
+ CLIENT_USER_TIMEOUT: "repositories.user.http.timeout",
237
+ CLIENT_USER_MAX_RESPONSE_BYTES: "repositories.user.http.maxResponseBytes",
238
+ },
136
239
  },
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
- );
240
+ // Forwarded into the repository factories so client/user adapters can
241
+ // register disposal callbacks (file-watch closers, say). Without it, a
242
+ // builder's `ctx.lifecycle?.register` is a no-op and those resources leak.
243
+ optional: ["lifecycleRegistrar"] as const,
244
+ provides: {
245
+ clientRepository: async ({ section, lifecycleRegistrar }) => {
246
+ const { clientFactory } = createRepositoryFactories({ lifecycle: lifecycleRegistrar });
247
+ const file = requiredBlock(section.client[selection.client], "client", selection.client);
248
+ return clientFactory.create({
249
+ type: selection.client,
250
+ path: path.resolve(process.cwd(), file.path),
251
+ });
252
+ },
253
+ userRepository: async ({ section, lifecycleRegistrar }) => {
254
+ const { userFactory } = createRepositoryFactories({ lifecycle: lifecycleRegistrar });
255
+ registerBuiltinAdapters({ userFactory });
256
+ return userFactory.create(
257
+ flattenAdapterConfig({
258
+ type: selection.user,
259
+ [selection.user]: section.user[selection.user],
260
+ }),
261
+ );
262
+ },
146
263
  },
147
- },
148
- });
264
+ });
265
+ }
149
266
 
150
267
  /**
151
- * In-memory CodeRepository module — wired by `buildModules` only when
152
- * `oauth.code.adapter = "memory"`. The redis branch swaps in
268
+ * In-memory CodeRepository module, wired by `buildModules` only when
269
+ * `adapters.codeRepository = "memory"`; the Redis choice swaps in
153
270
  * `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`.
271
+ * exclusive: both provide the `codeRepository` slot). It reads its own
272
+ * section, which moved from `repositories.code.memory`; the
273
+ * `repositories.code.redis` block is removed.
160
274
  */
161
275
  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.
276
+ name: "standalone-in-memory-code-repository",
277
+ section: {
278
+ schema: inMemoryCodeRepositorySectionSchema,
279
+ reference: templateReference(),
280
+ relocatedFrom: { "repositories.code.memory": "", "repositories.code.redis": null },
281
+ renamedVariables: {
282
+ CLIENT_CODE_DEFAULT_EXPIRES_IN: "repositories.code.memory.defaultExpiresIn",
283
+ CLIENT_CODE_ENDPOINT_URI: "repositories.code.redis.endpointUri",
284
+ CLIENT_CODE_PASSWORD: "repositories.code.redis.password",
285
+ },
286
+ },
287
+ // The replica-safety guard reads this off the manifest, not by module name.
166
288
  replicaSafety: {
167
289
  unsafe: true,
168
290
  reason:
169
291
  "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
292
  },
171
- requires: ["config"] as const,
172
293
  optional: ["lifecycleRegistrar"] as const,
173
294
  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" });
295
+ codeRepository: async ({ section, lifecycleRegistrar }) => {
296
+ const { codeFactory } = createRepositoryFactories({ lifecycle: lifecycleRegistrar });
297
+ return codeFactory.create({ type: "memory", defaultExpiresIn: section.defaultExpiresIn });
192
298
  },
193
299
  },
194
300
  });
195
301
 
196
302
  /**
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).
303
+ * In-memory user-session stores module: the user-session store, the session
304
+ * lifecycle store and the subject-level revocation pair. Wired by
305
+ * `buildModules` only when `adapters.userSessionStores = "memory"`; the Redis
306
+ * branch swaps in `redisSessionStoresModule` from `@o3co/auth-provider-redis`.
208
307
  */
209
308
  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.
309
+ name: "standalone-in-memory-session-stores",
215
310
  replicaSafety: {
216
311
  unsafe: true,
217
312
  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)",
313
+ "user sessions, the session lifecycle record 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, a close begun on one replica fences nothing on another, and a credential change enumerates and watermarks only the replica that handled it",
219
314
  },
220
315
  provides: {
221
316
  userSessionStore: () => createInMemoryUserSessionStore(),
222
- sessionRPRegistry: () => createInMemorySessionRPRegistry(),
223
- sessionFamilyIndex: () => createInMemorySessionFamilyIndex(),
224
- 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.
317
+ // The subject-level revocation slots. Without them `subjectRevocation`
318
+ // is undefined: `verifyJwt` skips the watermark, the refresh gate is
319
+ // inert and `revokeAllForSubject` reports `unavailable`, all silently.
320
+ // The boot guard refuses that state; the scaffold wires them rather than
321
+ // declaring the capability absent. Single-process only, like every
322
+ // other store on this branch.
237
323
  subjectSessionIndex: () => createInMemorySubjectSessionIndex(),
238
324
  subjectRevocation: () => createInMemorySubjectRevocation(),
325
+ // The session lifecycle's record, as the Redis branch provides it; read
326
+ // only where core's session lifecycle module is installed.
327
+ sessionLifecycleStore: () => createInMemorySessionLifecycleStore(),
239
328
  },
240
329
  });
241
330
 
242
331
  /**
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.
332
+ * In-memory federation token store module, wired by `buildModules` only when
333
+ * `adapters.federationTokenStore = "memory"` (the default). The redis branch
334
+ * swaps in `redisFederationTokenStoreModule` off the shared ioredis socket
335
+ * (mutually exclusive: both provide the `federationTokenStore` slot). One
336
+ * module per adapter, so the replica-safety guard can tell them apart and the
337
+ * Redis one can require its client. The slot is always wired, independent of
338
+ * the `adapters.userSessionStores` selection.
248
339
  *
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.
340
+ * The memory adapter comes through core's factory: that is where its
341
+ * "dev/test only" boot warning lives.
265
342
  */
266
343
  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.
344
+ name: "standalone-in-memory-federation-token-store",
270
345
  replicaSafety: {
271
346
  unsafe: true,
272
347
  reason:
@@ -282,372 +357,258 @@ export const inMemoryFederationTokenStoreModule: Module = defineModule({
282
357
  });
283
358
 
284
359
  /**
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:
360
+ * Audit-sink module: owns `audit-sink {}` and fills the `auditSink` slot every
361
+ * route that emits a security event reads, with the sink the composition
362
+ * root's `adapters.auditSink` names: the template's `"logger"` (its default)
363
+ * or one of core's built-ins, with that sink's options from
364
+ * `audit-sink.<name>`. The slot is `optional` on `oauthEndpointsModule`,
365
+ * `sessionModule` and `webauthnModule`, and `emitAuditEvent` is a no-op when
366
+ * it is empty, so this module is always in the manifest; a name no builder is
367
+ * registered under refuses boot. The sink's options moved from `audit.sink`.
298
368
  *
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 { … } }`).
309
- *
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).
369
+ * `createAuditSinkFactory()` takes no `BuilderContext`, so a sink builder
370
+ * receives `{}` and the `ctx.lifecycle?.register(…)` /
371
+ * `ctx.readiness?.register(…)` calls CONTRIBUTING.md requires of a builder
372
+ * opening a connection are silent no-ops: a sink holding a socket must own
373
+ * its cleanup another way.
320
374
  */
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
- );
375
+ export function auditSinkModuleFor(sink: string): Module {
376
+ return defineModule({
377
+ name: "audit-sink",
378
+ section: {
379
+ schema: auditSinkSectionSchema,
380
+ reference: templateReference(),
381
+ relocatedFrom: {
382
+ "audit.sink": { to: "", environmentVariable: null },
383
+ "audit.sink.type": null,
384
+ },
340
385
  },
341
- },
342
- });
343
-
344
- /**
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.
349
- */
350
- 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"`
354
- // like the split modules it stands in for.
355
- replicaSafety: {
356
- unsafe: true,
357
- reason:
358
- "user sessions, RP registrations, family indexes, the subject-level revocation pair and upstream federation tokens fork per replica — back-channel logout reaches only the replica that received it, so a logged-out session stays valid on the others",
359
- },
360
- requires: ["config"] as const,
361
- provides: {
362
- ...inMemorySessionStoresModule.provides,
363
- ...inMemoryFederationTokenStoreModule.provides,
364
- },
365
- });
386
+ provides: {
387
+ auditSink: async ({ section }) => {
388
+ const factory = createAuditSinkFactory();
389
+ registerBuiltinAuditSinks(factory);
390
+ factory.register("logger", () => createLoggerAuditSink(createAuditLogger()));
391
+ return factory.create(flattenAdapterConfig({ type: sink, [sink]: section?.[sink] }));
392
+ },
393
+ },
394
+ });
395
+ }
366
396
 
367
397
  /**
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.
386
- *
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.
398
+ * Shared ioredis clients module: opens ONE long-lived ioredis connection per
399
+ * replica and derives every per-purpose client from it (`makeIoredisClients`,
400
+ * plus the two federation-grant store clients from their own factories),
401
+ * rather than a socket per Redis-backed module, to keep connection-pool
402
+ * pressure off the upstream Redis. Every store module
403
+ * `@o3co/auth-provider-redis` ships finds its client slot here, including
404
+ * those this template does not select; `all-modules-composition.multi.test.mts`
405
+ * pins that.
392
406
  *
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).
407
+ * The connection's URL and password are the module's own section,
408
+ * `redis-clients`, which moved from `refreshTokenFamilyStore.redis` with its
409
+ * variables (`REDIS_CLIENTS_*`). Per-store Redis instances belong in a
410
+ * custom composition root. An empty URL throws rather than falling back to
411
+ * localhost. `io.quit()` is registered once with `lifecycleRegistrar`, so
412
+ * `handle.dispose()` closes the connection.
398
413
  *
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.
414
+ * `buildModules` adds this module only when some composed module needs a
415
+ * Redis client (`usingRedisAnywhere`), so memory-only compositions open no
416
+ * socket.
410
417
  */
411
418
  export const standaloneRedisClientsModule: Module = defineModule({
412
- name: "standalone:redis-clients",
413
- requires: ["config"] as const,
419
+ name: "redis-clients",
420
+ section: {
421
+ schema: redisClientsSectionSchema,
422
+ reference: templateReference(),
423
+ relocatedFrom: { "refreshTokenFamilyStore.redis": "" },
424
+ renamedVariables: {
425
+ REFRESH_TOKEN_FAMILY_STORE_REDIS_URL: "refreshTokenFamilyStore.redis.url",
426
+ REFRESH_TOKEN_FAMILY_STORE_REDIS_PASSWORD: "refreshTokenFamilyStore.redis.password",
427
+ },
428
+ },
414
429
  optional: ["lifecycleRegistrar", "readinessRegistrar", "logger"] as const,
415
430
  provides: {
416
431
  refreshTokenFamilyClient: async ({
417
- config,
432
+ section,
418
433
  lifecycleRegistrar,
419
434
  readinessRegistrar,
420
435
  logger,
421
436
  }) => {
422
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
437
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
423
438
  .refreshTokenFamilyClient;
424
439
  },
425
- userSessionStoreClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
426
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
440
+ userSessionStoreClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
441
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
427
442
  .userSessionStoreClient;
428
443
  },
429
- sessionRPRegistryClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
430
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
431
- .sessionRPRegistryClient;
432
- },
433
- sessionFamilyIndexClient: async ({
434
- config,
435
- lifecycleRegistrar,
436
- readinessRegistrar,
437
- logger,
438
- }) => {
439
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
440
- .sessionFamilyIndexClient;
441
- },
442
- sessionFederationIndexClient: async ({
443
- config,
444
- lifecycleRegistrar,
445
- readinessRegistrar,
446
- logger,
447
- }) => {
448
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
449
- .sessionFederationIndexClient;
450
- },
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.
444
+ // The grant store and the intent store, consumed by the Redis grant
445
+ // modules when their switches say "redis". Provided whenever this module
446
+ // is installed, as every slot here is: a slot is cheap, the socket is
447
+ // the cost.
455
448
  federationGrantStoreClient: async ({
456
- config,
449
+ section,
457
450
  lifecycleRegistrar,
458
451
  readinessRegistrar,
459
452
  logger,
460
453
  }) => {
461
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
454
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
462
455
  .federationGrantStoreClient;
463
456
  },
464
457
  federationGrantIntentStoreClient: async ({
465
- config,
458
+ section,
466
459
  lifecycleRegistrar,
467
460
  readinessRegistrar,
468
461
  logger,
469
462
  }) => {
470
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
463
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
471
464
  .federationGrantIntentStoreClient;
472
465
  },
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)
466
+ // The replay seen-set behind private_key_jwt client assertions.
467
+ replaySeenSetClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
468
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
476
469
  .replaySeenSetClient;
477
470
  },
478
- rateLimiterClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
479
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
471
+ rateLimiterClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
472
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
480
473
  .rateLimiterClient;
481
474
  },
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)
475
+ // `redisAttemptCounterModule` consumes this slot where a deployment installs it.
476
+ attemptCounterClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
477
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
478
+ .attemptCounterClient;
479
+ },
480
+ // `redisCodeRepositoryModule` consumes this slot when
481
+ // `adapters.codeRepository = "redis"`.
482
+ codeRepositoryClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
483
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
490
484
  .codeRepositoryClient;
491
485
  },
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.
486
+ // A denylist is only worth having if every replica reads the same one,
487
+ // so it belongs on the connection the rest of the shared state uses
488
+ // rather than a second one.
497
489
  accessTokenDenylistClient: async ({
498
- config,
490
+ section,
499
491
  lifecycleRegistrar,
500
492
  readinessRegistrar,
501
493
  logger,
502
494
  }) => {
503
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
495
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
504
496
  .accessTokenDenylistClient;
505
497
  },
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.
498
+ // The subject-level revocation pair, both required by
499
+ // `redisSessionStoresModule`: the index enumerates what a credential
500
+ // change cascades over, the watermark refuses what the cascade missed.
501
+ // Nothing in the standalone consumes them directly; they exist so that
502
+ // module can be selected.
514
503
  subjectSessionIndexClient: async ({
515
- config,
504
+ section,
516
505
  lifecycleRegistrar,
517
506
  readinessRegistrar,
518
507
  logger,
519
508
  }) => {
520
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
509
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
521
510
  .subjectSessionIndexClient;
522
511
  },
523
- subjectRevocationClient: async ({ config, lifecycleRegistrar, readinessRegistrar, logger }) => {
524
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
512
+ subjectRevocationClient: async ({
513
+ section,
514
+ lifecycleRegistrar,
515
+ readinessRegistrar,
516
+ logger,
517
+ }) => {
518
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
525
519
  .subjectRevocationClient;
526
520
  },
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)
521
+ // Required by `redisSessionStoresModule`, which builds the session
522
+ // lifecycle store from it once a module reads that store.
523
+ sessionLifecycleStoreClient: async ({
524
+ section,
525
+ lifecycleRegistrar,
526
+ readinessRegistrar,
527
+ logger,
528
+ }) => {
529
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
530
+ .sessionLifecycleStoreClient;
531
+ },
532
+ // Required by `redisDeviceCodeStoreModule`. This template does not mount
533
+ // the device grant; the slot is provided anyway, so a deployment that
534
+ // adds `deviceAuthorizationGrantModule` with the Redis store is not refused at boot
535
+ // (`missing-required-component`) for a client slot nothing provided.
536
+ deviceCodeStoreClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
537
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
538
538
  .deviceCodeStoreClient;
539
539
  },
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)
540
+ // The WebAuthn challenge store's client, for the device-code slot's
541
+ // reason: this template does not mount WebAuthn, and a deployment that
542
+ // adds `webauthnModule` with `redisChallengeStoreModule` would otherwise
543
+ // be refused at boot for a `challengeStoreClient` nothing provided.
544
+ challengeStoreClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
545
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
547
546
  .challengeStoreClient;
548
547
  },
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)
548
+ // The consent stores' clients. `redisConsentStoreModule` requires both
549
+ // (it provides the consent records and the parked requests together),
550
+ // and `buildModules` selects it under `adapters.consentStore = "redis"`.
551
+ consentStoreClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
552
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
555
553
  .consentStoreClient;
556
554
  },
557
555
  pendingConsentStoreClient: async ({
558
- config,
556
+ section,
559
557
  lifecycleRegistrar,
560
558
  readinessRegistrar,
561
559
  logger,
562
560
  }) => {
563
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
561
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
564
562
  .pendingConsentStoreClient;
565
563
  },
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.
564
+ // Required by `redisFederationTokenStoreModule`.
570
565
  federationTokenStoreClient: async ({
571
- config,
566
+ section,
572
567
  lifecycleRegistrar,
573
568
  readinessRegistrar,
574
569
  logger,
575
570
  }) => {
576
- return getOrCreateClients(config as AppConfig, lifecycleRegistrar, readinessRegistrar, logger)
571
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
577
572
  .federationTokenStoreClient;
578
573
  },
574
+ // The two MFA stores' clients, required by `redisMfaFactorStoreModule`
575
+ // and `redisMfaTransactionStoreModule`, which `buildModules` selects
576
+ // with MFA on and `adapters.mfaFactorStore` / `.mfaTransactionStore`
577
+ // = "redis". Each module checks the server's eviction policy and
578
+ // persistence when it boots.
579
+ mfaFactorStoreClient: async ({ section, lifecycleRegistrar, readinessRegistrar, logger }) => {
580
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
581
+ .mfaFactorStoreClient;
582
+ },
583
+ mfaTransactionStoreClient: async ({
584
+ section,
585
+ lifecycleRegistrar,
586
+ readinessRegistrar,
587
+ logger,
588
+ }) => {
589
+ return getOrCreateClients(section, lifecycleRegistrar, readinessRegistrar, logger)
590
+ .mfaTransactionStoreClient;
591
+ },
579
592
  },
580
593
  });
581
594
 
582
595
  /**
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.
599
- *
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.
596
+ * Failure-timing options for the one shared ioredis socket. Each value's
597
+ * reason, and the trade the offline queue buys, is in
598
+ * `docs/operator-runbook.md`, "Failure timing on the shared socket". In
599
+ * short: on the driver's defaults a partition produces *waiting*, not errors,
600
+ * so the Redis limiter's `redis-rate-limiter.failMode = "closed"` policy, which the
601
+ * guard (`createRateLimitGuard`) applies only when `limiter.check()` rejects,
602
+ * never engages.
603
+ * `commandTimeout` bounds every command, queued or on the wire, zombie socket
604
+ * included; `maxRetriesPerRequest` bounds the offline queue's depth; the
605
+ * queue stays on because the option is per connection and this socket also
606
+ * carries sessions, codes and refresh rotation.
622
607
  *
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.
608
+ * One second is ample: the socket's commands are O(1) primitives and small
609
+ * paged scans (`SET`/`GET`/`DEL`/`EXISTS`/`PTTL`/`INCR`/`EVAL`/`HSCAN`/
610
+ * `SSCAN`/`ZADD`/…), none blocking. `lazyConnect: false` is the driver
611
+ * default, declared so the boot-time-connect contract survives a future flip.
651
612
  */
652
613
  const SHARED_REDIS_TIMEOUTS = {
653
614
  commandTimeout: 1_000,
@@ -657,26 +618,12 @@ const SHARED_REDIS_TIMEOUTS = {
657
618
  lazyConnect: false,
658
619
  } as const;
659
620
 
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
621
  /**
675
622
  * 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.
623
+ * two the federation-grant stores need. Those two have their own factories in
624
+ * the Redis package, so a Cluster deployment can give the grants a
625
+ * connection of their own; this template shares the socket, as it does for
626
+ * every other store.
680
627
  */
681
628
  type StandaloneRedisClients = ReturnType<typeof makeIoredisClients> & {
682
629
  readonly federationGrantStoreClient: ReturnType<typeof makeIoredisFederationGrantStoreClient>;
@@ -684,9 +631,15 @@ type StandaloneRedisClients = ReturnType<typeof makeIoredisClients> & {
684
631
  typeof makeIoredisFederationGrantIntentStoreClient
685
632
  >;
686
633
  };
634
+ // So every `provides.*` factory reuses one `Redis` instance (and its clients)
635
+ // within a createApp() call, rather than opening a socket per consumed slot.
636
+ // Keyed on the `lifecycleRegistrar` identity, fresh per boot: keying on
637
+ // `config` would share connections across boots that reuse a config object,
638
+ // and only the first boot's registrar would dispose them. Without a
639
+ // registrar (tests that seed none), each call creates a fresh client.
687
640
  const clientsCache = new WeakMap<LifecycleRegistrar, StandaloneRedisClients>();
688
641
  function getOrCreateClients(
689
- config: AppConfig,
642
+ section: { readonly url: string; readonly password?: string | undefined },
690
643
  lifecycleRegistrar: LifecycleRegistrar | undefined,
691
644
  readinessRegistrar?: ReadinessRegistrar,
692
645
  injectedLogger?: Logger,
@@ -695,18 +648,16 @@ function getOrCreateClients(
695
648
  const cached = lifecycleRegistrar ? clientsCache.get(lifecycleRegistrar) : undefined;
696
649
  if (cached) return cached;
697
650
 
698
- const cfg = config.refreshTokenFamilyStore?.redis;
699
- if (typeof cfg?.url !== "string" || cfg.url.length === 0) {
651
+ if (section.url.length === 0) {
700
652
  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.",
653
+ "standaloneRedisClientsModule: `redis-clients.url` is required when any " +
654
+ "Redis-backed adapter is selected. Set REDIS_CLIENTS_URL, or the " +
655
+ "key in a configuration layer, to a non-empty URL. Multi-replica deployments require " +
656
+ "a shared Redis 7.2+ instance.",
705
657
  );
706
658
  }
707
- const password = typeof cfg.password === "string" ? cfg.password : undefined;
708
659
 
709
- const io = new Redis(cfg.url, { password, ...SHARED_REDIS_TIMEOUTS });
660
+ const io = new Redis(section.url, { password: section.password, ...SHARED_REDIS_TIMEOUTS });
710
661
 
711
662
  // Attach an error handler so unhandled "error" events do not crash the
712
663
  // process. Initial connection failures surface here; downstream adapter
@@ -721,9 +672,9 @@ function getOrCreateClients(
721
672
  await io.quit();
722
673
  });
723
674
 
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.
675
+ // One probe for the one socket, not one per consumed slot: the cache
676
+ // above means this runs once per boot, on the call that constructs the
677
+ // client.
727
678
  readinessRegistrar?.register({
728
679
  name: "redis",
729
680
  check: () => io.ping(),
@@ -740,160 +691,3 @@ function getOrCreateClients(
740
691
  if (lifecycleRegistrar) clientsCache.set(lifecycleRegistrar, clients);
741
692
  return clients;
742
693
  }
743
-
744
- /**
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
752
- * wrote down.
753
- */
754
- function optionalString(
755
- slice: Record<string, unknown>,
756
- field: string,
757
- ): Record<string, string> | Record<string, never> {
758
- const value = slice[field];
759
- if (value === undefined || value === null) return {};
760
- if (typeof value !== "string") {
761
- throw new Error(`federations.google.${field} must be a string when present`);
762
- }
763
- return { [field]: value };
764
- }
765
-
766
- /**
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.
775
- */
776
- function optionalBoolean(
777
- slice: Record<string, unknown>,
778
- field: string,
779
- ): Record<string, boolean> | Record<string, never> {
780
- const value = slice[field];
781
- if (value === undefined || value === null) return {};
782
- if (typeof value === "boolean") return { [field]: value };
783
- if (typeof value === "string") {
784
- const normalized = value.trim().toLowerCase();
785
- if (normalized === "true" || normalized === "1") return { [field]: true };
786
- if (normalized === "false" || normalized === "0") return { [field]: false };
787
- }
788
- throw new Error(
789
- `federations.google.${field} must be one of true, false, "true", "false", "1" or "0" when present`,
790
- );
791
- }
792
-
793
- /**
794
- * `accessType` from the `federations.google` slice: `"offline"` or `"online"`,
795
- * exactly, or absent. `federation-google` refuses any other value too; this
796
- * refuses it first, naming the key, as the fields above do.
797
- */
798
- function optionalAccessType(
799
- slice: Record<string, unknown>,
800
- ): { accessType: "offline" | "online" } | Record<string, never> {
801
- const value = slice.accessType;
802
- if (value === undefined || value === null) return {};
803
- if (value === "offline" || value === "online") return { accessType: value };
804
- throw new Error('federations.google.accessType must be "offline" or "online" when present');
805
- }
806
-
807
- /**
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.
814
- *
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.
823
- */
824
- export const googleFederationConfigModule: Module = defineModule({
825
- name: "standalone:google-federation-config",
826
- requires: ["config"] as const,
827
- provides: {
828
- googleFederationConfig: ({ config }): GoogleProviderConfig => {
829
- const slice = extractFederationSection((config as AppConfig).federations, "google");
830
- if (!slice) {
831
- throw new Error(
832
- "federations.google must be enabled with credentials when googleFederationModule is in the manifest",
833
- );
834
- }
835
- const clientId = slice.clientId;
836
- const clientSecret = slice.clientSecret;
837
- const callbackURL = slice.callbackURL;
838
- if (
839
- typeof clientId !== "string" ||
840
- typeof clientSecret !== "string" ||
841
- typeof callbackURL !== "string"
842
- ) {
843
- throw new Error(
844
- "federations.google requires clientId, clientSecret, callbackURL when enabled",
845
- );
846
- }
847
-
848
- // The allowlist is checked for shape here and for content by
849
- // `createFederationRedirectPolicy`, which owns the URL rules. This
850
- // only has to establish that HOCON produced a list of strings —
851
- // `redirectAllowlist = "https://…"` (a bare string, the natural typo)
852
- // would otherwise reach the policy as a config it cannot read.
853
- const rawAllowlist = slice.redirectAllowlist;
854
- let redirectAllowlist: Record<string, readonly string[]> | Record<string, never> = {};
855
- if (rawAllowlist !== undefined && rawAllowlist !== null) {
856
- if (!Array.isArray(rawAllowlist) || rawAllowlist.some((e) => typeof e !== "string")) {
857
- throw new Error(
858
- "federations.google.redirectAllowlist must be a list of URL strings, " +
859
- 'e.g. ["https://app.example.com/welcome"]',
860
- );
861
- }
862
- redirectAllowlist = { redirectAllowlist: rawAllowlist as readonly string[] };
863
- }
864
-
865
- return {
866
- clientId,
867
- clientSecret,
868
- callbackURL,
869
- ...redirectAllowlist,
870
- ...optionalString(slice, "sessionDomain"),
871
- ...optionalString(slice, "authCallbackUrl"),
872
- ...optionalString(slice, "clientUrl"),
873
- // #597: absent means the provider's default, which is to require it.
874
- ...optionalBoolean(slice, "requireAuthorizationResponseIss"),
875
- // Absent means the provider's default, "offline": consent on every
876
- // sign-in, and a refresh token for every session.
877
- ...optionalAccessType(slice),
878
- };
879
- },
880
- },
881
- });
882
-
883
- /**
884
- * OIDC federation config bridge (#524) — supplies the `oidcFederationConfigs`
885
- * slot every `oidcFederationModule(<name>)` in the manifest reads its entry
886
- * 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.
889
- * `buildModules` lists this module only when at least one such section
890
- * exists.
891
- */
892
- export const oidcFederationConfigModule: Module = defineModule({
893
- name: "standalone:oidc-federation-config",
894
- requires: ["config"] as const,
895
- provides: {
896
- oidcFederationConfigs: ({ config }) =>
897
- readOidcFederationConfigs((config as AppConfig).federations),
898
- },
899
- });