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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.ja.md +66 -21
  2. package/README.md +76 -31
  3. package/dist/index.d.mts +33 -10
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +118 -59
  6. package/dist/internal/template-filter.d.mts +7 -10
  7. package/dist/internal/template-filter.d.mts.map +1 -1
  8. package/dist/internal/template-filter.mjs +7 -10
  9. package/package.json +6 -4
  10. package/templates/standalone/.dockerignore +15 -2
  11. package/templates/standalone/.env.example +59 -26
  12. package/templates/standalone/Dockerfile +6 -2
  13. package/templates/standalone/README.ja.md +293 -162
  14. package/templates/standalone/README.md +533 -199
  15. package/templates/standalone/config/application.conf +196 -341
  16. package/templates/standalone/config/clients.yaml +2 -0
  17. package/templates/standalone/config/clients.yaml.example +4 -0
  18. package/templates/standalone/config/development.conf +13 -4
  19. package/templates/standalone/config/mailpit.conf +8 -0
  20. package/templates/standalone/config/production.conf +5 -6
  21. package/templates/standalone/config/reference.conf +399 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +58 -30
  24. package/templates/standalone/docker-compose.yml +13 -3
  25. package/templates/standalone/gitignore +1 -0
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +19 -20
  28. package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +216 -141
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +48 -33
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +77 -58
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
  33. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  34. package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
  35. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  36. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
  37. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +656 -269
  38. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +425 -84
  39. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +91 -70
  40. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +7 -5
  41. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +22 -19
  42. package/templates/standalone/src/__tests__/google-session.fixture.mts +53 -42
  43. package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
  44. package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
  45. package/templates/standalone/src/__tests__/listen.test.mts +6 -13
  46. package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
  47. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  48. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
  49. package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
  50. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  51. package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
  52. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
  53. package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
  54. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +7 -11
  55. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
  56. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
  57. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  58. package/templates/standalone/src/__tests__/replica-safety.test.mts +153 -142
  59. package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
  60. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
  61. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  62. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
  63. package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
  64. package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
  65. package/templates/standalone/src/__tests__/smoke.test.mts +179 -252
  66. package/templates/standalone/src/__tests__/store-credential.test.mts +91 -50
  67. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  68. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
  69. package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
  70. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
  71. package/templates/standalone/src/adapters.mts +149 -0
  72. package/templates/standalone/src/app.mts +64 -79
  73. package/templates/standalone/src/buildModules.mts +212 -251
  74. package/templates/standalone/src/configPath.mts +422 -29
  75. package/templates/standalone/src/listen.mts +12 -18
  76. package/templates/standalone/src/logger.mts +31 -60
  77. package/templates/standalone/src/metrics.mts +24 -47
  78. package/templates/standalone/src/mfaSwitch.mts +262 -0
  79. package/templates/standalone/src/modules.mts +458 -476
  80. package/templates/standalone/src/rootRenames.mts +79 -0
  81. package/templates/standalone/src/routes.mts +8 -14
  82. package/templates/standalone/src/sections.mts +366 -0
  83. package/templates/standalone/src/shutdown.mts +45 -123
  84. package/templates/standalone/tests/index.test.js +2 -2
  85. package/templates/standalone/tests/package-lock.json +1615 -2223
  86. package/templates/standalone/tests/package.json +2 -2
  87. package/templates/standalone/tests/vitest.config.mjs +26 -0
  88. package/templates/standalone/vitest.config.mts +8 -7
  89. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  90. package/templates/versions.json +18 -15
@@ -16,20 +16,17 @@
16
16
 
