@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
@@ -15,22 +15,25 @@
15
15
  */
16
16
 
17
17
  /**
18
- * Issue #407 — scaffold hardening from the v0.10.0 release-cut audit.
19
- *
20
- * These pin the parts of the scaffold that are checkable from inside the
18
+ * Pins the parts of the scaffold that are checkable from inside the
21
19
  * repository. The scaffold is the artifact an operator actually deploys, and
22
- * every one of these was invisible until someone read the file: the container
20
+ * a mistake in it stays invisible until someone reads the file: the container
23
21
  * install losing an allowlist, the dev compose dialling a Redis that is not
24
- * there, and one `git add .` committing the signing key.
22
+ * there, one `git add .` committing the signing key.
25
23
  */
26
24
 
27
25
  import { existsSync, readFileSync } from "node:fs";
28
26
  import { fileURLToPath } from "node:url";
29
- import { type AppConfig, AppConfigSchema } from "@o3co/auth-provider-core";
27
+ import { type AppConfig, AppConfigSchema, coreReference } from "@o3co/auth-provider-core";
30
28
  import { parseFile } from "@o3co/ts.hocon";
31
29
  import { validate } from "@o3co/ts.hocon/zod";
32
30
  import { describe, expect, it } from "vitest";
33
- import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPath.mjs";
31
+ import { readAdapters } from "../adapters.mjs";
32
+ import { buildModules } from "../buildModules.mjs";
33
+ import { readOwnLayers, readSwitches, resolveConfigPaths } from "../configPath.mjs";
34
+ import { templateReference } from "../modules.mjs";
35
+ import { type Adapters, repositoriesSectionSchema } from "../sections.mjs";
36
+ import { createRecordingLogger } from "./all-modules-composition.fixture.mjs";
34
37
 
35
38
  const standaloneDir = fileURLToPath(new URL("../..", import.meta.url));
36
39
  const configDir = fileURLToPath(new URL("../../config", import.meta.url));
@@ -38,17 +41,11 @@ const read = (rel: string): string => readFileSync(`${standaloneDir}${rel}`, "ut
38
41
 
39
42
  /**
40
43
  * The `environment:` entries of a compose file's `app` service, as the
41
- * environment the container would actually see.
42
- *
43
- * A hand-rolled reader rather than a YAML dependency: these two files are
44
- * fixed-shape artifacts in this repository, the block is flat `KEY: value`
45
- * scalars, and adding a parser to the template's dependency tree to read its
46
- * own scaffold would be a strange thing to ship to operators.
47
- *
48
- * Compose's `${VAR:?err}` required-variable form resolves from the operator's
49
- * shell or `.env`, so there is no literal value to record — the key is mapped
50
- * to `null`, which is the point of that form and what the assertions below
51
- * check for.
44
+ * environment the container would actually see. Hand-rolled rather than a
45
+ * YAML dependency: both files are fixed-shape, the block is flat `KEY: value`
46
+ * scalars, and the template should not ship a parser to read its own
47
+ * scaffold. Compose's `${VAR:?err}` required-variable form resolves from the
48
+ * operator's shell or `.env`, so its key is mapped to `null`.
52
49
  */
53
50
  function composeAppEnvironment(rel: string): Map<string, string | null> {
54
51
  const lines = read(rel).split("\n");
@@ -73,45 +70,59 @@ function resolveWith(env: Record<string, string>, configEnv = "production"): App
73
70
  return validate(
74
71
  parseFile(envConfPath, { env })
75
72
  .withFallback(parseFile(applicationConfPath, { env }))
76
- .withFallback(parseFile(resolveLibraryReferenceConfPath(), { env })),
73
+ .withFallback(parseFile(fileURLToPath(templateReference()), { env }))
74
+ .withFallback(parseFile(fileURLToPath(coreReference()), { env })),
77
75
  AppConfigSchema,
78
76
  );
79
77
  }
80
78
 
79
+ /** The template's own layers under `env`, over its own reference, unparsed. */
80
+ function ownResolved(
81
+ env: Record<string, string>,
82
+ configEnv = "production",
83
+ ): Record<string, unknown> {
84
+ const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, configEnv);
85
+ return parseFile(envConfPath, { env })
86
+ .withFallback(parseFile(applicationConfPath, { env }))
87
+ .withFallback(parseFile(fileURLToPath(templateReference()), { env }))
88
+ .toObject() as Record<string, unknown>;
89
+ }
90
+
91
+ /** The adapters phase one reads under `env`. */
92
+ function adaptersWith(env: Record<string, string>, configEnv = "production"): Adapters {
93
+ return readAdapters(ownResolved(env, configEnv), env);
94
+ }
95
+
81
96
  /**
82
97
  * A compose file's `environment:` block, plus the secrets no compose file
83
98
  * carries (they come from `.env` or a compose secret) — the environment the
84
- * process would boot under.
85
- *
86
- * The key material follows what the file itself says: the production compose
87
- * points the EdDSA key paths at its mounted secrets, so leave the default
88
- * algorithm alone; the dev compose supplies no key at all, so give it the
89
- * HS256 shape, whose `.strict()` union member is exactly why the two cannot
90
- * share one map.
99
+ * process would boot under. The production compose points the EdDSA key
100
+ * paths at its mounted secrets, so the default algorithm stays; the dev
101
+ * compose supplies no key, so it gets the HS256 shape, whose `.strict()`
102
+ * union member is why the two cannot share one map.
91
103
  */
