@o3co/create-auth-provider 0.11.0 → 0.12.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.
@@ -0,0 +1,456 @@
1
+ /*
2
+ * Copyright 2026 1o1 Co. Ltd.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /**
18
+ * #455 / #456 — the standalone's own store modules under
19
+ * `deployment.mode = "multi"`, booted the way an operator reaches them: from
20
+ * the shipped HOCON with one environment variable flipped.
21
+ *
22
+ * #455: the replica-safety guard keyed on core's module names, and this
23
+ * template wires its *own* in-memory modules for three of the stores —
24
+ * session stores, the code repository, the federation token store — under
25
+ * names the guard had never heard of. `DEPLOYMENT_MODE=multi` with
26
+ * `USER_SESSION_STORES_ADAPTER=memory` booted, and the refusal the README
27
+ * promises did not fire for exactly the stores that fork per replica the
28
+ * worst. Every memory branch is booted here under `"multi"` and must be
29
+ * refused by name; the all-Redis environment must boot.
30
+ *
31
+ * #456: `federationTokenStore.type = "redis"` was documented and could not
32
+ * boot — the module went through the adapter factory without ever handing
33
+ * the builder a client. The Redis branch now mounts
34
+ * `redisFederationTokenStoreModule` off the shared ioredis socket; the boot
35
+ * below is the test the README sentence never had.
36
+ *
37
+ * #474: express-session's own store was the one memory store the guard could
38
+ * not see — `SESSION_STORAGE_TYPE=memory` is config, and `sessionStoreModule`'s
39
+ * static manifest carried no declaration. `buildModules` now builds it from
40
+ * the config (`sessionStoreModuleFor`), so it is refused by name like the rest.
41
+ *
42
+ * #473: the Redis federation store's `allow-plaintext` guard keyed off
43
+ * `NODE_ENV` alone. It now reads the environment the config was selected by
44
+ * (`CONFIG_ENV || NODE_ENV`, passed through `buildModules`) and refuses under
45
+ * `deployment.mode = "multi"` in every environment.
46
+ *
47
+ * ioredis is mocked, as in `device-code-store-client-module.test.mts`: the
48
+ * point is composition and the boot planner's stage-1 verdict, not Redis.
49
+ * Nothing here issues a command. node-redis and connect-redis — the session
50
+ * store's own connection — are mocked for the same reason, since #474 puts
51
+ * `SESSION_STORAGE_TYPE=redis` in the baseline.
52
+ */
53
+
54
+ import { fileURLToPath } from "node:url";
55
+ import {
56
+ type AppConfig,
57
+ AppConfigSchema,
58
+ createApp,
59
+ createKeyStoreFactory,
60
+ defineModule,
61
+ InMemoryClientRepository,
62
+ InMemoryUserRepository,
63
+ registerBuiltinKeyStores,
64
+ replicaUnsafeReason,
65
+ } from "@o3co/auth-provider-core";
66
+ import { parseFile } from "@o3co/ts.hocon";
67
+ import { validate } from "@o3co/ts.hocon/zod";
68
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
69
+ import { buildModules } from "../buildModules.mjs";
70
+ import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPath.mjs";
71
+
72
+ // The redis session-store builder dynamically imports these (#474 puts the
73
+ // Redis session store in the baseline); mock them so no socket opens.
74
+ vi.mock("redis", () => ({
75
+ createClient: vi.fn(() => ({
76
+ connect: vi.fn().mockResolvedValue(undefined),
77
+ quit: vi.fn().mockResolvedValue(undefined),
78
+ ping: vi.fn().mockResolvedValue("PONG"),
79
+ on: vi.fn(),
80
+ })),
81
+ }));
82
+
83
+ vi.mock("connect-redis", async () => {
84
+ const { EventEmitter } = await import("node:events");
85
+ // express-session subscribes to store events, so the fake must be an
86
+ // EventEmitter rather than a plain object.
87
+ return {
88
+ RedisStore: class MockRedisStore extends EventEmitter {
89
+ constructor(_opts: { client: unknown }) {
90
+ super();
91
+ }
92
+ get(): unknown {
93
+ return undefined;
94
+ }
95
+ set(): void {}
96
+ destroy(): void {}
97
+ },
98
+ };
99
+ });
100
+
101
+ vi.mock("ioredis", () => {
102
+ // Every command resolves to nothing. A boot that reached Redis would be a
103
+ // boot doing work at stage 2 that belongs in an integration test; the
104
+ // stand-in only has to be constructible, quit cleanly, and answer `ping`
105
+ // for the readiness probe the shared clients module registers.
106
+ const explicit: Record<string, unknown> = {
107
+ on: () => undefined,
108
+ quit: async () => "OK",
109
+ disconnect: () => undefined,
110
+ ping: async () => "PONG",
111
+ };
112
+ const makeMockRedis = (): object =>
113
+ new Proxy(
114
+ {},
115
+ {
116
+ get(_target, prop) {
117
+ // A `then` that is a function would make the instance a
118
+ // thenable and hang anything that awaits it.
119
+ if (typeof prop !== "string" || prop === "then") return undefined;
120
+ if (prop === "duplicate") return makeMockRedis;
121
+ if (prop in explicit) return explicit[prop];
122
+ return async () => null;
123
+ },
124
+ },
125
+ );
126
+ // The template does `new Redis(url, options)`. A plain function that
127
+ // returns an object yields that object under `new`, so no class — and no
128
+ // constructor returning a value — is needed for the stand-in.
129
+ function MockRedis(): object {
130
+ return makeMockRedis();
131
+ }
132
+ return { Redis: MockRedis, default: MockRedis };
133
+ });
134
+
135
+ // config/ is two levels above this test file: src/__tests__/ → src/ → standalone/
136
+ const configDir = fileURLToPath(new URL("../../config", import.meta.url));
137
+
138
+ /** 32 bytes, base64 — what `REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY` carries. */
139
+ const ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
140
+
141
+ /**
142
+ * The umbrella E2E's shape (`o3co/auth` `tests/docker-compose.yml`), with
143
+ * every shared store on Redis — express-session's own included, since #474
144
+ * put it under the guard. Each case below flips one variable off this.
145
+ */
146
+ const ALL_REDIS_ENV: Readonly<Record<string, string>> = {
147
+ OAUTH_JWT_ALGORITHM: "HS256",
148
+ OAUTH_JWT_SECRET: "replica-safety-test-secret.at-least-32-bytes.ok",
149
+ OAUTH_JWT_ISSUER: "https://auth.test",
150
+ SESSION_SECRET: "replica-safety-session-secret.at-least-32-bytes.ok",
151
+ SESSION_SECURE: "false",
152
+ SESSION_NAME: "auth.session",
153
+ SESSION_STORAGE_TYPE: "redis",
154
+ SESSION_STORAGE_REDIS_URL: "redis://redis.test:6379",
155
+ CLIENT_USER_TYPE: "yaml",
156
+ DEPLOYMENT_MODE: "multi",
157
+ REFRESH_TOKEN_FAMILY_STORE_REDIS_URL: "redis://redis.test:6379",
158
+ USER_SESSION_STORES_ADAPTER: "redis",
159
+ RATE_LIMITER_ADAPTER: "redis",
160
+ OAUTH_CODE_ADAPTER: "redis",
161
+ ACCESS_TOKEN_DENYLIST_ADAPTER: "redis",
162
+ FEDERATION_TOKEN_STORE_TYPE: "redis",
163
+ REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: ENCRYPTION_KEY,
164
+ };
165
+
166
+ function resolveConfig(env: Record<string, string>): AppConfig {
167
+ const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
168
+ return validate(
169
+ parseFile(envConfPath, { env })
170
+ .withFallback(parseFile(applicationConfPath, { env }))
171
+ .withFallback(parseFile(resolveLibraryReferenceConfPath(), { env })),
172
+ AppConfigSchema,
173
+ );
174
+ }
175
+
176
+ /** Drops a variable, so the HOCON default takes over. */
177
+ function without(env: Record<string, string>, ...names: string[]): Record<string, string> {
178
+ const copy = { ...env };
179
+ for (const name of names) delete copy[name];
180
+ return copy;
181
+ }
182
+
183
+ // The file-system-backed modules are replaced as in `smoke.test.mts`: the
184
+ // boot under test is the store wiring, not the client registry on disk.
185
+ const testRepositoriesModule = defineModule({
186
+ name: "test:repositories",
187
+ provides: {
188
+ clientRepository: () => new InMemoryClientRepository(new Map()),
189
+ userRepository: () => new InMemoryUserRepository(new Map()),
190
+ },
191
+ });
192
+
193
+ const testKeyStoreModule = defineModule({
194
+ name: "test:key-store",
195
+ requires: ["config"] as const,
196
+ provides: {
197
+ keyStore: async ({ config: c }) => {
198
+ const factory = createKeyStoreFactory();
199
+ registerBuiltinKeyStores(factory);
200
+ return factory.create({
201
+ type: "local",
202
+ ...((c as AppConfig).oauth.jwt.signingKey.local ?? {}),
203
+ });
204
+ },
205
+ },
206
+ });
207
+
208
+ const modulesFor = (config: AppConfig, environment?: string) =>
209
+ buildModules(config, {
210
+ keyStoreModule: testKeyStoreModule,
211
+ repositoriesModule: testRepositoriesModule,
212
+ ...(environment === undefined ? {} : { environment }),
213
+ });
214
+
215
+ const boot = (config: AppConfig, environment?: string) =>
216
+ createApp({
217
+ modules: modulesFor(config, environment),
218
+ bootstrapComponents: { config, pathResolver: (s) => s },
219
+ });
220
+
221
+ /** The message of a boot failure and of the module error it wraps, together. */
222
+ const messageChain = (err: unknown): string => {
223
+ const e = err as { message?: string; cause?: { message?: string } };
224
+ return `${e.message ?? ""} ${e.cause?.message ?? ""}`;
225
+ };
226
+
227
+ describe('#455: the standalone\'s memory modules are refused under deployment.mode = "multi"', () => {
228
+ let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
229
+
230
+ afterEach(async () => {
231
+ await handleRef?.dispose();
232
+ handleRef = undefined;
233
+ });
234
+
235
+ const cases: ReadonlyArray<readonly [variable: string, module: string]> = [
236
+ // The template's own modules — the three the guard could not see.
237
+ ["USER_SESSION_STORES_ADAPTER", "standalone:in-memory-session-stores"],
238
+ ["OAUTH_CODE_ADAPTER", "standalone:in-memory-code-repository"],
239
+ ["FEDERATION_TOKEN_STORE_TYPE", "standalone:in-memory-federation-token-store"],
240
+ // #474: express-session's own store. Not a module of this template but
241
+ // built here from its config, which is what lets the manifest declare.
242
+ ["SESSION_STORAGE_TYPE", "sessionStoreModule"],
243
+ // Core's, selected by the same kind of switch. The guard knew these by
244
+ // name; pinned so the name table's departure (#455) did not lose them.
245
+ ["RATE_LIMITER_ADAPTER", "core-rate-limiter-memory"],
246
+ ["ACCESS_TOKEN_DENYLIST_ADAPTER", "core-access-token-denylist-memory"],
247
+ ];
248
+
249
+ for (const [variable, module] of cases) {
250
+ it(`${variable}=memory is refused, naming ${module}`, async () => {
251
+ const config = resolveConfig({ ...ALL_REDIS_ENV, [variable]: "memory" });
252
+ await expect(boot(config)).rejects.toMatchObject({
253
+ name: "BootError",
254
+ reason: "replica-unsafe-adapter",
255
+ details: { modules: [module] },
256
+ });
257
+ });
258
+ }
259
+
260
+ it("names every memory module together when every switch is memory", async () => {
261
+ const config = resolveConfig({
262
+ ...ALL_REDIS_ENV,
263
+ USER_SESSION_STORES_ADAPTER: "memory",
264
+ OAUTH_CODE_ADAPTER: "memory",
265
+ FEDERATION_TOKEN_STORE_TYPE: "memory",
266
+ SESSION_STORAGE_TYPE: "memory",
267
+ RATE_LIMITER_ADAPTER: "memory",
268
+ ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
269
+ });
270
+ await expect(boot(config)).rejects.toMatchObject({
271
+ reason: "replica-unsafe-adapter",
272
+ details: {
273
+ modules: expect.arrayContaining(cases.map(([, module]) => module)),
274
+ },
275
+ });
276
+ });
277
+
278
+ it("each standalone memory module declares its consequence on its own manifest", () => {
279
+ // The declaration is what the guard reads (#455), so it has to be on
280
+ // the module and it has to say what breaks — the guard quotes it.
281
+ const config = resolveConfig({
282
+ ...ALL_REDIS_ENV,
283
+ USER_SESSION_STORES_ADAPTER: "memory",
284
+ OAUTH_CODE_ADAPTER: "memory",
285
+ FEDERATION_TOKEN_STORE_TYPE: "memory",
286
+ });
287
+ const standaloneMemoryModules = modulesFor(config).filter((m) =>
288
+ m.name.startsWith("standalone:in-memory-"),
289
+ );
290
+ expect(standaloneMemoryModules.map((m) => m.name).sort()).toEqual([
291
+ "standalone:in-memory-code-repository",
292
+ "standalone:in-memory-federation-token-store",
293
+ "standalone:in-memory-session-stores",
294
+ ]);
295
+ for (const m of standaloneMemoryModules) {
296
+ expect(replicaUnsafeReason(m), m.name).toBeDefined();
297
+ expect((replicaUnsafeReason(m) ?? "").length, m.name).toBeGreaterThan(40);
298
+ }
299
+ });
300
+
301
+ it("boots the all-Redis environment, with nothing declaring replica-unsafe state", async () => {
302
+ const config = resolveConfig(ALL_REDIS_ENV);
303
+ for (const m of modulesFor(config)) {
304
+ expect(replicaUnsafeReason(m), m.name).toBeUndefined();
305
+ }
306
+ handleRef = await boot(config);
307
+ expect(handleRef).toBeDefined();
308
+ });
309
+ });
310
+
311
+ describe('#456: federationTokenStore.type = "redis" in the standalone', () => {
312
+ let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
313
+
314
+ afterEach(async () => {
315
+ await handleRef?.dispose();
316
+ handleRef = undefined;
317
+ });
318
+
319
+ it("reaches the resolved config from FEDERATION_TOKEN_STORE_TYPE", () => {
320
+ // `AppConfigSchema` strips top-level keys it does not declare; the
321
+ // switch had to be declared before an env var could select anything.
322
+ expect(resolveConfig(ALL_REDIS_ENV).federationTokenStore?.type).toBe("redis");
323
+ expect(
324
+ resolveConfig(without(ALL_REDIS_ENV, "FEDERATION_TOKEN_STORE_TYPE")).federationTokenStore
325
+ ?.type,
326
+ ).toBe("memory");
327
+ });
328
+
329
+ it("selects the Redis module and the shared clients module, not the memory one", () => {
330
+ const modules = modulesFor(resolveConfig(ALL_REDIS_ENV));
331
+ const names = modules.map((m) => m.name);
332
+ expect(names).toContain("redis-federation-token-store");
333
+ expect(names).toContain("standalone:redis-clients");
334
+ expect(names).not.toContain("standalone:in-memory-federation-token-store");
335
+ // Exactly one provider for the slot: both modules provide it, so
336
+ // selecting both would be a boot-time slot collision.
337
+ const providers = modules.filter((m) =>
338
+ Object.keys(m.provides ?? {}).includes("federationTokenStore"),
339
+ );
340
+ expect(providers.map((m) => m.name)).toEqual(["redis-federation-token-store"]);
341
+ });
342
+
343
+ it("pulls the shared clients module in for the federation store alone", () => {
344
+ // Every other store on memory, single replica: the Redis federation
345
+ // store still needs `federationTokenStoreClient`, which only the shared
346
+ // clients module provides. Without this the branch fails stage 1 with
347
+ // `missing-required-component` — the #439 shape, once more.
348
+ const config = resolveConfig({
349
+ ...ALL_REDIS_ENV,
350
+ DEPLOYMENT_MODE: "single",
351
+ USER_SESSION_STORES_ADAPTER: "memory",
352
+ OAUTH_CODE_ADAPTER: "memory",
353
+ RATE_LIMITER_ADAPTER: "memory",
354
+ ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
355
+ });
356
+ const names = modulesFor(config).map((m) => m.name);
357
+ expect(names).toContain("redis-federation-token-store");
358
+ expect(names).toContain("standalone:redis-clients");
359
+ });
360
+
361
+ it("boots, and the resolved store is the Redis adapter", async () => {
362
+ handleRef = await boot(resolveConfig(ALL_REDIS_ENV));
363
+ const store = handleRef.components.federationTokenStore;
364
+ expect(store?.kind).toBe("redis");
365
+ });
366
+
367
+ it("fails at boot, naming the key, when the encryption key is missing", async () => {
368
+ // The store encrypts long-lived IdP refresh tokens at rest; a Redis
369
+ // branch with no key is a misconfiguration to refuse at boot, not a
370
+ // store that works until the first federation login.
371
+ const config = resolveConfig(
372
+ without(ALL_REDIS_ENV, "REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY"),
373
+ );
374
+ await expect(boot(config)).rejects.toSatisfy((err: unknown) => {
375
+ const e = err as { message?: string; cause?: { message?: string } };
376
+ return /encryption\.key/.test(`${e.message ?? ""} ${e.cause?.message ?? ""}`);
377
+ });
378
+ });
379
+
380
+ it("keeps the memory branch as it was: the default, in single mode, resolves the memory adapter", async () => {
381
+ const config = resolveConfig({
382
+ ...without(ALL_REDIS_ENV, "FEDERATION_TOKEN_STORE_TYPE"),
383
+ DEPLOYMENT_MODE: "single",
384
+ });
385
+ const names = modulesFor(config).map((m) => m.name);
386
+ expect(names).toContain("standalone:in-memory-federation-token-store");
387
+ expect(names).not.toContain("redis-federation-token-store");
388
+ handleRef = await boot(config);
389
+ expect(handleRef.components.federationTokenStore?.kind).toBe("memory");
390
+ });
391
+ });
392
+
393
+ describe("#473: the Redis federation store's plaintext guard, booted from the shipped config", () => {
394
+ let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
395
+ let warnSpy: ReturnType<typeof vi.spyOn>;
396
+ let errorSpy: ReturnType<typeof vi.spyOn>;
397
+ let origInsecure: string | undefined;
398
+
399
+ beforeEach(() => {
400
+ // vitest runs with NODE_ENV=test: not a production name, so every
401
+ // refusal below comes from what the standalone passes, not from NODE_ENV.
402
+ expect(process.env.NODE_ENV).not.toMatch(/^(production|staging)$/);
403
+ origInsecure = process.env.FEDERATION_TOKENS_ALLOW_INSECURE;
404
+ delete process.env.FEDERATION_TOKENS_ALLOW_INSECURE;
405
+ warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
406
+ errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
407
+ });
408
+
409
+ afterEach(async () => {
410
+ await handleRef?.dispose();
411
+ handleRef = undefined;
412
+ if (origInsecure === undefined) delete process.env.FEDERATION_TOKENS_ALLOW_INSECURE;
413
+ else process.env.FEDERATION_TOKENS_ALLOW_INSECURE = origInsecure;
414
+ warnSpy.mockRestore();
415
+ errorSpy.mockRestore();
416
+ });
417
+
418
+ /** All-Redis, with the federation store told to skip encryption. */
419
+ const PLAINTEXT_ENV: Readonly<Record<string, string>> = {
420
+ ...without(ALL_REDIS_ENV, "REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY"),
421
+ REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_MODE: "allow-plaintext",
422
+ };
423
+
424
+ it('is refused under deployment.mode = "multi" in a development environment', async () => {
425
+ // The umbrella shape with plaintext: refused before #473 only if
426
+ // NODE_ENV happened to say production. A multi-replica deployment is
427
+ // never a development box.
428
+ await expect(boot(resolveConfig(PLAINTEXT_ENV), "development")).rejects.toSatisfy(
429
+ (err: unknown) => /deployment\.mode is "multi"/.test(messageChain(err)),
430
+ );
431
+ expect(errorSpy).not.toHaveBeenCalled();
432
+ });
433
+
434
+ it("is refused when the config was selected by CONFIG_ENV=production, whatever NODE_ENV says", async () => {
435
+ // `app.mts` selects `production.conf` by `CONFIG_ENV || NODE_ENV` and
436
+ // passes that name through `buildModules`; the guard used to read
437
+ // NODE_ENV alone and would have let this boot.
438
+ const config = resolveConfig({ ...PLAINTEXT_ENV, DEPLOYMENT_MODE: "single" });
439
+ await expect(boot(config, "production")).rejects.toSatisfy((err: unknown) =>
440
+ /the environment is "production"/.test(messageChain(err)),
441
+ );
442
+ });
443
+
444
+ it("boots with the plaintext warning in a development environment on a single replica", async () => {
445
+ const config = resolveConfig({ ...PLAINTEXT_ENV, DEPLOYMENT_MODE: "single" });
446
+ handleRef = await boot(config, "development");
447
+ expect(handleRef.components.federationTokenStore?.kind).toBe("redis");
448
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("allow-plaintext"));
449
+ });
450
+
451
+ it("keeps the FEDERATION_TOKENS_ALLOW_INSECURE=1 escape hatch under multi, at CRITICAL", async () => {
452
+ process.env.FEDERATION_TOKENS_ALLOW_INSECURE = "1";
453
+ handleRef = await boot(resolveConfig(PLAINTEXT_ENV), "development");
454
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("CRITICAL"));
455
+ });
456
+ });
@@ -26,11 +26,84 @@
26
26
 