17
17
  /**
18
18
  * The all-modules composition of `all-modules-composition.test.mts` on more
19
- * than one replica: every shared store on Redis, `deployment.mode = "multi"`,
19
+ * than one replica: every shared store on Redis, `core.deployment.mode = "multi"`,
20
20
  * every module the template can turn on switched on together.
21
21
  *
22
- * What it holds: the replica-safety declarations hold with every module on —
23
- * the all-Redis composition boots with nothing declaring replica-unsafe state,
24
- * and each store switched back to memory is refused at boot, naming the
25
- * module — and the Redis adapters change nothing a client can see: the same
26
- * routes, the same discovery document as on one replica.
22
+ * With every module on, the all-Redis composition boots with nothing declaring
23
+ * replica-unsafe state, and each store switched back to memory is refused at
24
+ * boot, naming the module. The Redis adapters change nothing a client can
25
+ * see: the same routes, the same discovery document as on one replica.
27
26
  *
28
27
  * ioredis, node-redis and connect-redis are stand-ins, as in
29
- * `replica-safety.test.mts`: what is under test is the composition and the
30
- * boot's verdict, and nothing here issues a command. The template has no
31
- * Redis server to run against — `packages/redis` runs its adapters against
32
- * one.
28
+ * `replica-safety.test.mts`: nothing here issues a command. `packages/redis`
29
+ * runs its adapters against a real server.
33
30
  */
34
31
 
35
32
  import { type Module, replicaUnsafeReason } from "@o3co/auth-provider-core";
@@ -110,7 +107,7 @@ const MULTI = { env: MULTI_ENV, shippedRefreshTokenFamilyStore: true } as const;
110
107
 
111
108
  /** What `buildModules` lists with every switch on and every shared store on Redis. */
