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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.ja.md +139 -48
  2. package/README.md +138 -47
  3. package/dist/index.d.mts +33 -10
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +118 -59
  6. package/dist/internal/template-filter.d.mts +7 -10
  7. package/dist/internal/template-filter.d.mts.map +1 -1
  8. package/dist/internal/template-filter.mjs +7 -10
  9. package/package.json +6 -4
  10. package/templates/standalone/.dockerignore +15 -2
  11. package/templates/standalone/.env.example +69 -24
  12. package/templates/standalone/Dockerfile +7 -3
  13. package/templates/standalone/README.ja.md +677 -184
  14. package/templates/standalone/README.md +699 -238
  15. package/templates/standalone/config/application.conf +203 -325
  16. package/templates/standalone/config/clients.yaml +2 -0
  17. package/templates/standalone/config/clients.yaml.example +4 -0
  18. package/templates/standalone/config/development.conf +13 -4
  19. package/templates/standalone/config/mailpit.conf +8 -0
  20. package/templates/standalone/config/production.conf +5 -6
  21. package/templates/standalone/config/reference.conf +399 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +58 -30
  24. package/templates/standalone/docker-compose.yml +13 -3
  25. package/templates/standalone/gitignore +4 -2
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +65 -32
  28. package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1178 -0
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +256 -0
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1119 -0
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
  33. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  34. package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
  35. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  36. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
  37. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +672 -271
  38. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +480 -78
  39. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +102 -66
  40. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +135 -0
  41. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +49 -18
  42. package/templates/standalone/src/__tests__/google-session.fixture.mts +315 -0
  43. package/templates/standalone/src/__tests__/host-routes.test.mts +134 -0
  44. package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
  45. package/templates/standalone/src/__tests__/listen.test.mts +121 -0
  46. package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
  47. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  48. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
  49. package/templates/standalone/src/__tests__/metrics.test.mts +17 -7
  50. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  51. package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
  52. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
  53. package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
  54. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +183 -0
  55. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +135 -0
  56. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
  57. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  58. package/templates/standalone/src/__tests__/replica-safety.test.mts +166 -145
  59. package/templates/standalone/src/__tests__/router-error-answers.test.mts +146 -0
  60. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
  61. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  62. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
  63. package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
  64. package/templates/standalone/src/__tests__/shutdown.test.mts +319 -115
  65. package/templates/standalone/src/__tests__/smoke.test.mts +179 -233
  66. package/templates/standalone/src/__tests__/store-credential.test.mts +317 -0
  67. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  68. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
  69. package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
  70. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
  71. package/templates/standalone/src/adapters.mts +149 -0
  72. package/templates/standalone/src/app.mts +71 -115
  73. package/templates/standalone/src/buildModules.mts +229 -251
  74. package/templates/standalone/src/configPath.mts +422 -29
  75. package/templates/standalone/src/listen.mts +60 -0
  76. package/templates/standalone/src/logger.mts +31 -57
  77. package/templates/standalone/src/metrics.mts +24 -47
  78. package/templates/standalone/src/mfaSwitch.mts +262 -0
  79. package/templates/standalone/src/modules.mts +480 -464
  80. package/templates/standalone/src/rootRenames.mts +79 -0
  81. package/templates/standalone/src/routes.mts +89 -0
  82. package/templates/standalone/src/sections.mts +366 -0
  83. package/templates/standalone/src/shutdown.mts +54 -126
  84. package/templates/standalone/tests/index.test.js +2 -2
  85. package/templates/standalone/tests/package-lock.json +1615 -2223
  86. package/templates/standalone/tests/package.json +2 -2
  87. package/templates/standalone/tests/vitest.config.mjs +26 -0
  88. package/templates/standalone/vitest.config.mts +8 -7
  89. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  90. package/templates/versions.json +18 -15
  91. package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
  92. package/templates/standalone/src/terminalError.mts +0 -60
@@ -14,10 +14,12 @@
14
14
  * limitations under the License.
15
15
  */