27
27
  import { existsSync, readFileSync } from "node:fs";
28
28
  import { fileURLToPath } from "node:url";
29
+ import { type AppConfig, AppConfigSchema } from "@o3co/auth-provider-core";
30
+ import { parseFile } from "@o3co/ts.hocon";
31
+ import { validate } from "@o3co/ts.hocon/zod";
29
32
  import { describe, expect, it } from "vitest";
33
+ import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPath.mjs";
30
34
 
31
35
  const standaloneDir = fileURLToPath(new URL("../..", import.meta.url));
36
+ const configDir = fileURLToPath(new URL("../../config", import.meta.url));
32
37
  const read = (rel: string): string => readFileSync(`${standaloneDir}${rel}`, "utf8");
33
38
 
39
+ /**
40
+ * 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.
52
+ */
53
+ function composeAppEnvironment(rel: string): Map<string, string | null> {
54
+ const lines = read(rel).split("\n");
55
+ const start = lines.findIndex((line) => /^\s{4}environment:\s*$/.test(line));
56
+ const env = new Map<string, string | null>();
57
+ if (start === -1) return env;
58
+ for (const line of lines.slice(start + 1)) {
59
+ if (line.trim() === "" || /^\s*#/.test(line)) continue;
60
+ // The block ends at the first line indented no further than its own key.
61
+ if (!/^\s{6}\S/.test(line)) break;
62
+ const match = /^\s{6}([A-Z][A-Z0-9_]*):\s*(.*)$/.exec(line);
63
+ if (!match) continue;
64
+ const raw = (match[2] as string).trim().replace(/^["']|["']$/g, "");
65
+ env.set(match[1] as string, /^\$\{[A-Z][A-Z0-9_]*:\?/.test(raw) ? null : raw);
66
+ }
67
+ return env;
68
+ }
69
+
70
+ /** Resolve the shipped config layers under a given environment, as `src/app.mts` does. */
71
+ function resolveWith(env: Record<string, string>, configEnv = "production"): AppConfig {
72
+ const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, configEnv);
73
+ return validate(
74
+ parseFile(envConfPath, { env })
75
+ .withFallback(parseFile(applicationConfPath, { env }))
76
+ .withFallback(parseFile(resolveLibraryReferenceConfPath(), { env })),
77
+ AppConfigSchema,
78
+ );
79
+ }
80
+
81
+ /**
82
+ * A compose file's `environment:` block, plus the secrets no compose file
83
+ * 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.
91
+ */
92
+ function bootableEnv(rel: string): Record<string, string> {
93
+ const env: Record<string, string> = {
94
+ OAUTH_JWT_ISSUER: "https://auth.test",
95
+ SESSION_SECRET: "scaffold-assets-compose-session.at-least-32-bytes.ok",
96
+ };
97
+ for (const [key, value] of composeAppEnvironment(rel)) {
98
+ if (value !== null) env[key] = value;
99
+ }
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";
103
+ }
104
+ return env;
105
+ }
106
+
34
107
  describe("#407 — the Dockerfile installs with everything pnpm needs", () => {
35
108
  it("copies pnpm-workspace.yaml into the deps stage", () => {
36
109
  // `create-auth-provider` generates it to carry the bcrypt
@@ -106,6 +179,62 @@ describe("#407 — the production compose matches the topology it documents", ()
106
179
  });
107
180
  });
108
181
 
182
+ 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
190
+ // guard answers "can these stores be shared", not "do these two stores have
191
+ // the same lifetime" — so nothing but this assertion stands behind it.
192
+ for (const file of ["/docker-compose.production.yml", "/docker-compose.yml"]) {
193
+ it(`${file} keeps the user-session stores with the express-session store`, () => {
194
+ const config = resolveWith(bootableEnv(file));
195
+ // Resolved through the real config layers, not read off the file:
196
+ // what matters is the value the process ends up with, whether the
197
+ // compose stated it or `config/application.conf` did.
198
+ expect(config.session.storage?.type).toBe("redis");
199
+ expect(config.userSessionStores?.adapter).toBe("redis");
200
+ });
201
+ }
202
+
203
+ it("the production compose leaves no store on memory while a sibling is on Redis", () => {
204
+ const config = resolveWith(bootableEnv("/docker-compose.production.yml"));
205
+ // Every store whose records must outlive one process. The federation
206
+ // token store is deliberately absent: this template ships every
207
+ // federation disabled, so nothing writes to it, and turning it on needs
208
+ // 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");
214
+ });
215
+ });
216
+
217
+ describe("the production compose refuses to guess HTTP_TRUST_PROXY", () => {
218
+ it("names the variable and supplies no default", () => {
219
+ // Without it the Secure cookie is never set, the CSRF origin check 403s
220
+ // every browser POST, and every IP-keyed rate limit shares one bucket.
221
+ // With a baked-in default it would silently trust a hop the operator
222
+ // never chose. The `${VAR:?err}` form is the only reading that is
223
+ // neither: compose refuses to start until the operator names their edge.
224
+ const compose = composeAppEnvironment("/docker-compose.production.yml");
225
+ expect(compose.has("HTTP_TRUST_PROXY")).toBe(true);
226
+ expect(compose.get("HTTP_TRUST_PROXY")).toBeNull();
227
+ expect(read("/docker-compose.production.yml")).not.toMatch(
228
+ /HTTP_TRUST_PROXY:\s*(true|false)\b/,
229
+ );
230
+ });
231
+
232
+ it("is offered in .env.example with no value", () => {
233
+ // The compose reads it from `.env`, which the operator copies from here.
234
+ expect(read("/.env.example")).toMatch(/^HTTP_TRUST_PROXY=$/m);
235
+ });
236
+ });
237
+
109
238
  describe("#407 — the two READMEs agree on security advice", () => {
110
239
  it("does not recommend HTTP_TRUST_PROXY=true in the Japanese README", () => {
111
240
  // #292 made `trust proxy` a CIDR/hop policy precisely because `true`