112
109
  const ALL_ON_REDIS_MODULES = [
113
- "sessionStoreModule",
110
+ "session-store",
114
111
  "federation-grant-background",
115
112
  "federation-grants",
116
113
  "oauth",
@@ -118,18 +115,21 @@ const ALL_ON_REDIS_MODULES = [
118
115
  "oauth-authorization",
119
116
  "jwks",
120
117
  "session",
121
- "federation:google",
122
- "standalone:google-federation-config",
123
- "standalone:oidc-federation-config",
124
- "federation:oidc:oidc",
125
- "standalone:key-store",
118
+ "federation-google",
119
+ "google-federation-config",
120
+ "oidc-federation-config",
121
+ "federation-oidc-oidc",
122
+ "logging",
123
+ "http",
124
+ "key-store",
126
125
  "test:repositories",
127
- "standalone:audit-sink",
128
- "standalone:redis-clients",
126
+ "audit-sink",
127
+ "standard-smtp-mail-sender",
128
+ "redis-clients",
129
129
  "redis-federation-token-store",
130
130
  "redis-federation-grant-store",
131
131
  "redis-federation-grant-intent-store",
132
- "redisSessionStores",
132
+ "redis-session-stores",
133
133
  "redis-rate-limiter",
134
134
  "redis-code-repository",
135
135
  "redis-access-token-denylist",
@@ -141,7 +141,7 @@ const ALL_ON_REDIS_MODULES = [
141
141
  "subject-revocation-service",
142
142
  ];
143
143
 
144
- describe('every module on, every shared store on Redis, deployment.mode = "multi"', () => {
144
+ describe('every module on, every shared store on Redis, core.deployment.mode = "multi"', () => {
145
145
  it("lists every module, none declaring replica-unsafe state, and boots", async () => {
146
146
  const modules = composedModules(resolveConfig(MULTI_ENV), MULTI);
147
147
  expect(modules.map((m) => m.name)).toEqual(ALL_ON_REDIS_MODULES);
@@ -166,21 +166,36 @@ describe('every module on, every shared store on Redis, deployment.mode = "multi
166
166
  expect(discovery.body).toEqual(single.discovery);
167
167
  });
168
168
 
169
+ it("boots as multi on CORE_DEPLOYMENT_MODE=multi alone", async () => {
170
+ expect(MULTI_ENV).toMatchObject({ CORE_DEPLOYMENT_MODE: "multi" });
171
+ expect(MULTI_ENV).not.toHaveProperty("DEPLOYMENT_MODE");
172
+ current = await compose(MULTI);
173
+ expect(current.handle.components.deploymentMode).toBe("multi");
174
+ });
175
+
176
+ it("boots as multi on DEPLOYMENT_MODE=multi beside CORE_DEPLOYMENT_MODE=multi, as the umbrella sets them", async () => {
177
+ current = await compose({
178
+ ...MULTI,
179
+ env: { ...MULTI_ENV, DEPLOYMENT_MODE: "multi", CORE_DEPLOYMENT_MODE: "multi" },
180
+ });
181
+ expect(current.handle.components.deploymentMode).toBe("multi");
182
+ });
183
+
169
184
  /**
170
185
  * Each store's switch back to memory, alone, with every module on. The
171
186
  * boot names the module that declared the state it would fork.
172
187
  */
173
188
  const MEMORY_SWITCHES: ReadonlyArray<readonly [variable: string, module: string]> = [
174
- ["SESSION_STORAGE_TYPE", "sessionStoreModule"],
175
- ["USER_SESSION_STORES_ADAPTER", "standalone:in-memory-session-stores"],
176
- ["OAUTH_CODE_ADAPTER", "standalone:in-memory-code-repository"],
177
- ["FEDERATION_TOKEN_STORE_TYPE", "standalone:in-memory-federation-token-store"],
178
- ["RATE_LIMITER_ADAPTER", "core-rate-limiter-memory"],
179
- ["ACCESS_TOKEN_DENYLIST_ADAPTER", "core-access-token-denylist-memory"],
180
- ["REPLAY_SEEN_SET_ADAPTER", "core-replay-seen-set-memory"],
181
- ["CONSENT_STORE_ADAPTER", "core-consent-store-memory"],
182
- ["FEDERATION_GRANT_STORE_ADAPTER", "core-federation-grant-store-memory"],
183
- ["FEDERATION_GRANT_INTENT_STORE_ADAPTER", "core-federation-grant-intent-store-memory"],
189
+ ["SESSION_STORE_STORAGE_TYPE", "session-store"],
190
+ ["ADAPTERS_USER_SESSION_STORES", "standalone-in-memory-session-stores"],
191
+ ["ADAPTERS_CODE_REPOSITORY", "standalone-in-memory-code-repository"],
192
+ ["ADAPTERS_FEDERATION_TOKEN_STORE", "standalone-in-memory-federation-token-store"],
193
+ ["ADAPTERS_RATE_LIMITER", "core-rate-limiter-memory"],
194
+ ["ADAPTERS_ACCESS_TOKEN_DENYLIST", "core-access-token-denylist-memory"],
195
+ ["ADAPTERS_REPLAY_SEEN_SET", "core-replay-seen-set-memory"],
196
+ ["ADAPTERS_CONSENT_STORE", "core-consent-store-memory"],
197
+ ["ADAPTERS_FEDERATION_GRANT_STORE", "core-federation-grant-store-memory"],
198
+ ["ADAPTERS_FEDERATION_GRANT_INTENT_STORE", "core-federation-grant-intent-store-memory"],
184
199
  ];
185
200
 
186
201
  it.each(MEMORY_SWITCHES)("%s=memory is refused at boot, naming %s", async (variable, module) => {
@@ -212,8 +227,8 @@ describe("the shared Redis socket can back every store the Redis package ships",
212
227
  * the others to the manifest with the modules that read them (the device
213
228
  * grant's code store, WebAuthn's challenge store), and each requires its
214
229
  * client from this module. One it does not provide is a boot refused with
215
- * `missing-required-component` — the #439 shape, which `modules.mts`
216
- * guards against for the device-code store by name.
230
+ * `missing-required-component`, which `modules.mts` guards against for
231
+ * the device-code store by name.
217
232
  */
218
233
  const storeModules: Module[] = Object.entries(redisPackage).flatMap(([name, value]) => {
219
234
  if (name.endsWith("ModuleFor") && typeof value === "function") {
@@ -226,7 +241,7 @@ describe("the shared Redis socket can back every store the Redis package ships",
226
241
  });
227
242
 
228
243
  it.each(storeModules.map((m) => [m.name, m] as const))(
229
- "%s: its client slot is provided by standalone:redis-clients",
244
+ "%s: its client slot is provided by redis-clients",
230
245
  (_name, module) => {
231
246
  const provided = Object.keys(standaloneRedisClientsModule.provides ?? {});
232
247
  const clients = (module.requires ?? []).filter((slot) => String(slot).endsWith("Client"));
@@ -16,34 +16,29 @@
16
16
 
17
17
  /**
18
18
  * Every module this template can turn on, booted together on one replica
19
- * through the real composition — the shipped HOCON, `buildModules`, core's
20
- * `createApp`, mounted as `app.mts` mounts it — and held to the contracts that
21
- * exist only when the modules meet: one discovery document that tells the
22
- * truth about every grant and endpoint, every module's route answering in the
23
- * same app, each module's body rules surviving its neighbours in either mount
24
- * order, and a store outage answered 503 and logged once.
25
- *
26
- * Each package's own suite boots that package alone or against stand-ins, and
27
- * the defects this file exists for were invisible there: a discovery document
28
- * made invalid by a neighbour's contribution, a disabled grant still
29
- * advertised, one module's prefix parser setting another's body limit, a
30
- * content-type rule that held in one mount order only. See
31
- * `all-modules-composition.fixture.mts` for what is real and what is
32
- * substituted, and `all-modules-composition.multi.test.mts` for the same
33
- * composition on Redis under `deployment.mode = "multi"`.
19
+ * through the real composition (the shipped HOCON, `buildModules`, core's
20
+ * `createApp`, mounted as `app.mts` mounts it), and held to the contracts that
21
+ * exist only when the modules meet, which no package's own suite can see: one
22
+ * discovery document that tells the truth about every grant and endpoint,
23
+ * every module's route answering in the same app, each module's body rules
24
+ * surviving its neighbours in either mount order, and a store outage answered
25
+ * 503 and logged once. The fixture file says what is real and what is
26
+ * substituted; `all-modules-composition.multi.test.mts` boots the same
27
+ * composition on Redis under `core.deployment.mode = "multi"`.
34
28
  *
35
29
  * `knownDefect` marks a contract the composition breaks today; its comment
36
30
  * names the defect. In the monorepo it is `it.fails`: the fix that mends the
37
31
  * defect turns the case red, and turns it into a plain `it`. An outage case
38
- * pins only the part of the #685 rule that is broken, and asserts the rest
32
+ * pins only the part of the outage rule that is broken, and asserts the rest
39
33
  * (`describeOutages` in the fixture).
40
34
  */
41
35
 
42
36
  import { readdirSync, readFileSync } from "node:fs";
43
- import type { AppConfig } from "@o3co/auth-provider-core";
37
+ import { federationsOf } from "@o3co/auth-provider-core";
44
38
  import type express from "express";
45
39
  import request from "supertest";
46
40
  import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
41
+ import type { Switches } from "#/configPath.mjs";
47
42
  import {
48
43
  ALICE,
49
44
  AS_LISTED,
@@ -115,16 +110,22 @@ const TEMPLATE_PACKAGES: Readonly<Record<string, string>> = {
115
110
  "@o3co/auth-provider-federation-google": "googleFederationModule",
116
111
  "@o3co/auth-provider-federation-grants": "federationGrantsModules",
117
112
  "@o3co/auth-provider-federation-oidc": "oidcFederationModule, one per `oidc` section",
118
- "@o3co/auth-provider-foundation": "the HTTP user repository adapter (no module)",
113
+ "@o3co/auth-provider-foundation":
114
+ "the HTTP user repository adapter; foundationMfaFactorStoreModule under MFA_MODE with adapters.mfaFactorStore = store (tools/composition boots it)",
115
+ "@o3co/auth-provider-mfa":
116
+ "mfaModules (mfaTotpFactorModule, mfaRecoveryCodeFactorModule, mfaEmailFactorModule, mfaModule) and mfaResetModule, under MFA_MODE (mfa-switch.test.mts)",
119
117
  "@o3co/auth-provider-oauth":
120
118
  "oauthModule, oauthSessionModule, oauthAuthorizationModule, subjectRevocationServiceModule",
121
119
  "@o3co/auth-provider-redis": "the Redis stores (all-modules-composition.multi.test.mts)",
122
120
  "@o3co/auth-provider-session": "sessionModule, sessionStoreModuleFor",
121
+ "@o3co/auth-provider-standard":
122
+ "standardSmtpMailSenderModule; standardDevelopmentMailSenderModule in development",
123
123
  };
124
124
 
125
125
  /**
126
126
  * Workspace packages the template does not depend on, so nothing here can
127
- * boot them. Each is a module a deployment adds to this manifest by hand —
127
+ * boot them. Each but the test kit, which has no module, is a module a
128
+ * deployment adds to this manifest by hand —
128
129
  * `modules.mts` provides the Redis clients their stores need for that reason
129
130
  * — and `tools/composition` in the monorepo boots each beside every module
130
131
  * here, through this file's fixture.
@@ -136,7 +137,9 @@ const NOT_IN_TEMPLATE: Readonly<Record<string, string>> = {
136
137
  "@o3co/auth-provider-federation-github": "githubFederationModule",
137
138
  "@o3co/auth-provider-mtls": "mtlsModule",
138
139
  "@o3co/auth-provider-oauth-token-exchange": "tokenExchangeModule",
139
- "@o3co/auth-provider-webauthn": "webauthnModule",
140
+ "@o3co/auth-provider-test-kit": "no module: contract suites and fakes, for tests",
141
+ "@o3co/auth-provider-webauthn":
142
+ "webauthnModule, webauthnSessionSubjectModule, webauthnMfaFactorModule",
140
143
  };
141
144
 
142
145
  describe("what the all-modules composition covers", () => {
@@ -173,7 +176,7 @@ describe("what the all-modules composition covers", () => {
173
176
 
174
177
  /** What `buildModules` lists with every switch on, one replica, memory stores. */
175
178
  const ALL_ON_MODULES = [
176
- "sessionStoreModule",
179
+ "session-store",
177
180
  "federation-grant-background",
178
181
  "federation-grants",
179
182
  "oauth",
@@ -181,19 +184,22 @@ const ALL_ON_MODULES = [
181
184
  "oauth-authorization",
182
185
  "jwks",
183
186
  "session",
184
- "federation:google",
185
- "standalone:google-federation-config",
186
- "standalone:oidc-federation-config",
187
- "federation:oidc:oidc",
188
- "standalone:key-store",
187
+ "federation-google",
188
+ "google-federation-config",
189
+ "oidc-federation-config",
190
+ "federation-oidc-oidc",
191
+ "logging",
192
+ "http",
193
+ "key-store",
189
194
  "test:repositories",
190
- "standalone:audit-sink",
191
- "standalone:in-memory-federation-token-store",
195
+ "audit-sink",
196
+ "standard-smtp-mail-sender",
197
+ "standalone-in-memory-federation-token-store",
192
198
  "core-federation-grant-store-memory",
193
199
  "core-federation-grant-intent-store-memory",
194
- "standalone:in-memory-session-stores",
200
+ "standalone-in-memory-session-stores",
195
201
  "core-rate-limiter-memory",
196
- "standalone:in-memory-code-repository",
202
+ "standalone-in-memory-code-repository",
197
203
  "core-access-token-denylist-memory",
198
204
  "core-replay-seen-set-memory",
199
205
  "core-consent-store-memory",
@@ -244,10 +250,10 @@ describe("every module the template can turn on boots together", () => {
244
250
  });
245
251
 
246
252
  it("hands the deployment's logger to every module that answers a request", async () => {
247
- // `app.mts` fills the `logger` slot so that `LOG_LEVEL` and the JSON
253
+ // `app.mts` fills the `logger` slot so that `LOGGING_LEVEL` and the JSON
248
254
  // envelope reach every module; boot hands a module only the slots its
249
255
  // manifest names, so a route or grant module without `logger` writes
250
- // its outage lines to nobody (the session grant did, answering 503).
256
+ // its outage lines to nobody.
251
257
  const { modules } = await boot();
252
258
  const answering = modules.filter(
253
259
  (m) => contributionNames(m, "routes").length + contributionNames(m, "grants").length > 0,
@@ -325,10 +331,10 @@ describe("discovery", () => {
325
331
  * is gone from the document and from the app, and nothing else moves.
326
332
  */
327
333
  const GRANT_SWITCHES: ReadonlyArray<readonly [grant: string, variable: string]> = [
328
- ["session", "OAUTH_GRANTS_SESSION_ENABLED"],
329
- ["authorization_code", "OAUTH_GRANTS_AUTHORIZATION_CODE_ENABLED"],
330
- ["refresh_token", "OAUTH_GRANTS_REFRESH_TOKEN_ENABLED"],
331
- ["client_credentials", "OAUTH_GRANTS_CLIENT_CREDENTIALS_ENABLED"],
334
+ ["session", "OAUTH_SESSION_ENABLED"],
335
+ ["authorization_code", "OAUTH_AUTHORIZATION_GRANTS_AUTHORIZATION_CODE_ENABLED"],
336
+ ["refresh_token", "OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_ENABLED"],
337
+ ["client_credentials", "OAUTH_AUTHORIZATION_GRANTS_CLIENT_CREDENTIALS_ENABLED"],
332
338
  ];
333
339
 
334
340
  it.each(GRANT_SWITCHES)(
@@ -336,7 +342,7 @@ describe("discovery", () => {
336
342
  async (grant, variable) => {
337
343
  const { app } = await boot({ env: { ...SINGLE_ENV, [variable]: "false" } });
338
344
  const doc = (await request(app).get(DISCOVERY_PATHS[0])).body;
339
- expectValidMetadata(doc);
345
+ expectValidMetadata(doc, { authorizationCode: grant !== "authorization_code" });
340
346
  expect([...doc.grant_types_supported].sort()).toEqual(
341
347
  ENABLED_GRANTS.filter((g) => g !== grant),
342
348
  );
@@ -347,11 +353,17 @@ describe("discovery", () => {
347
353
  .send({ grant_type: grant });
348
354
  expect(res.status).toBe(400);
349
355
  expect(res.body.error).toBe("unsupported_grant_type");
356
+ if (grant === "authorization_code") {
357
+ // No grant redeems a code, so none is issued: none is served.
358
+ expect((await request(app).get("/oauth/authorize")).status).toBe(404);
359
+ }
350
360
  },
351
361
  );
352
362
 
353
363
  it("Client ID Metadata Documents off: the flag is gone and nothing else moves", async () => {
354
- const { app } = await boot({ env: { ...SINGLE_ENV, OAUTH_CIMD_ENABLED: "false" } });
364
+ const { app } = await boot({
365
+ env: { ...SINGLE_ENV, OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ENABLED: "false" },
366
+ });
355
367
  const doc = (await request(app).get(DISCOVERY_PATHS[0])).body;
356
368
  expectValidMetadata(doc);
357
369
  expect(doc.client_id_metadata_document_supported).toBeUndefined();
@@ -367,7 +379,7 @@ describe("discovery", () => {
367
379
  await on.handle.dispose();
368
380
  current = undefined;
369
381
 
370
- const { app } = await boot({ env: { ...SINGLE_ENV, CONSENT_STORE_ADAPTER: "none" } });
382
+ const { app } = await boot({ env: { ...SINGLE_ENV, ADAPTERS_CONSENT_STORE: "none" } });
371
383
  const doc = (await request(app).get(DISCOVERY_PATHS[0])).body;
372
384
  expectValidMetadata(doc);
373
385
  expect(doc.client_id_metadata_document_supported).toBeUndefined();
@@ -375,27 +387,30 @@ describe("discovery", () => {
375
387
  });
376
388
 
377
389
  /** The grant connections dropped: they name the OIDC federation. */
378
- const withoutConnections = (config: AppConfig): AppConfig =>
379
- ({ ...config, federationGrants: { ...config.federationGrants, connections: {} } }) as AppConfig;
390
+ const withoutConnections = (config: Switches): Switches =>
391
+ ({
392
+ ...config,
393
+ "federation-grants": { ...config["federation-grants"], connections: {} },
394
+ }) as Switches;
380
395
 
381
396
  const FEATURE_SWITCHES: ReadonlyArray<
382
397
  readonly [
383
398
  feature: string,
384
399
  variable: string,
385
400
  path: string,
386
- config: ((config: AppConfig) => AppConfig) | undefined,
401
+ config: ((config: Switches) => Switches) | undefined,
387
402
  ]
388
403
  > = [
389
404
  ["federation grants", "FEDERATION_GRANTS_ENABLED", "/oauth/federation-grants", undefined],
390
405
  [
391
406
  "the Google federation",
392
- "FEDERATIONS_GOOGLE_ENABLED",
407
+ "CORE_FEDERATIONS_GOOGLE_ENABLED",
393
408
  "/session/oauth/federation/google",
394
409
  undefined,
395
410
  ],
396
411
  [
397
412
  "the OIDC federation",
398
- "FEDERATIONS_OIDC_ENABLED",
413
+ "CORE_FEDERATIONS_OIDC_ENABLED",
399
414
  "/session/oauth/federation/oidc",
400
415
  withoutConnections,
401
416
  ],
@@ -421,7 +436,7 @@ describe("discovery", () => {
421
436
 
422
437
  it("the OIDC federation off while a grant connection names it is refused at boot, naming both", async () => {
423
438
  await expect(
424
- compose({ env: { ...SINGLE_ENV, FEDERATIONS_OIDC_ENABLED: "false" } }),
439
+ compose({ env: { ...SINGLE_ENV, CORE_FEDERATIONS_OIDC_ENABLED: "false" } }),
425
440
  ).rejects.toSatisfy((err: unknown) => {
426
441
  const e = err as { name?: string; cause?: { message?: string } };
427
442
  return (
@@ -554,7 +569,7 @@ describe("every module's primary route answers in the one app", () => {
554
569
 
555
570
  // KNOWN DEFECT (the template's federation config, with the session
556
571
  // package's redirect policy): a federation enabled the way the template
557
- // README documents — its `FEDERATIONS_OIDC_*` variables — boots, and every
572
+ // README documents — its `CORE_FEDERATIONS_OIDC_*` variables — boots, and every
558
573
  // login through it then ends at the callback in `500 misconfiguration`
559
574
  // "client URL not configured", after the user has signed in upstream.
560
575
  // `clientUrl` has no environment form (`application.conf` ships it
@@ -564,10 +579,13 @@ describe("every module's primary route answers in the one app", () => {
564
579
  knownDefect(
565
580
  "a federation enabled from the documented variables alone either refuses to boot or completes a login",
566
581
  async () => {
567
- const withoutLanding = (config: AppConfig): AppConfig => {
568
- const federations = config.federations as Record<string, Record<string, unknown>>;
582
+ const withoutLanding = (config: Switches): Switches => {
583
+ const federations = federationsOf(config) as Record<string, Record<string, unknown>>;
569
584
  const { clientUrl: _dropped, ...oidc } = federations.oidc ?? {};
570
- return { ...config, federations: { ...federations, oidc } } as unknown as AppConfig;
585
+ return {
586
+ ...config,
587
+ core: { ...config.core, federations: { ...federations, oidc } },
588
+ } as unknown as Switches;
571
589
  };
572
590
  let composed: Composition;
573
591
  try {
@@ -746,9 +764,10 @@ describe.each([AS_LISTED, REVERSED] satisfies ModuleOrder[])(
746
764
  );
747
765
 
748
766
  describe("a request body the OAuth endpoints do not parse", () => {
749
- // KNOWN DEFECT (packages/oauth, `createOAuthRouter` in routes.mts): the
750
- // token and introspection handlers read `req.body` without checking that a
751
- // parser ran. A body that is neither a form nor JSON — or none at all —
767
+ // KNOWN DEFECT (packages/oauth, `routes/token.mts`,
768
+ // `routes/introspectCaller.mts` and `routes.mts`): the token and
769
+ // introspection handlers read `req.body` without checking that a parser
770
+ // ran. A body that is neither a form nor JSON — or none at all —
752
771
  // leaves it undefined, the handler throws, and the terminal handler answers
753
772
  // `500 server_error` and logs `unhandled_request_error` for the client's
754
773
  // own mistake. `/oauth/revoke` checks, and answers 400.
@@ -918,7 +937,7 @@ const OUTAGES: readonly OutageCase[] = [
918
937
  .send({ grant_type: "session", scope: "openid" });
919
938
  },
920
939
  answer: { status: 503, error: "temporarily_unavailable" },
921
- event: "session_grant_store_unavailable",
940
+ event: "session_admission_unavailable",
922
941
  },
923
942
  {
924
943
  module: "oauth (consent step)",
@@ -933,7 +952,7 @@ const OUTAGES: readonly OutageCase[] = [
933
952
  event: "authorize_consent_store_unavailable",
934
953
  defects: {
935
954
  "store-field":
936
- "packages/oauth `routes/authorize.mts`: `authorize_consent_store_unavailable` carries `clientId` and `err` but no `store` / `step` / `site` field",
955
+ "packages/oauth `routes/authorizeConsent.mts`: `authorize_consent_store_unavailable` carries `clientId` and `err` but no `store` / `step` / `site` field",
937
956
  },
938
957
  },
939
958
  {
@@ -949,7 +968,7 @@ const OUTAGES: readonly OutageCase[] = [
949
968
  event: "authorize_pending_consent_store_unavailable",
950
969
  defects: {
951
970
  "store-field":
952
- "packages/oauth `routes/authorize.mts`: `authorize_pending_consent_store_unavailable` carries `clientId` and `err` but no `store` / `step` / `site` field",
971
+ "packages/oauth `routes/authorizeConsent.mts`: `authorize_pending_consent_store_unavailable` carries `clientId` and `err` but no `store` / `step` / `site` field",
953
972
  },
954
973
  },
955
974
  {
@@ -1006,7 +1025,7 @@ const OUTAGES: readonly OutageCase[] = [
1006
1025
  outage.down = true;
1007
1026
  return (await login(app)).res;
1008
1027
  },
1009
- // Intended (#296): the login is not denied for a best-effort index write,
1028
+ // Intended: the login is not denied for a best-effort index write,
1010
1029
  // and the missed write is logged at error because a token minted from
1011
1030
  // this session keeps introspecting active after a credential change —
1012
1031
  // the runbook pages on it.
@@ -1034,7 +1053,7 @@ const OUTAGES: readonly OutageCase[] = [
1034
1053
  {
1035
1054
  module: "core (rate-limit guard)",
1036
1055
  slot: "rateLimiter",
1037
- surface: "/oauth/token (rateLimit.failMode = closed)",
1056
+ surface: "/oauth/token (redis-rate-limiter.failMode = closed)",
1038
1057
  run: async (app, outage) => {
1039
1058
  outage.down = true;
1040
1059
  return tokenRequest(app, M2M).send({ grant_type: "client_credentials", scope: "api.read" });
@@ -1094,7 +1113,7 @@ const OUTAGES: readonly OutageCase[] = [
1094
1113
  ];
1095
1114
 
1096
1115
  describeOutages(
1097
- "a store or repository outage answers 503 and is logged once, at error (#685)",
1116
+ "a store or repository outage gets the answer its case pins, and is logged once, at error",
1098
1117
  OUTAGES,
1099
1118
  (outage) => compose({ outage }),
1100
1119
  );