92
104
  function bootableEnv(rel: string): Record<string, string> {
93
105
  const env: Record<string, string> = {
94
106
  OAUTH_JWT_ISSUER: "https://auth.test",
95
- SESSION_SECRET: "scaffold-assets-compose-session.at-least-32-bytes.ok",
107
+ SESSION_STORE_SECRET: "scaffold-assets-compose-session.at-least-32-bytes.ok",
96
108
  };
97
109
  for (const [key, value] of composeAppEnvironment(rel)) {
98
110
  if (value !== null) env[key] = value;
99
111
  }
100
- if (env.OAUTH_JWT_PRIVATE_KEY_PATH === undefined) {
101
- env.OAUTH_JWT_ALGORITHM = "HS256";
102
- env.OAUTH_JWT_SECRET = "scaffold-assets-compose.at-least-32-bytes.ok";
112
+ if (env.KEY_STORE_LOCAL_PRIVATE_KEY_PATH === undefined) {
113
+ env.KEY_STORE_LOCAL_ALGORITHM = "HS256";
114
+ env.KEY_STORE_LOCAL_SECRET = "scaffold-assets-compose.at-least-32-bytes.ok";
103
115
  }
104
116
  return env;
105
117
  }
106
118
 
107
- describe("#407 — the Dockerfile installs with everything pnpm needs", () => {
119
+ describe("the Dockerfile installs with everything pnpm needs", () => {
108
120
  it("copies pnpm-workspace.yaml into the deps stage", () => {
109
121
  // `create-auth-provider` generates it to carry the bcrypt
110
122
  // `onlyBuiltDependencies` allowlist, because pnpm >= 10.29 reads that
111
- // setting ONLY from this file — in single-package projects too (#360).
112
- // A deps stage that copies `package.json` and the lockfile but not this
113
- // runs allowlist-less, which re-creates #360 the day an alpine prebuild
114
- // is missing and bcrypt has to compile.
123
+ // setting ONLY from this file — in single-package projects too. A deps
124
+ // stage without it runs allowlist-less, which leaves bcrypt unbuilt the
125
+ // day an alpine prebuild is missing and it has to compile.
115
126
  const dockerfile = read("/Dockerfile");
116
127
  const depsStage = dockerfile.slice(
117
128
  dockerfile.indexOf("FROM node-base AS deps"),
@@ -119,8 +130,7 @@ describe("#407 — the Dockerfile installs with everything pnpm needs", () => {
119
130
  );
120
131
  // Matched as a COPY instruction, not as a substring of the stage: the
121
132
  // comment above that COPY names the file too, so a substring check
122
- // would keep passing if the instruction regressed and the comment
123
- // stayed — which is the failure this test exists to catch.
133
+ // would keep passing without the instruction.
124
134
  const copyLines = depsStage
125
135
  .split("\n")
126
136
  .filter((line) => /^COPY\b/.test(line.trim()) && !line.trim().startsWith("#"));
@@ -128,13 +138,19 @@ describe("#407 — the Dockerfile installs with everything pnpm needs", () => {
128
138
  });
129
139
  });
130
140
 
131
- describe("#407 — the dev compose can reach every Redis it configures", () => {
141
+ describe("the dev compose can reach every Redis it configures", () => {
132
142
  it("sets every *_REDIS_URL the template config reads", () => {
133
- // The template's application.conf substitutes several Redis URLs, each
134
- // defaulting to `redis://localhost:6379` — which inside the container is
135
- // the container itself. `.env.example` is what the compose file loads,
136
- // so a URL missing from it is a boot that dials nothing.
137
- const conf = read("/config/application.conf");
143
+ // The template's application.conf and its own reference.conf substitute
144
+ // several Redis URLs, each defaulting to `redis://localhost:6379` —
145
+ // which inside the container is the container itself. `.env.example` is
146
+ // what the compose file loads, so a URL missing from it is a boot that
147
+ // dials nothing.
148
+ // The captures of renamed variables (`renamed-variables { … }`) read an
149
+ // old name only to refuse it, and dial nothing.
150
+ const conf = (read("/config/application.conf") + read("/config/reference.conf")).replace(
151
+ /^renamed-variables \{[\s\S]*?^\}/m,
152
+ "",
153
+ );
138
154
  const declared = [...conf.matchAll(/\$\{\?([A-Z0-9_]*REDIS_URL)\}/g)].map((m) => m[1]);
139
155
  expect(declared.length).toBeGreaterThan(0);
140
156
 
@@ -146,7 +162,7 @@ describe("#407 — the dev compose can reach every Redis it configures", () => {
146
162
  });
147
163
  });
148
164
 
149
- describe("#407 — the scaffold does not invite committing its own secrets", () => {
165
+ describe("the scaffold does not invite committing its own secrets", () => {
150
166
  it("ships a .gitignore", () => {
151
167
  // Without one, the first `git add .` in a scaffolded project commits
152
168
  // `.env` and `jwt-private.pem` — both of which the README tells the
@@ -168,7 +184,7 @@ describe("#407 — the scaffold does not invite committing its own secrets", ()
168
184
  });
169
185
  });
170
186
 
171
- describe("#407 — the production compose matches the topology it documents", () => {
187
+ describe("the production compose matches the topology it documents", () => {
172
188
  it("does not publish the app port on every interface", () => {
173
189
  // The README says to keep `/metrics` off the public listener and the
174
190
  // file assumes TLS is terminated in front, so a bare "3000:3000"
@@ -179,38 +195,192 @@ describe("#407 — the production compose matches the topology it documents", ()
179
195
  });
180
196
  });
181
197
 
198
+ /**
199
+ * What `.gitignore` keeps out of git (secrets and per-machine configuration,
200
+ * the client registry among them) stays out of the build context too: the
201
+ * `Dockerfile` copies `config/`, so a `config/clients.yaml` in the working
202
+ * copy, client secrets included, would be baked into the runtime image.
203
+ * Production is given the registry from outside the image instead.
204
+ */
205
+ describe("what .gitignore keeps out of git stays out of the image, and production is given it", () => {
206
+ /**
207
+ * `.gitignore`'s "Secrets and per-machine configuration" section: each
208
+ * pattern, with files it matches. A pattern without a `/` matches at any
209
+ * depth in `.gitignore`, so those carry a nested file too — `.dockerignore`
210
+ * anchors every pattern at the context root, where `*` stops at a `/`.
211
+ */
212
+ const PER_DEPLOYMENT: Readonly<Record<string, readonly string[]>> = {
213
+ ".env": [".env", "config/.env"],
214
+ ".env.*": [".env.local", ".env.production", "config/.env.production"],
215
+ "*.pem": ["jwt-private.pem", "config/jwt-private.pem"],
216
+ "*.key": ["tls.key", "config/tls.key"],
217
+ "config/clients.yaml": ["config/clients.yaml"],
218
+ "config/*.local.conf": ["config/production.local.conf"],
219
+ };
220
+
221
+ /**
222
+ * The committed files beside them, which the image does need. `.env.example`
223
+ * is what `.gitignore` re-includes, at any depth: the same here.
224
+ */
225
+ const COMMITTED = [
226
+ ".env.example",
227
+ "config/.env.example",
228
+ "config/application.conf",
229
+ "config/development.conf",
230
+ "config/production.conf",
231
+ "config/mailpit.conf",
232
+ "config/clients.yaml.example",
233
+ ] as const;
234
+
235
+ it("checks every pattern of .gitignore's secrets section", () => {
236
+ const lines = read("/.gitignore").split("\n");
237
+ const start = lines.indexOf("# Secrets and per-machine configuration");
238
+ expect(start).not.toBe(-1);
239
+ const section: string[] = [];
240
+ for (const line of lines.slice(start + 1)) {
241
+ if (line.trim() === "") break;
242
+ if (!line.startsWith("#") && !line.startsWith("!")) section.push(line.trim());
243
+ }
244
+ expect(section).toEqual(Object.keys(PER_DEPLOYMENT));
245
+ });
246
+
247
+ it.each(Object.entries(PER_DEPLOYMENT))(
248
+ "keeps what %s matches out of the build context",
249
+ (_, files) => {
250
+ const dockerignore = read("/.dockerignore");
251
+ for (const file of files) {
252
+ expect(dockerignoreExcludes(dockerignore, file), `${file} reaches the build context`).toBe(
253
+ true,
254
+ );
255
+ }
256
+ },
257
+ );
258
+
259
+ it("lets the committed configuration through", () => {
260
+ const dockerignore = read("/.dockerignore");
261
+ for (const file of COMMITTED) {
262
+ expect(dockerignoreExcludes(dockerignore, file), `${file} is excluded`).toBe(false);
263
+ }
264
+ });
265
+
266
+ it("gives the production compose's process the client registry, read-only, from outside the image", () => {
267
+ // A compose secret, like the key pair: mounted read-only under
268
+ // /run/secrets, never part of a layer.
269
+ const name = "client_registry";
270
+ expect(composeAppSecrets("/docker-compose.production.yml")).toContain(name);
271
+ expect(composeSecretFiles("/docker-compose.production.yml").get(name)).toBe(
272
+ "./config/clients.yaml",
273
+ );
274
+ // And the process reads it there, through the real config layers.
275
+ const env = bootableEnv("/docker-compose.production.yml");
276
+ expect(adaptersWith(env).clientRepository).toBe("yaml");
277
+ expect(repositoriesSectionSchema.parse(ownResolved(env).repositories).client.yaml.path).toBe(
278
+ `/run/secrets/${name}`,
279
+ );
280
+ });
281
+ });
282
+
283
+ /** The secrets a compose file's `app` service mounts. */
284
+ function composeAppSecrets(rel: string): string[] {
285
+ const lines = read(rel).split("\n");
286
+ const start = lines.findIndex((line) => /^\s{4}secrets:\s*$/.test(line));
287
+ const names: string[] = [];
288
+ if (start === -1) return names;
289
+ for (const line of lines.slice(start + 1)) {
290
+ if (line.trim() === "" || /^\s*#/.test(line)) continue;
291
+ const match = /^\s{6}-\s*(\S+)\s*$/.exec(line);
292
+ if (!match) break;
293
+ names.push(match[1] as string);
294
+ }
295
+ return names;
296
+ }
297
+
298
+ /** A compose file's top-level `secrets:`, as each name's `file:`. */
299
+ function composeSecretFiles(rel: string): Map<string, string> {
300
+ const lines = read(rel).split("\n");
301
+ const start = lines.findIndex((line) => /^secrets:\s*$/.test(line));
302
+ const files = new Map<string, string>();
303
+ if (start === -1) return files;
304
+ let name: string | undefined;
305
+ for (const line of lines.slice(start + 1)) {
306
+ if (line.trim() === "" || /^\s*#/.test(line)) continue;
307
+ if (!/^\s/.test(line)) break;
308
+ const entry = /^\s{2}([A-Za-z0-9_-]+):\s*$/.exec(line);
309
+ if (entry) {
310
+ name = entry[1];
311
+ continue;
312
+ }
313
+ const file = /^\s{4}file:\s*(\S+)\s*$/.exec(line);
314
+ if (file && name !== undefined) files.set(name, file[1] as string);
315
+ }
316
+ return files;
317
+ }
318
+
182
319
  describe("the compose files put a store and its lifetime-sibling on the same backend", () => {
183
- // The production compose set `SESSION_STORAGE_TYPE: redis` and left
184
- // `USER_SESSION_STORES_ADAPTER` at its `memory` default. express-session
185
- // then survives a restart and the `UserSession` it points at does not, so
186
- // every browser comes back `isAuthenticated` with nothing behind it and
187
- // /authorize loops until the cookie is deleted by hand.
188
- //
189
- // `DEPLOYMENT_MODE: single` is silent about this on purpose — the replica
320
+ // With express-session on Redis and the user-session stores on memory,
321
+ // express-session survives a restart and the `UserSession` it points at
322
+ // does not: every browser comes back `isAuthenticated` with nothing behind
323
+ // it and /authorize loops until the cookie is deleted by hand.
324
+ // `CORE_DEPLOYMENT_MODE: single` is silent about this on purpose — the replica
190
325
  // guard answers "can these stores be shared", not "do these two stores have
191
326
  // the same lifetime" — so nothing but this assertion stands behind it.
192
327
  for (const file of ["/docker-compose.production.yml", "/docker-compose.yml"]) {
193
328
  it(`${file} keeps the user-session stores with the express-session store`, () => {
194
- const config = resolveWith(bootableEnv(file));
329
+ const env = bootableEnv(file);
195
330
  // Resolved through the real config layers, not read off the file:
196
331
  // what matters is the value the process ends up with, whether the
197
332
  // compose stated it or `config/application.conf` did.
198
- expect(config.session.storage?.type).toBe("redis");
199
- expect(config.userSessionStores?.adapter).toBe("redis");
333
+ expect(resolveWith(env)["session-store"]?.storage?.type).toBe("redis");
334
+ expect(adaptersWith(env).userSessionStores).toBe("redis");
200
335
  });
201
336
  }
202
337
 
203
338
  it("the production compose leaves no store on memory while a sibling is on Redis", () => {
204
- const config = resolveWith(bootableEnv("/docker-compose.production.yml"));
339
+ const env = bootableEnv("/docker-compose.production.yml");
205
340
  // Every store whose records must outlive one process. The federation
206
341
  // token store is deliberately absent: this template ships every
207
342
  // federation disabled, so nothing writes to it, and turning it on needs
208
343
  // an AES key the compose file must not invent.
209
- expect(config.session.storage?.type).toBe("redis");
210
- expect(config.userSessionStores?.adapter).toBe("redis");
211
- expect(config.oauth.code?.adapter).toBe("redis");
212
- expect(config.accessTokenDenylist?.adapter).toBe("redis");
213
- expect(config.rateLimiter?.adapter).toBe("redis");
344
+ expect(resolveWith(env)["session-store"]?.storage?.type).toBe("redis");
345
+ expect(adaptersWith(env)).toMatchObject({
346
+ userSessionStores: "redis",
347
+ codeRepository: "redis",
348
+ accessTokenDenylist: "redis",
349
+ rateLimiter: "redis",
350
+ });
351
+ });
352
+ });
353
+
354
+ /** `.env.example`'s uncommented lines that carry a value: what a `.env` copied from it sets. */
355
+ function dotenvExample(): Record<string, string> {
356
+ const env: Record<string, string> = {};
357
+ for (const line of read("/.env.example").split("\n")) {
358
+ const match = /^([A-Z][A-Z0-9_]*)=(.+)$/.exec(line);
359
+ if (match) env[match[1] as string] = match[2] as string;
360
+ }
361
+ return env;
362
+ }
363
+
364
+ describe("the production compose leaves MFA on, for the deployment to decide", () => {
365
+ it("reads the switch as required, so a deployment that sets nothing about MFA is refused naming MFA_MODE=off", () => {
366
+ // `environment:` wins over `env_file`, so the compose file's block is
367
+ // laid over the `.env` copied from `.env.example`.
368
+ const env = { ...dotenvExample(), ...bootableEnv("/docker-compose.production.yml") };
369
+ // `app.mts`: CONFIG_ENV, then NODE_ENV, then development.
370
+ const configEnv = env.CONFIG_ENV || env.NODE_ENV || "development";
371
+ expect(configEnv).toBe("production");
372
+ const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, configEnv);
373
+ const switches = readSwitches(readOwnLayers([envConfPath, applicationConfPath], { env }));
374
+ expect(switches.mfaMode).toBe("required");
375
+
376
+ let err: unknown;
377
+ try {
378
+ buildModules(switches, { environment: configEnv, logger: createRecordingLogger() });
379
+ } catch (caught) {
380
+ err = caught;
381
+ }
382
+ expect(err).toBeInstanceOf(RangeError);
383
+ expect((err as RangeError).message).toContain("MFA_MODE=off");
214
384
  });
215
385
  });
216
386
 
@@ -235,31 +405,25 @@ describe("the production compose refuses to guess HTTP_TRUST_PROXY", () => {
235
405
  });
236
406
  });
237
407
 
238
- describe("#407 — the two READMEs agree on security advice", () => {
408
+ describe("the READMEs' security advice", () => {
239
409
  it("does not recommend HTTP_TRUST_PROXY=true in the Japanese README", () => {
240
- // #292 made `trust proxy` a CIDR/hop policy precisely because `true`
241
- // means "believe the leftmost forwarded entry from whoever opened the
242
- // connection". The English README warns against it; the Japanese one
243
- // still recommended it, so the two disagreed on which is safe.
410
+ // `trust proxy` is a CIDR/hop policy because `true` means "believe the
411
+ // leftmost forwarded entry from whoever opened the connection". The
412
+ // English README warns against it, and the Japanese one must agree.
244
413
  expect(read("/README.ja.md")).not.toMatch(/HTTP_TRUST_PROXY=true/);
245
414
  });
246
415
  });
247
416
 
248
417
  /**
249
- * Issue #512 — the suite that ships with a scaffolded project has to be green
250
- * in that project, not only in this workspace.
251
- *
252
- * A scaffold installs `@o3co/auth-provider-*` from npm, where the packages sit
253
- * under `node_modules` and vitest externalizes them: Node loads them natively
254
- * and their own `import "ioredis"` / `import "redis"` never meet the
255
- * `vi.mock` registry. In this workspace the same packages are symlinks to
256
- * source outside `node_modules`, vitest inlines them, and the mocks apply —
257
- * which is how replica-safety.test.mts stayed green here while dialling
258
- * `redis.test` for real in every scaffold. The second half is `make test`:
259
- * it runs this suite inside the `test` image, where a file the Dockerfile
260
- * never copied simply does not exist.
418
+ * The suite that ships with a scaffolded project has to be green in that
419
+ * project, not only in this workspace. A scaffold installs
420
+ * `@o3co/auth-provider-*` from npm, under `node_modules`, where vitest
421
+ * externalizes them and their own `import "ioredis"` / `import "redis"` never
422
+ * meet the `vi.mock` registry unless inlined; here they are symlinks vitest
423
+ * inlines anyway. And `make test` runs this suite inside the `test` image,
424
+ * where a file the Dockerfile never copied does not exist.
261
425
  */
262
- describe("#512 — the shipped suite is green outside this repository", () => {
426
+ describe("the shipped suite carries what it needs to run outside this repository", () => {
263
427
  it("runs the published packages through vitest, so module mocks reach them", async () => {
264
428
  // The config is loaded, not grepped: what matters is the value vitest
265
429
  // resolves, and a `server.deps.inline` naming the wrong package would
@@ -300,6 +464,7 @@ describe("#512 — the shipped suite is green outside this repository", () => {
300
464
  "README.ja.md",
301
465
  "docker-compose.yml",
302
466
  "docker-compose.production.yml",
467
+ "docker-compose.mailpit.yml",
303
468
  ] as const;
304
469
 
305
470
  it("reads only files the template actually ships", () => {
@@ -325,7 +490,7 @@ describe("#512 — the shipped suite is green outside this repository", () => {
325
490
  }
326
491
  });
327
492
 
328
- it("ships every setup file vitest.config.mts loads, into the `test` image as well (#556)", async () => {
493
+ it("ships every setup file vitest.config.mts loads, into the `test` image as well", async () => {
329
494
  // vitest refuses to start when a `setupFiles` entry is missing, so a setup
330
495
  // file the image never copied fails the whole of `make test`, not one test.
331
496
  const configPath = fileURLToPath(new URL("../../vitest.config.mts", import.meta.url));
@@ -333,7 +498,7 @@ describe("#512 — the shipped suite is green outside this repository", () => {
333
498
  default: { test?: { setupFiles?: string | readonly string[] } };
334
499
  };
335
500
  const setupFiles = [config.test?.setupFiles ?? []].flat();
336
- expect(setupFiles, "the #556 supertest loopback guard is not wired in").not.toHaveLength(0);
501
+ expect(setupFiles, "the supertest loopback guard is not wired in").not.toHaveLength(0);
337
502
 
338
503
  const copied = copiedIntoStage(read("/Dockerfile"), "test");
339
504
  const dockerignore = read("/.dockerignore");
@@ -389,31 +554,46 @@ function copiedIntoStage(dockerfile: string, stage: string): Set<string> {
389
554
  }
390
555
 
391
556
  /**
392
- * Whether `.dockerignore` keeps a root-level entry out of the build context.
393
- * The last matching pattern wins and a leading `!` re-includes; `*` and `?`
394
- * stop at a `/` while `**` does not — the subset of Docker's matching that
395
- * root-level file names exercise.
557
+ * Whether `.dockerignore` keeps a file out of the build context, as Docker
558
+ * decides it (moby's patternmatcher): patterns are anchored at the context
559
+ * root, the last matching one wins and a leading `!` re-includes; `*` and `?`
560
+ * stop at a `/`, `**` does not, and `**` followed by `/` also matches no
561
+ * directory at all; a pattern that matches one of the file's parent
562
+ * directories matches the file.
396
563
  */
397
564
  function dockerignoreExcludes(dockerignore: string, file: string): boolean {
565
+ const parents = file.split("/").slice(0, -1);
566
+ const candidates = [file, ...parents.map((_, i) => parents.slice(0, i + 1).join("/"))];
398
567
  let excluded = false;
399
568
  for (const raw of dockerignore.split("\n")) {
400
569
  const line = raw.trim();
401
570
  if (line === "" || line.startsWith("#")) continue;
402
571
  const negated = line.startsWith("!");
403
- const pattern = negated ? line.slice(1) : line;
404
- // `**` is split out first so the single-`*` rewrite cannot see it.
405
- const segment = (s: string): string =>
406
- s
407
- .replace(/[.+^${}()|[\]\\]/g, "\\$&")
408
- .replace(/\*/g, "[^/]*")
409
- .replace(/\?/g, "[^/]");
410
- const regex = new RegExp(`^${pattern.split("**").map(segment).join(".*")}$`);
411
- if (regex.test(file)) excluded = !negated;
572
+ // Cleaned as Docker cleans a pattern (`filepath.Clean`): a leading `/`
573
+ // or `./` and a trailing `/` say nothing about what it matches.
574
+ const pattern = (negated ? line.slice(1) : line).replace(/^(\.?\/)+/, "").replace(/\/+$/, "");
575
+ let source = "";
576
+ for (let i = 0; i < pattern.length; ) {
577
+ if (pattern.startsWith("**/", i)) {
578
+ source += "(?:.*/)?";
579
+ i += 3;
580
+ } else if (pattern.startsWith("**", i)) {
581
+ source += ".*";
582
+ i += 2;
583
+ } else {
584
+ const char = pattern[i] as string;
585
+ source +=
586
+ char === "*" ? "[^/]*" : char === "?" ? "[^/]" : char.replace(/[.+^${}()|[\]\\]/, "\\$&");
587
+ i += 1;
588
+ }
589
+ }
590
+ const regex = new RegExp(`^${source}$`);
591
+ if (candidates.some((candidate) => regex.test(candidate))) excluded = !negated;
412
592
  }
413
593
  return excluded;
414
594
  }
415
595
 
416
- describe("#593 slice 7 — the compose files give the process time to finish a shutdown", () => {
596
+ describe("the compose files give the process time to finish a shutdown", () => {
417
597
  it.each(["/docker-compose.yml", "/docker-compose.production.yml"])(
418
598
  "%s declares a stop_grace_period covering the HTTP drain, the grants cleanup and an exit margin",
419
599
  (rel) => {