16
16
  import {
17
- type AppConfig,
17
+ consoleLogger,
18
18
  defaultRefreshTokenFamilyRevocationModule,
19
19
  defaultRefreshTokenFamilyRotationModule,
20
+ federationsOf,
20
21
  jwksModule,
22
+ type Logger,
21
23
  type Module,
22
24
  memoryAccessTokenDenylistModule,
23
25
  memoryConsentStoreModule,
@@ -53,60 +55,76 @@ import {
53
55
  sessionStoreModuleFor,
54
56
  } from "@o3co/auth-provider-session";
55
57
  import {
56
- auditSinkModule,
58
+ standardDevelopmentMailSenderModule,
59
+ standardSmtpMailSenderModule,
60
+ } from "@o3co/auth-provider-standard";
61
+ import type { Switches } from "./configPath.mjs";
62
+ import { mfaModulesFor } from "./mfaSwitch.mjs";
63
+ import {
64
+ auditSinkModuleFor,
57
65
  googleFederationConfigModule,
66
+ httpModule,
58
67
  inMemoryCodeRepositoryModule,
59
68
  inMemoryFederationTokenStoreModule,
60
69
  inMemorySessionStoresModule,
61
70
  keyStoreModule,
71
+ loggingModule,
62
72
  oidcFederationConfigModule,
63
- repositoriesModule,
73
+ repositoriesModuleFor,
64
74
  standaloneRedisClientsModule,
65
75
  } from "./modules.mjs";
66
76
 
67
77
  /**
68
- * Overrides for the composition. All but `environment` are test-only: they let
69
- * the smoke test substitute in-memory implementations of the file-system-backed
70
- * modules, and production callers should not pass them — the defaults match the
71
- * standalone scaffold.
78
+ * Overrides for the composition. All but `environment`, `logger` and
79
+ * `mailSenderModules` are test-only: they let the smoke test substitute
80
+ * in-memory implementations of the file-system-backed modules, and production
81
+ * callers should not pass them — the defaults match the standalone scaffold.
72
82
  */
73
83
  export interface BuildModulesOverrides {
74
84
  /**
75
- * #473: the name this deployment selected its configuration by —
76
- * `CONFIG_ENV || NODE_ENV`, computed once in `app.mts` and passed here so
77
- * the Redis federation-token store's `allow-plaintext` guard reads the
78
- * environment the config actually came from, not `NODE_ENV` alone. Omitted,
79
- * the guard falls back to `NODE_ENV` (and `deployment.mode`, which it reads
80
- * off the config either way).
85
+ * The name this deployment selected its configuration by (`CONFIG_ENV ||
86
+ * NODE_ENV`, computed once in `app.mts`), so the Redis federation-token
87
+ * store's `allow-plaintext` guard reads the environment the config came
88
+ * from, not `NODE_ENV` alone. Omitted, the guard falls back to `NODE_ENV`
89
+ * (and the `deploymentMode` slot core fills from `core.deployment.mode`, which it
90
+ * reads either way). Unless `mailSenderModules` is given, it also chooses
91
+ * the mail sender: `development` installs the one that logs each code, any
92
+ * other name the SMTP one, and none is installed when it is omitted. The
93
+ * MFA module reads it too, for the development sample key's refusal.
81
94
  */
82
95
  readonly environment?: string;
96
+ /**
97
+ * Where the composition's own notices go — a deprecated config key, one
98
+ * `config_key_deprecated` line (warn) each. `app.mts` passes the logger it
99
+ * hands every module; omitted, `consoleLogger`.
100
+ */
101
+ readonly logger?: Logger;
83
102
  readonly keyStoreModule?: Module;
84
103
  readonly repositoriesModule?: Module;
85
104
  readonly storesModule?: Module;
86
105
  /**
87
- * D-2 v2 / Wave 5d: override the RT family store module + (when adapter
88
- * is `"redis"`) the bundled redis-clients + redis-store pair. Default
89
- * production manifest is `[standaloneRedisClientsModule, redisRefreshTokenFamilyStoreModule]`
90
- * (single shared ioredis socket per replica via the F4 PR2 unification).
91
- *
92
- * Smoke tests / unit tests that don't want to open an ioredis connection
93
- * pass `[memoryRefreshTokenFamilyStoreModule]` here. The override REPLACES
94
- * the entire group — when the override is provided, `standaloneRedisClientsModule`
95
- * is dropped from the manifest unless the override list includes it.
106
+ * Replaces the refresh-token family store modules (default
107
+ * `[redisRefreshTokenFamilyStoreModule]`, on the shared ioredis socket).
108
+ * Tests that must not open an ioredis connection pass
109
+ * `[memoryRefreshTokenFamilyStoreModule]`.
96
110
  */
97
111
  readonly refreshTokenFamilyModules?: readonly Module[];
98
112
  /**
99
- * #287: override the module filling the `auditSink` slot. Tests substitute
100
- * a sink they can assert on; the default writes the audit trail to stdout
101
- * and is what a deployment gets.
102
- *
103
- * The override REPLACES the default — it does not add a second provider,
104
- * which would be a boot-time slot collision. There is intentionally no way
105
- * to pass "no audit sink": #304's sink policy is that the trail is always
106
- * wired, and a composition that genuinely wants events discarded says so by
107
- * providing a sink that discards them.
113
+ * Replaces the module filling the `auditSink` slot (the default writes the
114
+ * trail to stdout); tests substitute a sink they can assert on. It replaces
115
+ * rather than adds, since a second provider would be a boot-time slot
116
+ * collision. There is no way to pass "no audit sink": pass one that discards.
108
117
  */
109
118
  readonly auditSinkModule?: Module;
119
+ /**
120
+ * The deployment's own modules behind core's `mailSender` slot. Given, they
121
+ * are installed under every environment name in place of the bundled
122
+ * sender, which is never installed beside them (a second provider would be
123
+ * a boot-time slot collision); an empty list installs no sender, and a
124
+ * module that needs one (the MFA email factor, or the MFA module with
125
+ * `requireEmailProof = "always"`) then refuses the boot.
126
+ */
127
+ readonly mailSenderModules?: readonly Module[];
110
128
  }
111
129
 
112
130
  /**
@@ -118,313 +136,273 @@ export interface BuildModulesOverrides {
118
136
  */
119
137
  const SHIPPED_ACCESS_TOKEN_EXPIRES_IN = 3600;
120
138
 
139
+ /** Whether `value` sets anything: a value, or a section with one somewhere under it. */
140
+ function setsAnything(value: unknown): boolean {
141
+ if (value === undefined) return false;
142
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return true;
143
+ return Object.values(value).some(setsAnything);
144
+ }
145
+
121
146
  /**
122
- * Compose the standalone v0.5.0 module list from `config`. Splitting this
123
- * out of `app.mts` keeps the composition root testable: a smoke test can
124
- * verify that disabling a federation removes its module pair from the
125
- * manifest without spinning up a full HTTP server.
126
- *
127
- * Federation gating: `googleFederationModule` requires `googleFederationConfig`,
128
- * which `googleFederationConfigModule` produces by reading
129
- * `config.federations.google`. When google is disabled (or the section is
130
- * absent), the config-bridge module's provider throws — so the entire pair
131
- * MUST be conditionally included at composition time, not gated inside the
132
- * provider.
133
- *
134
- * #524: the generic OIDC federation follows the same rule, once per enabled
135
- * `federations.<name>` of type "oidc": `oidcFederationModule(name)` per
136
- * instance plus the one `oidcFederationConfigModule` that reads every
137
- * instance's config into the slot they share. A section's `type` decides
138
- * which implementation owns the name, so the two gates never both select
139
- * the same section.
147
+ * Compose the standalone module list from phase one's `config`: the
148
+ * composition root's `adapters`, which choose the adapter behind each slot,
149
+ * and the switches core's reader parsed. Kept out of `app.mts` so a smoke
150
+ * test can check the manifest (that disabling a federation removes its module
151
+ * pair, say) without an HTTP server. The rules for editing the list (order,
152
+ * one module per store slot, federation adapters with their config bridges)
153
+ * are in the template README, "Module Composition Order".
140
154
  */
141
- export function buildModules(config: AppConfig, overrides: BuildModulesOverrides = {}): Module[] {
142
- // A section's `type` names the implementation: `federations.google` is the
143
- // built-in Google federation only when its type is `google` (the default
144
- // for that name). With `type = "oidc"` it is a generic OIDC instance named
145
- // "google" (#524), and composing both would contribute the same
146
- // federation and redirect-policy keys twice.
147
- const googleEnabled =
148
- extractFederationSection(config.federations ?? {}, "google")?.type === "google";
149
- const oidcFederations = oidcFederationNames(config.federations ?? {});
155
+ export function buildModules(config: Switches, overrides: BuildModulesOverrides = {}): Module[] {
156
+ // An entry's `type` names the implementation, so the two gates never both
157
+ // select one entry: `core.federations.google` is the built-in Google
158
+ // federation only when its type is `google` (that name's default); with
159
+ // `type = "oidc"` it is a generic OIDC instance, and composing both would
160
+ // contribute the same federation and redirect-policy keys twice.
161
+ const federations = federationsOf(config);
162
+ const googleEnabled = extractFederationSection(federations, "google")?.type === "google";
163
+ const oidcFederations = oidcFederationNames(federations);
164
+ const logger = overrides.logger ?? consoleLogger;
165
+ const adapters = config.adapters;
150
166
 
151
- // Wave 5d (IH-14 + OR-M1 + OR-4) + OR-9: adapter-driven branching for
152
- // the OAuth-endpoint rate limiter, the user-session-store family, AND
153
- // the OAuth code repository. The RT family store always uses Redis in
154
- // the production manifest (D-2 v2 / OR-1) unless
155
- // `overrides.refreshTokenFamilyModules` swaps it out. When ANY consumer
156
- // adapter is `"redis"` (or the RT family override includes a
157
- // Redis-backed store module), the shared `standaloneRedisClientsModule`
158
- // is added once and provides every per-purpose ComponentMap slot from a
159
- // single ioredis socket per replica. Memory-only deployments skip it.
160
- const rateLimiterAdapter = config.rateLimiter?.adapter ?? "memory";
161
- const userSessionStoresAdapter = config.userSessionStores?.adapter ?? "memory";
162
- // #277: the RFC 7009 access-token denylist. Unlike the switches above there
163
- // is no "no denylist" branch — `oauthModule` reads the `accessTokenDenylist`
164
- // slot because it mounts `/oauth/revoke`, and core's boot validator refuses
165
- // a composition that reads the slot with nothing filling it, since the
166
- // endpoint would answer 200 while the token kept working. The switch here is
167
- // only over WHICH denylist.
168
- const accessTokenDenylistAdapter = config.accessTokenDenylist?.adapter ?? "memory";
169
- // #484: the jti single-use record behind private_key_jwt client auth.
170
- const replaySeenSetAdapter = config.replaySeenSet?.adapter ?? "memory";
171
- // #527: the consent store behind /authorize for clients that are not
172
- // first-party. `"none"` (the default) wires nothing — such clients are
173
- // refused, as before — so a first-party-only deployment pays nothing and
174
- // a multi-replica one is not handed a memory store it cannot run.
175
- // `"redis"` (#561) is the store such a deployment selects.
176
- const consentStoreAdapter = config.consentStore?.adapter ?? "none";
177
- // #456: adapter switch for the federation token store. `"memory"` by
178
- // default, the template's local-dev shape; `"redis"` mounts
179
- // `redisFederationTokenStoreModule` off the shared socket — what the README
180
- // promised and what the previous factory-based module never delivered: it
181
- // built the Redis store without a client and failed at boot.
182
- const federationTokenStoreAdapter = config.federationTokenStore?.type ?? "memory";
183
- // #593 slice 7: federation grants — a user's standing consent that a client
184
- // may obtain upstream tokens with no session behind the call. Off by
185
- // default, and off installs nothing: no store, no socket, no boot
186
- // requirement a deployment that never asked for grants would have to meet.
187
- // On, the routes and the background registry the shutdown drains come as a
188
- // pair, each store follows its own switch (grants in Redis with acquisition
189
- // in memory is a supported single-replica shape), and the subject-revocation
190
- // service is installed so a credential change can reach the grants — its
191
- // `federationGrantStore` edge is what makes "revoke everything this subject
192
- // holds" include them. Enabling the feature is also a statement that the
193
- // deployment has a consent page, a callback per connection and a user
194
- // repository that covers each connection's registration; the routes module
195
- // refuses at boot what is missing, naming it.
196
- const federationGrantsEnabled = config.federationGrants?.enabled === true;
197
- const federationGrantStoreAdapter = config.federationGrantStore?.adapter ?? "memory";
198
- const federationGrantIntentStoreAdapter = config.federationGrantIntentStore?.adapter ?? "memory";
167
+ // Federation grants: a user's standing consent that a client may obtain
168
+ // upstream tokens with no session behind the call. Off by default, and off
169
+ // installs nothing: no store, no socket, no boot requirement. On, the routes
170
+ // and the background registry the shutdown drains come as a pair; each store
171
+ // follows its own selection (grants in Redis with acquisition in memory is
172
+ // a supported single-replica shape); and the subject-revocation service is
173
+ // installed, whose `federationGrantStore` edge makes "revoke everything this
174
+ // subject holds" include the grants. Enabling it also states the deployment
175
+ // has a consent page, a callback per connection and a user repository
176
+ // covering each connection's registration; the routes module refuses at
177
+ // boot what is missing, naming it. A setting still written at the section's
178
+ // old path, `federationGrants`, installs the feature too, so that boot
179
+ // refuses it naming the new path rather than reading the feature as off.
180
+ const federationGrantsEnabled =
181
+ config["federation-grants"]?.enabled === true || setsAnything(config.federationGrants);
199
182
 
200
- // OR-9: effective code-repo adapter. `oauth.code.adapter` is the
201
- // authoritative switch; the legacy `repositories.code.type = "redis"`
202
- // path is honored with a deprecation warn so existing operators
203
- // relying on `CLIENT_CODE_TYPE=redis` env-var overrides keep working
204
- // until they migrate. See CHANGELOG for the removal version.
205
- const oauthCodeAdapter = config.oauth?.code?.adapter;
206
- const legacyCodeType = (config.repositories?.code as { type?: string } | undefined)?.type;
207
- let codeRepositoryAdapter: "memory" | "redis";
208
- if (oauthCodeAdapter !== undefined) {
209
- codeRepositoryAdapter = oauthCodeAdapter;
210
- } else if (legacyCodeType === "redis") {
211
- console.warn(
212
- '[buildModules] `repositories.code.type = "redis"` is deprecated; use ' +
213
- '`oauth.code.adapter = "redis"` instead — see CHANGELOG for the removal version.',
214
- );
215
- codeRepositoryAdapter = "redis";
216
- } else {
217
- codeRepositoryAdapter = "memory";
218
- }
183
+ // MFA: the template's own switch, `mfaMode` (MFA_MODE). `off` installs
184
+ // nothing of MFA; `optional` and `required` install what `mfaModulesFor`
185
+ // lists, the configuration then expects `mfa`
186
+ // (`expectedSessionRequirements`) and `mfa.mode` is written from the
187
+ // switch (`resolveForBoot`).
188
+ const mfaInstalled = config.mfaMode !== "off";
219
189
 
220
- // The access-token lifetime's deprecated alias: `oauth.accessToken.expiresIn`
190
+ // The deprecated alias `oauth.accessToken.expiresIn`
221
191
  // (OAUTH_ACCESS_TOKEN_EXPIRES_IN) is still read as the default while
222
- // `defaultExpiresIn` is unset — `resolveAccessTokenLifetime` does that, and
223
- // this only says so. Core's `reference.conf` keeps the shipped lifetime on
224
- // the deprecated key, so "the alias supplied the default" is every
225
- // deployment that set nothing; an override of it is the one case with
226
- // something to move, the reading `legacyCodeType === "redis"` gives above.
227
- // See CHANGELOG for the removal version.
192
+ // `defaultExpiresIn` is unset; `resolveAccessTokenLifetime` does that, and
193
+ // this only warns. Core's `reference.conf` ships the lifetime on the
194
+ // deprecated key, so only an override of it has something to move. See
195
+ // CHANGELOG for the removal version.
228
196
  const accessToken = config.oauth.accessToken;
229
197
  if (
230
198
  accessToken.defaultExpiresIn === undefined &&
231
199
  accessToken.expiresIn !== SHIPPED_ACCESS_TOKEN_EXPIRES_IN
232
200
  ) {
233
- console.warn(
234
- "[buildModules] `oauth.accessToken.expiresIn` (OAUTH_ACCESS_TOKEN_EXPIRES_IN) is " +
235
- "deprecated; use `oauth.accessToken.defaultExpiresIn` " +
236
- "(OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN) instead — see CHANGELOG for the removal version.",
201
+ logger.warn(
202
+ {
203
+ key: "oauth.accessToken.expiresIn",
204
+ env: "OAUTH_ACCESS_TOKEN_EXPIRES_IN",
205
+ replacement: "oauth.accessToken.defaultExpiresIn",
206
+ replacementEnv: "OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN",
207
+ },
208
+ "config_key_deprecated",
237
209
  );
238
210
  }
239
211
 
240
212
  const refreshTokenFamilyModules: readonly Module[] = overrides.refreshTokenFamilyModules ?? [
241
213
  redisRefreshTokenFamilyStoreModule,
242
214
  ];
243
- // Detect whether the RT-family override (if any) keeps the Redis-backed
244
- // store. Default (no override) uses Redis. An override that includes the
245
- // Redis store module without also wiring `standaloneRedisClientsModule`
246
- // would otherwise boot-fail on the missing `refreshTokenFamilyClient`
247
- // component (Copilot review on PR #121).
215
+ // Whether the family modules keep the Redis-backed store (the default
216
+ // does). Without the shared clients module added for it, that store fails
217
+ // boot on the missing `refreshTokenFamilyClient` component.
248
218
  const refreshTokenFamilyUsesRedis = refreshTokenFamilyModules.some(
249
219
  (m) => m.name === "redis-refresh-token-family-store",
250
220
  );
221
+ // When ANY selection is `"redis"`, or the refresh-token family modules keep
222
+ // the Redis store (the default), the shared `standaloneRedisClientsModule`
223
+ // is added once, one ioredis socket per replica; memory-only deployments
224
+ // open none.
251
225
  const usingRedisAnywhere =
252
226
  refreshTokenFamilyUsesRedis ||
253
- rateLimiterAdapter === "redis" ||
254
- userSessionStoresAdapter === "redis" ||
255
- codeRepositoryAdapter === "redis" ||
256
- accessTokenDenylistAdapter === "redis" ||
257
- replaySeenSetAdapter === "redis" ||
258
- consentStoreAdapter === "redis" ||
259
- federationTokenStoreAdapter === "redis" ||
260
- // Only while the feature is on: a switch left at "redis" for a feature
261
- // that is off must not open a socket.
227
+ adapters.rateLimiter === "redis" ||
228
+ adapters.userSessionStores === "redis" ||
229
+ adapters.codeRepository === "redis" ||
230
+ adapters.accessTokenDenylist === "redis" ||
231
+ adapters.replaySeenSet === "redis" ||
232
+ adapters.consentStore === "redis" ||
233
+ adapters.federationTokenStore === "redis" ||
234
+ // Only while the feature is on: a selection left at "redis" for a
235
+ // feature that is off must not open a socket.
262
236
  (federationGrantsEnabled &&
263
- (federationGrantStoreAdapter === "redis" || federationGrantIntentStoreAdapter === "redis"));
237
+ (adapters.federationGrantStore === "redis" ||
238
+ adapters.federationGrantIntentStore === "redis")) ||
239
+ (mfaInstalled &&
240
+ (adapters.mfaFactorStore === "redis" || adapters.mfaTransactionStore === "redis"));
264
241
 
265
- // The four user-session stores switch on `userSessionStores.adapter`; the
266
- // federation-token store is always wired and switches on its own key
267
- // below (#456). Pre-Wave-5d the redis branch dropped the
268
- // federation-token-store provider entirely (Copilot review on PR #121);
269
- // splitting `storesModule` fixed that boot failure.
242
+ // The four user-session stores and the subject-level revocation pair
243
+ // follow `adapters.userSessionStores`; the federation-token store is always
244
+ // wired and follows its own selection below.
270
245
  const sessionStoresModules: Module[] =
271
- userSessionStoresAdapter === "redis"
246
+ adapters.userSessionStores === "redis"
272
247
  ? [redisSessionStoresModule]
273
248
  : overrides.storesModule
274
249
  ? [overrides.storesModule]
275
250
  : [inMemorySessionStoresModule];
276
251
 
277
252
  const rateLimiterModules: Module[] =
278
- rateLimiterAdapter === "redis" ? [redisRateLimiterModule] : [memoryRateLimiterModule];
253
+ adapters.rateLimiter === "redis" ? [redisRateLimiterModule] : [memoryRateLimiterModule];
279
254
 
280
- // OR-9: code-repository module — mutually exclusive memory/redis pair.
281
- // Same pattern as sessionStoresModules + rateLimiterModules. The two
282
- // modules provide the same `codeRepository` slot; including both would
283
- // be a boot-time slot collision.
255
+ // Mutually exclusive: both modules provide the `codeRepository` slot, and
256
+ // including both would be a boot-time slot collision.
284
257
  const codeRepositoryModules: Module[] =
285
- codeRepositoryAdapter === "redis"
258
+ adapters.codeRepository === "redis"
286
259
  ? [redisCodeRepositoryModule]
287
260
  : [inMemoryCodeRepositoryModule];
288
261
 
289
- // #277: mutually-exclusive denylist pair, same shape as the three switches
290
- // above. The memory branch is a dev convenience and nothing more: it forks
291
- // per replica, so `deployment.mode = "multi"` refuses it by name (see core's
292
- // replica-safety guard). The template's own application.conf ships `"redis"`.
262
+ // The RFC 7009 access-token denylist has no "none": `oauthModule` mounts
263
+ // `/oauth/revoke` and reads the `accessTokenDenylist` slot, and core's boot
264
+ // validator refuses a composition that reads the slot with nothing filling
265
+ // it, since the endpoint would answer 200 while the token kept working. The
266
+ // memory denylist is a dev convenience: it forks per replica, so
267
+ // `core.deployment.mode = "multi"` refuses it by name. The template ships
268
+ // `"redis"`.
293
269
  const accessTokenDenylistModules: Module[] =
294
- accessTokenDenylistAdapter === "redis"
270
+ adapters.accessTokenDenylist === "redis"
295
271
  ? [redisAccessTokenDenylistModule]
296
272
  : [memoryAccessTokenDenylistModule];
297
273
 
298
- // #527: opt-in. Each module provides both slots the consent step needs —
299
- // the consent records and the requests parked while the page asks — so the
300
- // two cannot be wired apart. The memory one declares itself replica-unsafe
301
- // and `deployment.mode = "multi"` refuses it by name; the Redis one (#561)
302
- // shares both over the ioredis socket.
274
+ // Opt-in: `"none"` (the default) wires nothing, and a client that is not
275
+ // first-party is refused. Each module provides both slots the consent step
276
+ // needs (the consent records and the requests parked while the page asks),
277
+ // so the two cannot be wired apart. The memory one declares itself
278
+ // replica-unsafe and `core.deployment.mode = "multi"` refuses it by name;
279
+ // the Redis one shares both over the ioredis socket.
303
280
  const consentStoreModules: Module[] =
304
- consentStoreAdapter === "redis"
281
+ adapters.consentStore === "redis"
305
282
  ? [redisConsentStoreModule]
306
- : consentStoreAdapter === "memory"
283
+ : adapters.consentStore === "memory"
307
284
  ? [memoryConsentStoreModule]
308
285
  : [];
309
286
 
310
- // #455 / #456: mutually-exclusive federation-token-store pair, the same
311
- // shape as the session stores. One module per adapter, so the memory one
312
- // can declare `replicaSafety` on its manifest and the Redis one can
313
- // `require` its client slot. The previous single module chose the adapter
314
- // at runtime under one name — the replica guard could not tell the two
315
- // apart, and the Redis branch had no client to hand the builder.
316
- //
317
- // #473: the Redis module is built for this composition root so its
318
- // plaintext guard knows which environment selected the config; it reads
319
- // `deployment.mode` off the config itself.
287
+ // The jti single-use record behind private_key_jwt client authentication.
320
288
  const replaySeenSetModules: Module[] =
321
- replaySeenSetAdapter === "redis" ? [redisReplaySeenSetModule] : [memoryReplaySeenSetModule];
289
+ adapters.replaySeenSet === "redis" ? [redisReplaySeenSetModule] : [memoryReplaySeenSetModule];
322
290
 
291
+ // One federation-token-store module per adapter, so the memory one can
292
+ // declare `replicaSafety` on its manifest and the Redis one can `require`
293
+ // its client slot. The Redis module is built for this composition root so
294
+ // its plaintext guard knows which environment selected the config; it
295
+ // reads the replica count from core's `deploymentMode` slot.
323
296
  const federationTokenStoreModules: Module[] =
324
- federationTokenStoreAdapter === "redis"
297
+ adapters.federationTokenStore === "redis"
325
298
  ? [redisFederationTokenStoreModuleFor({ environment: overrides.environment })]
326
299
  : [inMemoryFederationTokenStoreModule];
327
300
 
328
- // #593 slice 7: one module per store, nothing while the feature is off.
329
- // The Redis grant store is built for this composition root so its
330
- // plaintext guard knows which environment selected the config (#473), as
331
- // the federation-token store's is. Both memory modules declare
332
- // `replicaSafety`, so `deployment.mode = "multi"` refuses them by name; a
333
- // Redis grant store beside memory user-session stores is refused by the
334
- // routes module itself, because the grants would outlive the boundary that
335
- // ends them (D13).
301
+ // One module per store, nothing while the feature is off or the store is
302
+ // `"none"` (the default), which the routes module refuses at boot naming
303
+ // the store. The Redis grant store is built for this composition root so
304
+ // its plaintext guard knows which environment selected the config, as the
305
+ // federation-token store's is. Both memory modules declare
306
+ // `replicaSafety`, so `core.deployment.mode = "multi"` refuses them by name;
307
+ // the routes module itself refuses a Redis grant store beside memory
308
+ // user-session stores, because the grants would outlive the boundary that
309
+ // ends them.
336
310
  const federationGrantStoreModules: Module[] = !federationGrantsEnabled
337
311
  ? []
338
- : federationGrantStoreAdapter === "redis"
312
+ : adapters.federationGrantStore === "redis"
339
313
  ? [
340
314
  overrides.environment === undefined
341
315
  ? redisFederationGrantStoreModuleFor()
342
316
  : redisFederationGrantStoreModuleFor({ environment: overrides.environment }),
343
317
  ]
344
- : [memoryFederationGrantStoreModule];
318
+ : adapters.federationGrantStore === "memory"
319
+ ? [memoryFederationGrantStoreModule]
320
+ : [];
345
321
  const federationGrantIntentStoreModules: Module[] = !federationGrantsEnabled
346
322
  ? []
347
- : federationGrantIntentStoreAdapter === "redis"
323
+ : adapters.federationGrantIntentStore === "redis"
348
324
  ? [redisFederationGrantIntentStoreModule]
349
- : [memoryFederationGrantIntentStoreModule];
325
+ : adapters.federationGrantIntentStore === "memory"
326
+ ? [memoryFederationGrantIntentStoreModule]
327
+ : [];
328
+
329
+ // What the MFA switch installs (`mfaSwitch.mts`): nothing while it is off.
330
+ const mfaInstalledModules = mfaModulesFor({
331
+ mode: config.mfaMode,
332
+ adapters,
333
+ storeTransport: config.storeTransport,
334
+ environment: overrides.environment,
335
+ });
336
+
337
+ // The mail sender behind core's `mailSender` slot: the deployment's own when
338
+ // given, otherwise the bundled one for the environment. The development one
339
+ // logs each code and refuses the boot where the configuration or NODE_ENV
340
+ // is production or staging, or the deployment multi-replica.
341
+ const mailSenderModules: readonly Module[] =
342
+ overrides.mailSenderModules ??
343
+ (overrides.environment === undefined
344
+ ? []
345
+ : overrides.environment === "development"
346
+ ? [standardDevelopmentMailSenderModule({ environment: overrides.environment })]
347
+ : [standardSmtpMailSenderModule]);
350
348
 
351
349
  return [
352
- // D-5: sessionStoreModule wires the express-session middleware into the
353
- // boot-planner-managed lifecycle. **Mount order is enforced by this
354
- // list position (declarationIndex tie-breaking)** — the module
355
- // intentionally has no `before`/`after` clause, so it MUST be listed
356
- // ahead of every session-consuming module here. Do not reorder.
357
- //
358
- // #474: built from `config` rather than the static manifest, so that
359
- // `session.storage.type = "memory"` declares itself replica-unsafe and
360
- // `deployment.mode = "multi"` refuses it by name like the other stores.
350
+ // MUST stay first: it declares no `before`/`after`, so this position is
351
+ // what mounts express-session ahead of every session-consuming module.
352
+ // Built from `config` so that `session-store.storage.type = "memory"` declares
353
+ // itself replica-unsafe and `core.deployment.mode = "multi"` refuses it.
361
354
  sessionStoreModuleFor(config),
362
- // #593 slice 7: before `oauthModule`, whose router mounts under the same
363
- // `/oauth` prefix with body parsers of its own — mounted after it, a
364
- // malformed body would be refused by the OAuth router's parser without
365
- // this package's request id, `Cache-Control: no-store` or throttle (the
366
- // package README's mounting-order rule). The browser half sits after the
367
- // session middleware by its own `after`, wherever it is listed.
355
+ // Under `/oauth` beside `oauthModule`, each parsing its own requests, so
356
+ // their relative order does not matter; the browser half orders itself
357
+ // after the session middleware by its own `after`.
368
358
  ...(federationGrantsEnabled ? federationGrantsModules : []),
369
359
  oauthModule({ config }),
370
360
  oauthSessionModule({ config }),
371
361
  oauthAuthorizationModule({ config }),
372
- // JWKS publishing — always wired (depends only on config + keyStore).
373
- // Contributed by core's `jwksModule`, NOT oauthModule: a provider that
374
- // signs tokens must publish its verification keys regardless of OIDC
375
- // issuer config. Co-installed with oauthModule so the discovery
376
- // `jwks_uri` (advertised by oauthModule when an issuer is set) always
377
- // resolves to this mounted route.
362
+ // Always wired: a provider that signs tokens must publish its
363
+ // verification keys regardless of OIDC issuer config, and `oauthModule`'s
364
+ // discovery `jwks_uri` (advertised when an issuer is set) must resolve.
378
365
  jwksModule,
379
366
  sessionModule,
380
367
  ...(googleEnabled ? [googleFederationModule, googleFederationConfigModule] : []),
381
368
  ...(oidcFederations.length > 0
382
369
  ? [oidcFederationConfigModule, ...oidcFederations.map((name) => oidcFederationModule(name))]
383
370
  : []),
371
+ // The template's own settings: `logging {}` and `http {}`.
372
+ loggingModule,
373
+ httpModule,
384
374
  overrides.keyStoreModule ?? keyStoreModule,
385
- overrides.repositoriesModule ?? repositoriesModule,
386
- // #287: the audit sink, always wired. Unlike every switch above there is
387
- // no "off" branch — the routes' security events (`token.issued.failure`,
388
- // `authorize.rejected`, `rate_limit.unavailable`, …) went nowhere in the
389
- // shipped artifact because the slot was empty and `emitAuditEvent`
390
- // no-ops when it is. Which sink is a config question
391
- // (`audit.sink.type`); whether there is one is not.
392
- overrides.auditSinkModule ?? auditSinkModule,
393
- // Shared ioredis clients — only when at least one consumer adapter
394
- // actually needs Redis. Memory-only deployments skip this so they
395
- // don't open an unused socket.
375
+ overrides.repositoriesModule ??
376
+ repositoriesModuleFor({ client: adapters.clientRepository, user: adapters.userRepository }),
377
+ // The audit sink, always wired: `emitAuditEvent` no-ops on an empty slot,
378
+ // so the routes' security events (`token.issued.failure`,
379
+ // `authorize.rejected`, `rate_limit.unavailable`, …) would go nowhere.
380
+ // Which sink is a selection (`adapters.auditSink`); whether there is one
381
+ // is not.
382
+ overrides.auditSinkModule ?? auditSinkModuleFor(adapters.auditSink),
383
+ ...mailSenderModules,
396
384
  ...(usingRedisAnywhere ? [standaloneRedisClientsModule] : []),
397
- // Federation-token store: redis (multi-replica) or memory (dev). Always
398
- // wired, independent of the `userSessionStores.adapter` switch — it was
399
- // bundled into `storesModule` pre-Wave-5d, and the redis session-stores
400
- // branch dropped the provider.
401
385
  ...federationTokenStoreModules,
402
- // #593 slice 7: the grant store and the intent store, when the feature is on.
403
386
  ...federationGrantStoreModules,
404
387
  ...federationGrantIntentStoreModules,
405
- // User-session-store family: redis (multi-replica) or memory (dev).
406
388
  ...sessionStoresModules,
407
- // OAuth-endpoint rate limiter: redis (shared counters) or memory.
408
389
  ...rateLimiterModules,
409
- // OAuth code repository: redis (multi-replica) or memory (single-instance).
410
390
  ...codeRepositoryModules,
411
- // RFC 7009 access-token denylist: redis (shared revocations) or memory
412
- // (single-instance dev). Always wired — see the switch above.
413
391
  ...accessTokenDenylistModules,
414
392
  ...replaySeenSetModules,
415
- // Consent records for clients that are not first-party: redis
416
- // (multi-replica, #561), memory (single-instance), or nothing (#527).
417
393
  ...consentStoreModules,
418
- // RT family store: redis by default (closes OR-1); override path
419
- // swaps to `[memoryRefreshTokenFamilyStoreModule]` for unit tests.
420
394
  ...refreshTokenFamilyModules,
421
395
  defaultRefreshTokenFamilyRotationModule,
422
396
  defaultRefreshTokenFamilyRevocationModule,
423
- // #593 slice 7: the composed "end everything this subject holds" a
424
- // credential change calls, reached as `handle.components.subjectRevocationService`.
425
- // Installed with the feature, which is what makes it reach the grants;
426
- // the template never installed it before, so a deployment without
427
- // grants is as it was — a Store there calls core's revokeAllForSubject.
428
- ...(federationGrantsEnabled ? [subjectRevocationServiceModule] : []),
397
+ // MFA's modules and stores; the MFA routes order themselves after the
398
+ // session middleware.
399
+ ...mfaInstalledModules,
400
+ // The composed "end everything this subject holds" a credential change
401
+ // calls, reached as `handle.components.subjectRevocationService`.
402
+ // Installed with federation grants, which is what makes it reach the
403
+ // grants, and with MFA, whose operator reset ends every session and
404
+ // token of the subject's through it; otherwise a Store calls core's
405
+ // revokeAllForSubject.
406
+ ...(federationGrantsEnabled || mfaInstalled ? [subjectRevocationServiceModule] : []),
429
407
  ];
430
408
  }