@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
@@ -19,43 +19,38 @@
19
19
  * template can turn on, switched on together from the shipped HOCON, and
20
20
  * mounted the way `app.mts` mounts it.
21
21
  *
22
- * What is real: the configuration (`config/*.conf` over core's
23
- * `reference.conf`, resolved with the environment a deployment would export),
24
- * `buildModules`, core's `createApp`, every module and store it selects, the
25
- * key store, the audit sink, `helmet` and the terminal error handler.
22
+ * Real: the configuration (`config/*.conf` over every loaded package's
23
+ * `reference.conf`, core's last, under the environment a deployment would
24
+ * export) read in `app.mts`'s two phases (`readSwitches`, then
25
+ * `resolveForBoot` for `createApp`), `buildModules`, core's `createApp`, every
26
+ * module and store it selects, the key store, the audit sink, `helmet` and the
27
+ * terminal error handler.
26
28
  *
27
- * What is substituted, and why:
29
+ * Substituted (the first two are overrides `buildModules` offers):
30
+ * - client and user repositories: in memory, not YAML off disk;
31
+ * - the refresh-token family store under `core.deployment.mode = "single"`: memory,
32
+ * not the shipped Redis, so the single-replica boot opens no sockets;
33
+ * - upstream identity providers: core's fake OpenID Provider, through the
34
+ * `fetch` option the Google and OIDC federation type modules take: each is
35
+ * the module `buildModules` lists, built with that option, and the entries
36
+ * it handles are read as shipped;
37
+ * - configuration with no environment form (grant connections, a key ring, a
38
+ * landing URL): one more HOCON layer above the shipped ones.
28
39
  *
29
- * - The client and user repositories — `repositoriesModule` reads YAML files
30
- * off disk; the in-memory repositories carry the registrations the flows
31
- * need. `buildModules` offers this override for the purpose.
32
- * - The refresh-token family store under `deployment.mode = "single"` — the
33
- * shipped composition always puts it on Redis; the memory store keeps the
34
- * single-replica boot free of sockets. `buildModules` offers this override.
35
- * - The upstream identity providers — core's fake OpenID Provider behind a
36
- * `fetch`, handed to the Google and OIDC adapters through the `fetch` option
37
- * each adapter takes for exactly this. The config bridges' values are read
38
- * through the template's own bridge (`googleFederationConfigModule`) and the
39
- * OIDC package's reader, and only `fetch` is added.
40
- * - Configuration with no environment form (a map of grant connections, a key
41
- * ring, a landing URL): laid over the resolved config, as an operator would
42
- * write it in `application.conf`.
43
- *
44
- * `tools/composition` in the monorepo imports this file and boots the same
40
+ * `tools/composition` in the monorepo imports this file to boot the same
45
41
  * composition with the workspace's other modules added (`ComposeOptions`'s
46
- * `referenceConfs`, `extraModules`, `extraOverrides`, `extraClients` and
47
- * `extraUsers`), so the two suites share one fixture rather than two copies
48
- * that drift.
42
+ * `extra*` options), so the two suites share one fixture.
49
43
  */
50
44
 
51
45
  import { createHash, generateKeyPairSync, randomBytes } from "node:crypto";
52
- import { readFileSync } from "node:fs";
46
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
53
47
  import http from "node:http";
54
48
  import type { AddressInfo } from "node:net";
49
+ import { tmpdir } from "node:os";
50
+ import { join } from "node:path";
55
51
  import { fileURLToPath } from "node:url";
56
52
  import {
57
53
  type AppConfig,
58
- AppConfigSchema,
59
54
  ClientEntrySchema,
60
55
  createApp,
61
56
  defineModule,
@@ -67,15 +62,22 @@ import {
67
62
  terminalErrorHandler,
68
63
  } from "@o3co/auth-provider-core";
69
64
  import { createFakeIdp, type FakeIdp } from "@o3co/auth-provider-core/testing";
70
- import { parseFile } from "@o3co/ts.hocon";
71
- import { validate } from "@o3co/ts.hocon/zod";
65
+ import { googleFederationTypeModule } from "@o3co/auth-provider-federation-google";
66
+ import { oidcFederationTypeModule } from "@o3co/auth-provider-federation-oidc";
72
67
  import express from "express";
73
68
  import helmet from "helmet";
74
69
  import request from "supertest";
75
70
  import { beforeAll, describe, expect, it } from "vitest";
76
71
  import { buildModules } from "#/buildModules.mjs";
77
- import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "#/configPath.mjs";
78
- import { googleFederationConfigModule, oidcFederationConfigModule } from "#/modules.mjs";
72
+ import {
73
+ configDefaultsFor,
74
+ type OwnLayers,
75
+ readOwnLayers,
76
+ readSwitches,
77
+ resolveConfigPaths,
78
+ resolveForBoot,
79
+ type Switches,
80
+ } from "#/configPath.mjs";
79
81
 
80
82
  export const ISSUER = "https://auth.test";
81
83
  const OIDC_ISSUER = "https://idp.test";
@@ -93,47 +95,50 @@ const ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
93
95
  * One replica, every store in memory, every feature the template can switch
94
96
  * on switched on: the four token grants, the consent step with Client ID
95
97
  * Metadata Documents, federation grants, the Google federation and the
96
- * shipped generic OIDC one.
98
+ * shipped generic OIDC one. MFA, on by default, is switched off: the suites
99
+ * about MFA turn it on with `MFA_MODE`.
97
100
  */
98
101
  export const SINGLE_ENV: Readonly<Record<string, string>> = {
102
+ MFA_MODE: "off",
99
103
  OAUTH_JWT_ISSUER: ISSUER,
100
104
  // The shipped default algorithm (EdDSA), with the key pair inline.
101
- OAUTH_JWT_PRIVATE_KEY: signingKey.privateKey,
102
- OAUTH_JWT_PUBLIC_KEY: signingKey.publicKey,
103
- SESSION_SECRET: "all-modules-composition-session.at-least-32-bytes.ok",
104
- SESSION_SECURE: "false",
105
- SESSION_NAME: "auth.session",
106
- DEPLOYMENT_MODE: "single",
107
- SESSION_STORAGE_TYPE: "memory",
108
- USER_SESSION_STORES_ADAPTER: "memory",
109
- RATE_LIMITER_ADAPTER: "memory",
110
- OAUTH_CODE_ADAPTER: "memory",
111
- ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
112
- REPLAY_SEEN_SET_ADAPTER: "memory",
113
- FEDERATION_TOKEN_STORE_TYPE: "memory",
114
- CONSENT_STORE_ADAPTER: "memory",
115
- FEDERATION_GRANT_STORE_ADAPTER: "memory",
116
- FEDERATION_GRANT_INTENT_STORE_ADAPTER: "memory",
105
+ KEY_STORE_LOCAL_PRIVATE_KEY: signingKey.privateKey,
106
+ KEY_STORE_LOCAL_PUBLIC_KEY: signingKey.publicKey,
107
+ SESSION_STORE_SECRET: "all-modules-composition-session.at-least-32-bytes.ok",
108
+ SESSION_STORE_SECURE: "false",
109
+ SESSION_STORE_NAME: "auth.session",
110
+ CORE_DEPLOYMENT_MODE: "single",
111
+ SESSION_STORE_STORAGE_TYPE: "memory",
112
+ ADAPTERS_USER_SESSION_STORES: "memory",
113
+ ADAPTERS_RATE_LIMITER: "memory",
114
+ ADAPTERS_ATTEMPT_COUNTER: "memory",
115
+ ADAPTERS_CODE_REPOSITORY: "memory",
116
+ ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
117
+ ADAPTERS_REPLAY_SEEN_SET: "memory",
118
+ ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
119
+ ADAPTERS_CONSENT_STORE: "memory",
120
+ ADAPTERS_FEDERATION_GRANT_STORE: "memory",
121
+ ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "memory",
117
122
  // The user repository is replaced (see the header); `yaml` keeps the
118
123
  // shipped `http` adapter's URL requirements out of config validation.
119
- CLIENT_USER_TYPE: "yaml",
120
- OAUTH_GRANTS_SESSION_ENABLED: "true",
121
- OAUTH_GRANTS_AUTHORIZATION_CODE_ENABLED: "true",
122
- OAUTH_GRANTS_REFRESH_TOKEN_ENABLED: "true",
123
- OAUTH_GRANTS_CLIENT_CREDENTIALS_ENABLED: "true",
124
- OAUTH_CIMD_ENABLED: "true",
124
+ ADAPTERS_USER_REPOSITORY: "yaml",
125
+ OAUTH_SESSION_ENABLED: "true",
126
+ OAUTH_AUTHORIZATION_GRANTS_AUTHORIZATION_CODE_ENABLED: "true",
127
+ OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_ENABLED: "true",
128
+ OAUTH_AUTHORIZATION_GRANTS_CLIENT_CREDENTIALS_ENABLED: "true",
129
+ OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ENABLED: "true",
125
130
  FEDERATION_GRANTS_ENABLED: "true",
126
131
  FEDERATION_GRANTS_CONSENT_URL: "/consent/grants",
127
132
  // The in-memory user repository covers no registration; `required` is a
128
133
  // Store's statement that it does (see the federation-grants README).
129
134
  FEDERATION_GRANTS_IDENTITY_LOOKUP: "unsupported",
130
- FEDERATIONS_GOOGLE_ENABLED: "true",
131
- FEDERATIONS_GOOGLE_CLIENT_ID: "google-client",
132
- FEDERATIONS_GOOGLE_CLIENT_SECRET: "google-secret",
133
- FEDERATIONS_OIDC_ENABLED: "true",
134
- FEDERATIONS_OIDC_ISSUER: OIDC_ISSUER,
135
- FEDERATIONS_OIDC_CLIENT_ID: "oidc-client",
136
- FEDERATIONS_OIDC_CLIENT_SECRET: "oidc-secret",
135
+ CORE_FEDERATIONS_GOOGLE_ENABLED: "true",
136
+ CORE_FEDERATIONS_GOOGLE_CLIENT_ID: "google-client",
137
+ CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET: "google-secret",
138
+ CORE_FEDERATIONS_OIDC_ENABLED: "true",
139
+ CORE_FEDERATIONS_OIDC_ISSUER: OIDC_ISSUER,
140
+ CORE_FEDERATIONS_OIDC_CLIENT_ID: "oidc-client",
141
+ CORE_FEDERATIONS_OIDC_CLIENT_SECRET: "oidc-secret",
137
142
  };
138
143
 
139
144
  /**
@@ -142,20 +147,22 @@ export const SINGLE_ENV: Readonly<Record<string, string>> = {
142
147
  */
143
148
  export const MULTI_ENV: Readonly<Record<string, string>> = {
144
149
  ...SINGLE_ENV,
145
- DEPLOYMENT_MODE: "multi",
146
- SESSION_STORAGE_TYPE: "redis",
147
- SESSION_STORAGE_REDIS_URL: "redis://redis.test:6379",
148
- REFRESH_TOKEN_FAMILY_STORE_REDIS_URL: "redis://redis.test:6379",
149
- USER_SESSION_STORES_ADAPTER: "redis",
150
- RATE_LIMITER_ADAPTER: "redis",
151
- OAUTH_CODE_ADAPTER: "redis",
152
- ACCESS_TOKEN_DENYLIST_ADAPTER: "redis",
153
- REPLAY_SEEN_SET_ADAPTER: "redis",
154
- FEDERATION_TOKEN_STORE_TYPE: "redis",
150
+ CORE_DEPLOYMENT_MODE: "multi",
151
+ SESSION_STORE_STORAGE_TYPE: "redis",
152
+ SESSION_STORE_STORAGE_REDIS_URL: "redis://redis.test:6379",
153
+ REDIS_CLIENTS_URL: "redis://redis.test:6379",
154
+ ADAPTERS_USER_SESSION_STORES: "redis",
155
+ ADAPTERS_RATE_LIMITER: "redis",
156
+ // The login's attempt counter, shared: per process, `multi` refuses the login.
157
+ ADAPTERS_ATTEMPT_COUNTER: "redis",
158
+ ADAPTERS_CODE_REPOSITORY: "redis",
159
+ ADAPTERS_ACCESS_TOKEN_DENYLIST: "redis",
160
+ ADAPTERS_REPLAY_SEEN_SET: "redis",
161
+ ADAPTERS_FEDERATION_TOKEN_STORE: "redis",
155
162
  REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: ENCRYPTION_KEY,
156
- CONSENT_STORE_ADAPTER: "redis",
157
- FEDERATION_GRANT_STORE_ADAPTER: "redis",
158
- FEDERATION_GRANT_INTENT_STORE_ADAPTER: "redis",
163
+ ADAPTERS_CONSENT_STORE: "redis",
164
+ ADAPTERS_FEDERATION_GRANT_STORE: "redis",
165
+ ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "redis",
159
166
  };
160
167
 
161
168
  /** The grant connection the federation-grant flows use, on the shipped `oidc` federation. */
@@ -163,46 +170,80 @@ export const CONNECTION = "calendar";
163
170
 
164
171
  const configDir = fileURLToPath(new URL("../../config", import.meta.url));
165
172
 
173
+ const quoted = (value: string) => JSON.stringify(value);
174
+
166
175
  /**
167
- * The shipped HOCON under `env`, with what has no environment form laid over
168
- * it: the federations' landing page, the grant key ring and one grant
169
- * connection. `referenceConfs` — other packages' `reference.conf` files — are
170
- * layered between `application.conf` and core's, as a deployment layers them.
176
+ * What has no environment form — the federations' landing page and one grant
177
+ * connection — as an operator writes it: a HOCON file of their own, the
178
+ * highest layer. Written once per process.
179
+ */
180
+ const OPERATOR_LAYER: string = (() => {
181
+ const file = join(mkdtempSync(join(tmpdir(), "all-modules-composition-")), "operator.conf");
182
+ writeFileSync(
183
+ file,
184
+ `core.federations.google.clientUrl = ${quoted(FEDERATION_LANDING)}
185
+ core.federations.oidc.clientUrl = ${quoted(FEDERATION_LANDING)}
186
+ federation-grants {
187
+ connections {
188
+ ${CONNECTION} {
189
+ federation = "oidc"
190
+ scopes = ["openid", "offline_access", "calendar.read"]
191
+ boundary = "production"
192
+ maxAccessTokenLifetime = 3600
193
+ callbackURL = ${quoted(`${ISSUER}/session/federation-grants/callback/${CONNECTION}`)}
194
+ }
195
+ }
196
+ }
197
+ `,
198
+ );
199
+ return file;
200
+ })();
201
+
202
+ /** `text` in a file of its own, for a layer above the composition's files. */
203
+ function hoconFile(text: string): string {
204
+ const file = join(mkdtempSync(join(tmpdir(), "all-modules-operator-")), "operator.conf");
205
+ writeFileSync(file, text);
206
+ return file;
207
+ }
208
+
209
+ /**
210
+ * The Redis grant store's key ring, which has no environment form either, in
211
+ * a layer of its own: an operator writes it where that store is installed,
212
+ * since written for a store the composition does not load it reaches nothing
213
+ * and boot may name its section. Written once per process.
214
+ */
215
+ const GRANT_KEY_RING_LAYER: string = (() => {
216
+ const file = join(mkdtempSync(join(tmpdir(), "all-modules-composition-")), "grant-keys.conf");
217
+ writeFileSync(
218
+ file,
219
+ `redis-federation-grant-store.encryptionKeys = [{ id = "k-test", key = ${quoted(ENCRYPTION_KEY)} }]\n`,
220
+ );
221
+ return file;
222
+ })();
223
+
224
+ /**
225
+ * The composition's own files under `env`, highest first: the operator's
226
+ * layers — the grant key ring among them where `env` turns federation grants
227
+ * on and puts their store on Redis — then the shipped production ones.
228
+ */
229
+ export function ownFiles(env: Readonly<Record<string, string>> = SINGLE_ENV): string[] {
230
+ const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
231
+ const grantStoreOnRedis =
232
+ env.FEDERATION_GRANTS_ENABLED === "true" && env.ADAPTERS_FEDERATION_GRANT_STORE === "redis";
233
+ const keyRing = grantStoreOnRedis ? [GRANT_KEY_RING_LAYER] : [];
234
+ return [OPERATOR_LAYER, ...keyRing, envConfPath, applicationConfPath];
235
+ }
236
+
237
+ /**
238
+ * Phase one, as `app.mts` reads it: the switches `buildModules` chooses the
239
+ * modules by, from the composition's own files under `env` over the
240
+ * template's `reference.conf`.
171
241
  */
172
242
  export function resolveConfig(
173
243
  env: Readonly<Record<string, string>>,
174
- referenceConfs: readonly string[] = [],
175
- ): AppConfig {
176
- const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
177
- const read = (path: string) => parseFile(path, { env: { ...env } });
178
- const layered = [
179
- applicationConfPath,
180
- ...referenceConfs,
181
- resolveLibraryReferenceConfPath(),
182
- ].reduce((config, path) => config.withFallback(read(path)), read(envConfPath));
183
- const resolved = validate(layered, AppConfigSchema);
184
- const federations = resolved.federations as Record<string, Record<string, unknown>>;
185
- return {
186
- ...resolved,
187
- federations: {
188
- ...federations,
189
- google: { ...federations.google, clientUrl: FEDERATION_LANDING },
190
- oidc: { ...federations.oidc, clientUrl: FEDERATION_LANDING },
191
- },
192
- federationGrants: {
193
- ...resolved.federationGrants,
194
- encryptionKeys: [{ id: "k-test", key: ENCRYPTION_KEY }],
195
- connections: {
196
- [CONNECTION]: {
197
- federation: "oidc",
198
- scopes: ["openid", "offline_access", "calendar.read"],
199
- boundary: "production",
200
- maxAccessTokenLifetime: 3600,
201
- callbackURL: `${ISSUER}/session/federation-grants/callback/${CONNECTION}`,
202
- },
203
- },
204
- },
205
- } as unknown as AppConfig;
244
+ own: OwnLayers = readOwnLayers(ownFiles(env), { env }),
245
+ ): Switches {
246
+ return readSwitches(own);
206
247
  }
207
248
 
208
249
  // ---------------------------------------------------------------------------
@@ -327,30 +368,55 @@ let upstreams: Promise<{ fakes: Upstreams; reset: () => void }> | undefined;
327
368
  * run if one did.
328
369
  */
329
370
  export async function sharedUpstreams(): Promise<Upstreams> {
371
+ const { fakes, reset } = await madeUpstreams();
372
+ reset();
373
+ return fakes;
374
+ }
375
+
376
+ /** The two fake upstreams, made on first use and never reset here. */
377
+ function madeUpstreams(): Promise<{ fakes: Upstreams; reset: () => void }> {
330
378
  upstreams ??= createUpstreams().then((fakes) => ({
331
379
  fakes,
332
380
  reset: resettable(fakes.oidc, fakes.google),
333
381
  }));
334
- const { fakes, reset } = await upstreams;
335
- reset();
336
- return fakes;
382
+ return upstreams;
337
383
  }
338
384
 
385
+ /** A `fetch` that reaches the shared fake upstream `name`. */
386
+ const upstreamFetch =
387
+ (name: keyof Upstreams): typeof fetch =>
388
+ async (input, init) =>
389
+ (await madeUpstreams()).fakes[name].fetch(input, init);
390
+
339
391
  /**
340
- * Snapshots each fake's settings — every property that is not a function —
341
- * and returns what puts them back: primitives reassigned, objects restored in
392
+ * The federation type modules `buildModules` lists, by name, each built as
393
+ * the template builds it but with a `fetch` that reaches its fake upstream.
394
+ */
395
+ const UPSTREAM_TYPE_MODULES: ReadonlyMap<string, () => Module> = new Map([
396
+ [
397
+ googleFederationTypeModule().name,
398
+ () => googleFederationTypeModule({ fetch: upstreamFetch("google") }),
399
+ ],
400
+ [
401
+ oidcFederationTypeModule().name,
402
+ () => oidcFederationTypeModule({ fetch: upstreamFetch("oidc") }),
403
+ ],
404
+ ]);
405
+
406
+ /**
407
+ * Snapshots each fake's settings (every property that is not a function) and
408
+ * returns what puts them back: primitives reassigned, objects restored in
342
409
  * place (a fake may hold its own reference to one), recorded requests
343
410
  * cleared. A fake's signing key is checked, not restored: a rotated key is
344
411
  * refused.
345
412
  *
346
- * What a fake keeps in its closure is not restored either: core's fake IdP
347
- * remembers whether consent was granted (`consentGranted`), how many codes it
348
- * issued (`codesIssued`) and every authorization it recorded. Codes and
349
- * authorizations are keyed per login, so a later boot's login is unaffected;
350
- * consent is not, and it decides Google's refresh token only under
351
- * `refreshTokenOnlyOnConsent`. That knob is therefore forbidden on a shared
352
- * fake — the next reset refuses to run if a test set it; a test that needs it
353
- * makes a fake of its own with `createFakeIdp`.
413
+ * A fake's closure is not restored: core's fake IdP remembers whether consent
414
+ * was granted (`consentGranted`), how many codes it issued (`codesIssued`) and
415
+ * every authorization it recorded.
416
+ * Codes and authorizations are keyed per login; consent is not, and it decides
417
+ * Google's refresh token only under `refreshTokenOnlyOnConsent`. That knob is
418
+ * therefore forbidden on a shared fake (the next reset refuses to run if a test
419
+ * set it); a test that needs it makes its own fake with `createFakeIdp`.
354
420
  */
355
421
  export function resettable(...fakes: readonly object[]): () => void {
356
422
  const snapshots = fakes.map((fake) => ({
@@ -399,7 +465,7 @@ async function createUpstreams(): Promise<Upstreams> {
399
465
  oidc: await createFakeIdp({
400
466
  issuer: OIDC_ISSUER,
401
467
  discovery: true,
402
- clientId: SINGLE_ENV.FEDERATIONS_OIDC_CLIENT_ID,
468
+ clientId: SINGLE_ENV.CORE_FEDERATIONS_OIDC_CLIENT_ID,
403
469
  sub: OIDC_SUB,
404
470
  }),
405
471
  // Google's endpoints are fixed in the adapter, not discovered.
@@ -409,58 +475,12 @@ async function createUpstreams(): Promise<Upstreams> {
409
475
  tokenEndpoint: "https://oauth2.googleapis.com/token",
410
476
  jwksUri: "https://www.googleapis.com/oauth2/v3/certs",
411
477
  userinfoEndpoint: "https://www.googleapis.com/oauth2/v3/userinfo",
412
- clientId: SINGLE_ENV.FEDERATIONS_GOOGLE_CLIENT_ID,
478
+ clientId: SINGLE_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_ID,
413
479
  sub: GOOGLE_SUB,
414
480
  }),
415
481
  };
416
482
  }
417
483
 
418
- /**
419
- * What one of the template's config bridges provides for `config`. Read
420
- * through a record, not the typed slot: a program that loads this file without
421
- * a federation package's ComponentMap augmentation (`tools/composition` does)
422
- * has no key to name.
423
- */
424
- const bridged = <T,>(module: Module, slot: string, config: AppConfig): T => {
425
- const provider = (module.provides as Record<string, unknown> | undefined)?.[slot];
426
- if (typeof provider !== "function") throw new Error(`${module.name} provides no ${slot}`);
427
- return (provider as (deps: { config: AppConfig }) => T)({ config });
428
- };
429
-
430
- /**
431
- * The two federation config slots, read by the template's own bridges
432
- * (`oidcFederationConfigModule`, `googleFederationConfigModule`), with each
433
- * adapter's `fetch` pointed at its fake upstream. Only for the federations the
434
- * config enables — which is when `buildModules` lists the bridges.
435
- */
436
- async function federationOverrides(
437
- config: AppConfig,
438
- upstreams: Upstreams,
439
- ): Promise<Record<string, unknown>> {
440
- const overrides: Record<string, unknown> = {};
441
- const modules = buildModules(config).map((m) => m.name);
442
- if (modules.includes(oidcFederationConfigModule.name)) {
443
- const oidc = bridged<Record<string, object>>(
444
- oidcFederationConfigModule,
445
- "oidcFederationConfigs",
446
- config,
447
- );
448
- overrides.oidcFederationConfigs = Object.fromEntries(
449
- Object.entries(oidc).map(([name, entry]) => [
450
- name,
451
- { ...entry, fetch: upstreams.oidc.fetch },
452
- ]),
453
- );
454
- }
455
- if (modules.includes(googleFederationConfigModule.name)) {
456
- overrides.googleFederationConfig = {
457
- ...bridged<object>(googleFederationConfigModule, "googleFederationConfig", config),
458
- fetch: upstreams.google.fetch,
459
- };
460
- }
461
- return overrides;
462
- }
463
-
464
484
  // ---------------------------------------------------------------------------
465
485
  // Logger spy
466
486
  // ---------------------------------------------------------------------------
@@ -567,18 +587,30 @@ export type ModuleOrder = typeof AS_LISTED | typeof REVERSED;
567
587
 
568
588
  export interface ComposeOptions {
569
589
  readonly env?: Readonly<Record<string, string>>;
570
- /** Other packages' `reference.conf` files, layered above core's (see `resolveConfig`). */
571
- readonly referenceConfs?: readonly string[];
590
+ /** The name the configuration was selected by, as `app.mts` hands `buildModules` it; `production` by default. */
591
+ readonly environment?: string;
592
+ /** HOCON an operator writes above the composition's own files, read in both phases. */
593
+ readonly operatorHocon?: string;
594
+ /** The deployment's own mail sender modules, handed to `buildModules` as `mailSenderModules`. */
595
+ readonly mailSenderModules?: readonly Module[];
572
596
  /** Modules added after the template's own, before the order and the outage apply. */
573
- readonly extraModules?: (config: AppConfig) => readonly Module[];
574
- /** Components laid over the boot's, beside the federation config slots. */
575
- readonly extraOverrides?: (config: AppConfig) => Record<string, unknown>;
597
+ readonly extraModules?: (config: Switches) => readonly Module[];
598
+ /** Components laid over the boot's (`overrideComponents`). */
599
+ readonly extraOverrides?: (config: Switches) => Record<string, unknown>;
576
600
  /** Client registrations beside the fixture's own, as `ClientEntrySchema` input. */
577
601
  readonly extraClients?: Readonly<Record<string, Record<string, unknown>>>;
578
602
  /** Users beside the fixture's own, keyed by username. */
579
603
  readonly extraUsers?: Readonly<Record<string, Record<string, unknown>>>;
580
- /** Adjust the resolved config before anything reads it. */
604
+ /**
605
+ * Adjust what `createApp` is handed, as resolved, as an operator's own
606
+ * layer would.
607
+ */
581
608
  readonly config?: (config: AppConfig) => AppConfig;
609
+ /**
610
+ * Adjust phase one's switches: what only phase one reads, such as the
611
+ * Store transport settings a hand-built root passes.
612
+ */
613
+ readonly switches?: (switches: Switches) => Switches;
582
614
  readonly order?: ModuleOrder;
583
615
  readonly outage?: { readonly slot: string; readonly outage: Outage };
584
616
  /** Keep the shipped Redis refresh-token family store (the `multi` boot). */
@@ -593,15 +625,18 @@ export interface ComposeOptions {
593
625
  }
594
626
 
595
627
  /** The module list the template boots for `config`, as `app.mts` builds it. */
596
- export function composedModules(config: AppConfig, options: ComposeOptions = {}): Module[] {
628
+ export function composedModules(config: Switches, options: ComposeOptions = {}): Module[] {
597
629
  let modules = [
598
630
  ...buildModules(config, {
599
- environment: "production",
631
+ environment: options.environment ?? "production",
600
632
  repositoriesModule: testRepositoriesModule(options.extraClients, options.extraUsers),
633
+ ...(options.mailSenderModules === undefined
634
+ ? {}
635
+ : { mailSenderModules: options.mailSenderModules }),
601
636
  ...(options.shippedRefreshTokenFamilyStore
602
637
  ? {}
603
638
  : { refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule] }),
604
- }),
639
+ }).map((module) => UPSTREAM_TYPE_MODULES.get(module.name)?.() ?? module),
605
640
  ...(options.extraModules?.(config) ?? []),
606
641
  ];
607
642
  if (options.outage) modules = withOutage(modules, options.outage.slot, options.outage.outage);
@@ -612,7 +647,10 @@ export function composedModules(config: AppConfig, options: ComposeOptions = {})
612
647
  export interface Composition {
613
648
  readonly app: express.Express;
614
649
  readonly handle: Awaited<ReturnType<typeof createApp>>;
650
+ /** The configuration boot parsed: what the handle's `config` slot holds. */
615
651
  readonly config: AppConfig;
652
+ /** What `createApp` was handed: the configuration as resolved (phase two), unparsed. */
653
+ readonly resolved: AppConfig;
616
654
  readonly modules: readonly Module[];
617
655
  readonly logger: RecordingLogger;
618
656
  readonly upstreams: Upstreams;
@@ -625,21 +663,40 @@ export interface Composition {
625
663
  * boot's `logger` component.
626
664
  */
627
665
  export async function compose(options: ComposeOptions = {}): Promise<Composition> {
628
- const base = resolveConfig(options.env ?? SINGLE_ENV, options.referenceConfs);
629
- const config = options.config ? options.config(base) : base;
666
+ const env = options.env ?? SINGLE_ENV;
667
+ // The composition's own layers, read once for both phases, as `app.mts`
668
+ // reads them. Phase one: the switches the modules are chosen by.
669
+ const own = readOwnLayers(
670
+ options.operatorHocon === undefined
671
+ ? ownFiles(env)
672
+ : [hoconFile(options.operatorHocon), ...ownFiles(env)],
673
+ { env },
674
+ );
675
+ const switches = resolveConfig(env, own);
676
+ const config = options.switches ? options.switches(switches) : switches;
630
677
  const fakes = await sharedUpstreams();
631
678
  const modules = composedModules(config, options);
632
679
  const logger = createRecordingLogger();
680
+ // Phase two: the configuration as resolved over every loaded package's
681
+ // reference.conf, which createApp parses once, and its defaults.
682
+ const forBoot = resolveForBoot(own, modules, config);
683
+ const resolved = options.config ? options.config(forBoot) : forBoot;
633
684
  const handle = await createApp({
634
685
  modules,
635
- bootstrapComponents: { config, pathResolver: (s) => s, logger },
636
- overrideComponents: {
637
- ...(await federationOverrides(config, fakes)),
638
- ...options.extraOverrides?.(config),
639
- } as never,
686
+ bootstrapComponents: {
687
+ config: resolved,
688
+ configDefaults: configDefaultsFor(modules),
689
+ pathResolver: (s) => s,
690
+ logger,
691
+ },
692
+ overrideComponents: { ...options.extraOverrides?.(config) } as never,
640
693
  });
694
+ const parsed = handle.components.config;
695
+ if (parsed === undefined) throw new Error("createApp booted without the parsed configuration");
696
+ const httpSettings = handle.components.httpSettings;
697
+ if (httpSettings === undefined) throw new Error("createApp booted without httpSettings");
641
698
  const app = express();
642
- app.set("trust proxy", config.http.trustProxy);
699
+ app.set("trust proxy", httpSettings.trustProxy);
643
700
  app.use(
644
701
  helmet({
645
702
  contentSecurityPolicy: { directives: { defaultSrc: ["'none'"], frameAncestors: ["'none'"] } },
@@ -647,7 +704,7 @@ export async function compose(options: ComposeOptions = {}): Promise<Composition
647
704
  );
648
705
  app.use(handle.router);
649
706
  if (options.terminalErrorHandler !== false) app.use(terminalErrorHandler(logger));
650
- return { app, handle, config, modules, logger, upstreams: fakes };
707
+ return { app, handle, config: parsed, resolved, modules, logger, upstreams: fakes };
651
708
  }
652
709
 
653
710
  // ---------------------------------------------------------------------------
@@ -765,12 +822,17 @@ export const DISCOVERY_PATHS = [
765
822
  /**
766
823
  * What RFC 8414 §2 and OpenID Connect Discovery §3 require of the document
767
824
  * this composition serves, and what each advertised URL must be: https, on
768
- * the issuer's origin.
825
+ * the issuer's origin. `authorizationCode` says whether the composition
826
+ * registers that grant (it does unless a test switches it off): with it the
827
+ * document names the authorization endpoint and lists the code response type
828
+ * and PKCE; without it, none of them.
769
829
  */
770
- export function expectValidMetadata(doc: Record<string, unknown>): void {
830
+ export function expectValidMetadata(
831
+ doc: Record<string, unknown>,
832
+ { authorizationCode = true }: { readonly authorizationCode?: boolean } = {},
833
+ ): void {
771
834
  expect(doc.issuer).toBe(ISSUER);
772
835
  for (const field of [
773
- "authorization_endpoint",
774
836
  "token_endpoint",
775
837
  "jwks_uri",
776
838
  "response_types_supported",
@@ -786,13 +848,20 @@ export function expectValidMetadata(doc: Record<string, unknown>): void {
786
848
  expect(url.search + url.hash, field).toBe("");
787
849
  }
788
850
  if (field.endsWith("_supported") && Array.isArray(value)) {
789
- expect(value.length, field).toBeGreaterThan(0);
851
+ if (field !== "response_types_supported") expect(value.length, field).toBeGreaterThan(0);
790
852
  for (const entry of value) expect(typeof entry, field).toBe("string");
791
853
  expect(new Set(value).size, `${field} repeats a value`).toBe(value.length);
792
854
  }
793
855
  }
794
- expect(doc.response_types_supported).toEqual(["code"]);
795
- expect(doc.code_challenge_methods_supported).toEqual(["S256"]);
856
+ if (authorizationCode) {
857
+ expect(doc.authorization_endpoint).toBe(`${ISSUER}/oauth/authorize`);
858
+ expect(doc.response_types_supported).toEqual(["code"]);
859
+ expect(doc.code_challenge_methods_supported).toEqual(["S256"]);
860
+ } else {
861
+ expect(doc).not.toHaveProperty("authorization_endpoint");
862
+ expect(doc.response_types_supported).toEqual([]);
863
+ expect(doc).not.toHaveProperty("code_challenge_methods_supported");
864
+ }
796
865
  }
797
866
 
798
867
  // ---------------------------------------------------------------------------
@@ -937,7 +1006,7 @@ export const TOO_LARGE = { error: "invalid_request", error_description: "body_to
937
1006
  // ---------------------------------------------------------------------------
938
1007
 
939
1008
  /**
940
- * The parts of the #685 outage rule, each checked on its own so a case pins
1009
+ * The parts of the outage rule, each checked on its own so a case pins
941
1010
  * only what is broken and asserts the rest:
942
1011
  *
943
1012
  * - `answer` — the status and error code (a redirect's location and code);
@@ -981,6 +1050,11 @@ export interface OutageCase<C extends Composition = Composition> {
981
1050
  readonly storeFieldNotRequired?: string;
982
1051
  /** The predicates the composition breaks today, each naming its defect. */
983
1052
  readonly defects?: Partial<Record<OutagePredicate, string>>;
1053
+ /**
1054
+ * The warn lines a `no-warn` defect writes, pinned exactly while it stands,
1055
+ * so the defect covers those lines and no other.
1056
+ */
1057
+ readonly defectWarns?: readonly string[];
984
1058
  }
985
1059
 
986
1060
  /** The same defect text for every predicate that depends on the one missing line. */
@@ -1011,6 +1085,14 @@ export function describeOutages<C extends Composition>(
1011
1085
  ): void {
1012
1086
  describe(title, () => {
1013
1087
  for (const c of cases) {
1088
+ if (
1089
+ c.defectWarns !== undefined &&
1090
+ (c.defects?.["no-warn"] === undefined || c.defectWarns.length === 0)
1091
+ ) {
1092
+ throw new Error(
1093
+ `${c.module}: ${c.slot} down at ${c.surface}: defectWarns pins the lines of a no-warn defect, so it needs defects["no-warn"] and at least one line`,
1094
+ );
1095
+ }
1014
1096
  describe(`${c.module}: ${c.slot} down at ${c.surface}`, () => {
1015
1097
  let res: request.Response;
1016
1098
  let lines: LogLine[] = [];
@@ -1090,13 +1172,20 @@ export function describeOutages<C extends Composition>(
1090
1172
  expect(err).toMatchObject({ name: expect.any(String) });
1091
1173
  expect(err).not.toBeInstanceOf(Error);
1092
1174
  });
1093
- check("no-warn", "writes no warn line for it", () => {
1094
- const warns = lines
1175
+ const outageWarns = () =>
1176
+ lines
1095
1177
  .filter((line) => line.level === "warn")
1096
1178
  .map((line) => (typeof line.args[1] === "string" ? line.args[1] : String(line.args[0])))
1097
1179
  .filter((event) => !(c.unrelatedWarns ?? []).includes(event));
1098
- expect(warns).toEqual([]);
1180
+ check("no-warn", "writes no warn line for it", () => {
1181
+ expect(outageWarns()).toEqual([]);
1099
1182
  });
1183
+ if (c.defectWarns !== undefined) {
1184
+ const pinned = c.defectWarns;
1185
+ it(`writes exactly the warn lines its no-warn defect names: ${pinned.join(", ")}`, () => {
1186
+ expect(outageWarns()).toEqual(pinned);
1187
+ });
1188
+ }
1100
1189
  });
1101
1190
  }
1102
1191
  });