@o3co/create-auth-provider 0.17.0 → 0.18.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 (114) hide show
  1. package/README.md +2 -198
  2. package/main.js +4 -0
  3. package/package.json +8 -30
  4. package/README.ja.md +0 -200
  5. package/dist/cli.d.mts +0 -3
  6. package/dist/cli.d.mts.map +0 -1
  7. package/dist/cli.mjs +0 -18
  8. package/dist/index.d.mts +0 -48
  9. package/dist/index.d.mts.map +0 -1
  10. package/dist/index.mjs +0 -325
  11. package/dist/internal/template-filter.d.mts +0 -14
  12. package/dist/internal/template-filter.d.mts.map +0 -1
  13. package/dist/internal/template-filter.mjs +0 -42
  14. package/templates/standalone/.dockerignore +0 -23
  15. package/templates/standalone/.env.example +0 -152
  16. package/templates/standalone/Dockerfile +0 -125
  17. package/templates/standalone/Makefile +0 -12
  18. package/templates/standalone/README.ja.md +0 -912
  19. package/templates/standalone/README.md +0 -1536
  20. package/templates/standalone/config/application.conf +0 -351
  21. package/templates/standalone/config/clients.yaml +0 -4
  22. package/templates/standalone/config/clients.yaml.example +0 -124
  23. package/templates/standalone/config/development.conf +0 -17
  24. package/templates/standalone/config/mailpit.conf +0 -13
  25. package/templates/standalone/config/production.conf +0 -15
  26. package/templates/standalone/config/reference.conf +0 -427
  27. package/templates/standalone/docker-compose.mailpit.yml +0 -39
  28. package/templates/standalone/docker-compose.production.yml +0 -229
  29. package/templates/standalone/docker-compose.test.yml +0 -16
  30. package/templates/standalone/docker-compose.yml +0 -67
  31. package/templates/standalone/gitignore +0 -23
  32. package/templates/standalone/package.json +0 -56
  33. package/templates/standalone/src/__tests__/adapters.test.mts +0 -425
  34. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +0 -87
  35. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +0 -1188
  36. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +0 -285
  37. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +0 -1154
  38. package/templates/standalone/src/__tests__/audit-sink.test.mts +0 -355
  39. package/templates/standalone/src/__tests__/backchannel-peer.fixture.mts +0 -64
  40. package/templates/standalone/src/__tests__/config-notices.test.mts +0 -157
  41. package/templates/standalone/src/__tests__/configPath.test.mts +0 -69
  42. package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +0 -48
  43. package/templates/standalone/src/__tests__/core-federations.test.mts +0 -252
  44. package/templates/standalone/src/__tests__/core-sections.test.mts +0 -192
  45. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +0 -95
  46. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +0 -1291
  47. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -787
  48. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -464
  49. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +0 -141
  50. package/templates/standalone/src/__tests__/federation-types.test.mts +0 -239
  51. package/templates/standalone/src/__tests__/frontchannel-logout-csp.test.mts +0 -168
  52. package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -318
  53. package/templates/standalone/src/__tests__/host-routes.test.mts +0 -134
  54. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +0 -29
  55. package/templates/standalone/src/__tests__/library-references.fixture.mts +0 -188
  56. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +0 -217
  57. package/templates/standalone/src/__tests__/listen.test.mts +0 -121
  58. package/templates/standalone/src/__tests__/logger-projection.test.mts +0 -77
  59. package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +0 -113
  60. package/templates/standalone/src/__tests__/mail-sender.test.mts +0 -134
  61. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -393
  62. package/templates/standalone/src/__tests__/metrics.test.mts +0 -253
  63. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +0 -122
  64. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +0 -127
  65. package/templates/standalone/src/__tests__/mfa-switch.test.mts +0 -883
  66. package/templates/standalone/src/__tests__/own-modules.test.mts +0 -1480
  67. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
  68. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -283
  69. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +0 -135
  70. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +0 -214
  71. package/templates/standalone/src/__tests__/redis-stand-in.fixture.mts +0 -98
  72. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
  73. package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -657
  74. package/templates/standalone/src/__tests__/repositories-logger.test.mts +0 -119
  75. package/templates/standalone/src/__tests__/router-error-answers.test.mts +0 -146
  76. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +0 -610
  77. package/templates/standalone/src/__tests__/sections.test.mts +0 -516
  78. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -213
  79. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -305
  80. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +0 -130
  81. package/templates/standalone/src/__tests__/session-requirements.test.mts +0 -382
  82. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +0 -274
  83. package/templates/standalone/src/__tests__/shutdown.test.mts +0 -622
  84. package/templates/standalone/src/__tests__/smoke.test.mts +0 -642
  85. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -164
  86. package/templates/standalone/src/__tests__/store-credential.test.mts +0 -314
  87. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +0 -296
  88. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +0 -265
  89. package/templates/standalone/src/__tests__/two-phase-config.test.mts +0 -605
  90. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +0 -385
  91. package/templates/standalone/src/adapters.mts +0 -162
  92. package/templates/standalone/src/app.mts +0 -127
  93. package/templates/standalone/src/bootRefusal.mts +0 -117
  94. package/templates/standalone/src/buildModules.mts +0 -358
  95. package/templates/standalone/src/configPath.mts +0 -487
  96. package/templates/standalone/src/listen.mts +0 -60
  97. package/templates/standalone/src/logger.mts +0 -80
  98. package/templates/standalone/src/metrics.mts +0 -167
  99. package/templates/standalone/src/mfaSwitch.mts +0 -284
  100. package/templates/standalone/src/modules.mts +0 -705
  101. package/templates/standalone/src/rootRenames.mts +0 -95
  102. package/templates/standalone/src/routes.mts +0 -89
  103. package/templates/standalone/src/sections.mts +0 -378
  104. package/templates/standalone/src/securityHeaders.mts +0 -36
  105. package/templates/standalone/src/shutdown.mts +0 -213
  106. package/templates/standalone/tests/Makefile +0 -5
  107. package/templates/standalone/tests/index.test.js +0 -283
  108. package/templates/standalone/tests/package-lock.json +0 -1617
  109. package/templates/standalone/tests/package.json +0 -16
  110. package/templates/standalone/tests/vitest.config.mjs +0 -26
  111. package/templates/standalone/tsconfig.json +0 -31
  112. package/templates/standalone/vitest.config.mts +0 -31
  113. package/templates/standalone/vitest.supertest-loopback.mts +0 -278
  114. package/templates/versions.json +0 -20
@@ -1,657 +0,0 @@
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
- * The standalone's own store modules under `core.deployment.mode = "multi"`,
19
- * booted the way an operator reaches them: from the shipped HOCON with one
20
- * environment variable flipped.
21
- *
22
- * - Every memory branch must be refused by name, and the all-Redis
23
- * environment must boot. The replica-safety guard reads each module's
24
- * manifest declaration, which covers the template's own in-memory modules
25
- * and express-session's store (`sessionStoreModule`, which declares from
26
- * its own section).
27
- * - `federationTokenStore.type = "redis"` mounts
28
- * `redisFederationTokenStoreModule` off the shared ioredis socket.
29
- * - The Redis federation store's `allow-plaintext` guard reads the
30
- * environment the config was selected by (`CONFIG_ENV || NODE_ENV`, passed
31
- * through `buildModules`) and refuses under `core.deployment.mode = "multi"` in
32
- * every environment.
33
- *
34
- * ioredis, node-redis and connect-redis are mocked, as in
35
- * `device-code-store-client-module.test.mts`: the point is composition and
36
- * the boot planner's stage-1 verdict, not Redis. Nothing here issues a
37
- * command.
38
- */
39
-
40
- import { fileURLToPath } from "node:url";
41
- import {
42
- createApp,
43
- createKeyStoreFactory,
44
- defineModule,
45
- InMemoryClientRepository,
46
- InMemoryUserRepository,
47
- type Module,
48
- memoryRefreshTokenFamilyStoreModule,
49
- registerBuiltinKeyStores,
50
- replicaUnsafeReason,
51
- } from "@o3co/auth-provider-core";
52
- import { sessionStoreModule } from "@o3co/auth-provider-session";
53
- import { standardSmtpMailSenderConfigForTests } from "@o3co/auth-provider-standard/testing";
54
- import { parseFile } from "@o3co/ts.hocon";
55
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
56
- import { buildModules } from "../buildModules.mjs";
57
- import { resolveConfigPaths } from "../configPath.mjs";
58
- import { templateReference } from "../modules.mjs";
59
- import { keyStoreSectionSchema } from "../sections.mjs";
60
- import {
61
- type BothPhases,
62
- bothPhasesOf,
63
- capturedRenames,
64
- libraryLayers,
65
- } from "./library-references.fixture.mjs";
66
-
67
- // The redis session-store builder, which the baseline selects, dynamically
68
- // imports these; mock them so no socket opens.
69
- vi.mock("redis", () => ({
70
- createClient: vi.fn(() => ({
71
- connect: vi.fn().mockResolvedValue(undefined),
72
- quit: vi.fn().mockResolvedValue(undefined),
73
- ping: vi.fn().mockResolvedValue("PONG"),
74
- on: vi.fn(),
75
- })),
76
- }));
77
-
78
- vi.mock("connect-redis", async () => {
79
- const { EventEmitter } = await import("node:events");
80
- // express-session subscribes to store events, so the fake must be an
81
- // EventEmitter rather than a plain object.
82
- return {
83
- RedisStore: class MockRedisStore extends EventEmitter {
84
- constructor(_opts: { client: unknown }) {
85
- super();
86
- }
87
- get(): unknown {
88
- return undefined;
89
- }
90
- set(): void {}
91
- destroy(): void {}
92
- },
93
- };
94
- });
95
-
96
- vi.mock("ioredis", () => {
97
- // Every command resolves to nothing. A boot that reached Redis would be a
98
- // boot doing work at stage 2 that belongs in an integration test; the
99
- // stand-in only has to be constructible, quit cleanly, and answer `ping`
100
- // for the readiness probe the shared clients module registers.
101
- const explicit: Record<string, unknown> = {
102
- on: () => undefined,
103
- quit: async () => "OK",
104
- disconnect: () => undefined,
105
- ping: async () => "PONG",
106
- // The Redis stores' eviction gate reads the policy from INFO memory: a
107
- // default server's.
108
- info: async (section: string) =>
109
- section === "memory" ? "# Memory\r\nmaxmemory_policy:noeviction\r\n" : null,
110
- };
111
- const makeMockRedis = (): object =>
112
- new Proxy(
113
- {},
114
- {
115
- get(_target, prop) {
116
- // A `then` that is a function would make the instance a
117
- // thenable and hang anything that awaits it.
118
- if (typeof prop !== "string" || prop === "then") return undefined;
119
- if (prop === "duplicate") return makeMockRedis;
120
- if (prop in explicit) return explicit[prop];
121
- return async () => null;
122
- },
123
- },
124
- );
125
- // The template does `new Redis(url, options)`. A plain function that
126
- // returns an object yields that object under `new`, so no class — and no
127
- // constructor returning a value — is needed for the stand-in.
128
- function MockRedis(): object {
129
- return makeMockRedis();
130
- }
131
- return { Redis: MockRedis, default: MockRedis };
132
- });
133
-
134
- // config/ is two levels above this test file: src/__tests__/ → src/ → standalone/
135
- const configDir = fileURLToPath(new URL("../../config", import.meta.url));
136
-
137
- /** 32 bytes, base64 — what `REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY` carries. */
138
- const ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
139
-
140
- /**
141
- * The umbrella E2E's shape (`o3co/auth` `tests/docker-compose.yml`), with
142
- * every shared store on Redis, express-session's own included, and MFA off
143
- * as the umbrella sets it. Each case below flips one variable off this.
144
- */
145
- const ALL_REDIS_ENV: Readonly<Record<string, string>> = {
146
- MFA_MODE: "off",
147
- KEY_STORE_LOCAL_ALGORITHM: "HS256",
148
- KEY_STORE_LOCAL_SECRET: "replica-safety-test-secret.at-least-32-bytes.ok",
149
- OAUTH_JWT_ISSUER: "https://auth.test",
150
- SESSION_STORE_SECRET: "replica-safety-session-secret.at-least-32-bytes.ok",
151
- SESSION_STORE_SECURE: "false",
152
- SESSION_STORE_NAME: "auth.session",
153
- SESSION_STORE_STORAGE_TYPE: "redis",
154
- SESSION_STORE_STORAGE_REDIS_URL: "redis://redis.test:6379",
155
- ADAPTERS_USER_REPOSITORY: "yaml",
156
- CORE_DEPLOYMENT_MODE: "multi",
157
- REDIS_CLIENTS_URL: "redis://redis.test:6379",
158
- ADAPTERS_USER_SESSION_STORES: "redis",
159
- ADAPTERS_RATE_LIMITER: "redis",
160
- // The login's attempt counter, shared: per process, `multi` refuses the login.
161
- ADAPTERS_ATTEMPT_COUNTER: "redis",
162
- ADAPTERS_CODE_REPOSITORY: "redis",
163
- ADAPTERS_ACCESS_TOKEN_DENYLIST: "redis",
164
- ADAPTERS_REPLAY_SEEN_SET: "redis",
165
- ADAPTERS_FEDERATION_TOKEN_STORE: "redis",
166
- REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: ENCRYPTION_KEY,
167
- // The consent step for clients that are not first-party, on the
168
- // shared store — the one switch value `multi` accepts besides `none`.
169
- ADAPTERS_CONSENT_STORE: "redis",
170
- };
171
-
172
- function resolveConfig(env: Record<string, string>): BothPhases {
173
- const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
174
- const layers = parseFile(envConfPath, { env })
175
- .withFallback(parseFile(applicationConfPath, { env }))
176
- .withFallback(parseFile(fileURLToPath(templateReference()), { env }))
177
- .withFallback(libraryLayers(env));
178
- return {
179
- ...bothPhasesOf(layers, env),
180
- // What the resolution captured of the renamed variables.
181
- "renamed-variables": capturedRenames(env),
182
- } as BothPhases;
183
- }
184
-
185
- /** Drops a variable, so the HOCON default takes over. */
186
- function without(env: Record<string, string>, ...names: string[]): Record<string, string> {
187
- const copy = { ...env };
188
- for (const name of names) delete copy[name];
189
- return copy;
190
- }
191
-
192
- // The file-system-backed modules are replaced as in `smoke.test.mts`: the
193
- // boot under test is the store wiring, not the client registry on disk.
194
- const testRepositoriesModule = defineModule({
195
- name: "test:repositories",
196
- provides: {
197
- clientRepository: () => new InMemoryClientRepository(new Map()),
198
- userRepository: () => new InMemoryUserRepository(new Map()),
199
- },
200
- });
201
-
202
- // The template's `key-store` section, read by its own schema, without the
203
- // section's relocation bridge.
204
- const testKeyStoreModule = defineModule({
205
- name: "key-store",
206
- section: { schema: keyStoreSectionSchema },
207
- provides: {
208
- keyStore: async ({ section }) => {
209
- const factory = createKeyStoreFactory();
210
- registerBuiltinKeyStores(factory);
211
- return factory.create({
212
- type: "local",
213
- ...(section.local ?? {}),
214
- });
215
- },
216
- },
217
- });
218
-
219
- const modulesFor = (config: BothPhases, environment?: string) =>
220
- buildModules(config, {
221
- keyStoreModule: testKeyStoreModule,
222
- repositoriesModule: testRepositoriesModule,
223
- ...(environment === undefined ? {} : { environment }),
224
- });
225
-
226
- /**
227
- * `module`'s section in `config`, for a declaration made from it: each module
228
- * here that has one sits at its name.
229
- */
230
- const sectionOf = (config: BothPhases, module: Module): unknown =>
231
- (config as unknown as Record<string, unknown>)[module.name];
232
-
233
- const boot = (config: BothPhases, environment?: string) =>
234
- createApp({
235
- modules: modulesFor(config, environment),
236
- bootstrapComponents: { config, pathResolver: (s) => s },
237
- });
238
-
239
- /** The message of a boot failure and of the module error it wraps, together. */
240
- const messageChain = (err: unknown): string => {
241
- const e = err as { message?: string; cause?: { message?: string } };
242
- return `${e.message ?? ""} ${e.cause?.message ?? ""}`;
243
- };
244
-
245
- describe('the standalone\'s memory modules are refused under core.deployment.mode = "multi"', () => {
246
- let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
247
-
248
- afterEach(async () => {
249
- await handleRef?.dispose();
250
- handleRef = undefined;
251
- });
252
-
253
- const cases: ReadonlyArray<readonly [variable: string, module: string]> = [
254
- // The template's own modules.
255
- ["ADAPTERS_USER_SESSION_STORES", "standalone-in-memory-session-stores"],
256
- ["ADAPTERS_CODE_REPOSITORY", "standalone-in-memory-code-repository"],
257
- ["ADAPTERS_FEDERATION_TOKEN_STORE", "standalone-in-memory-federation-token-store"],
258
- // express-session's own store. Not a module of this template: it
259
- // declares from its own section, which boot parses.
260
- ["SESSION_STORE_STORAGE_TYPE", "session-store"],
261
- // The consent store, wired only when the switch says so.
262
- ["ADAPTERS_CONSENT_STORE", "core-consent-store-memory"],
263
- // Core's, selected by the same kind of switch.
264
- ["ADAPTERS_RATE_LIMITER", "core-rate-limiter-memory"],
265
- ["ADAPTERS_ACCESS_TOKEN_DENYLIST", "core-access-token-denylist-memory"],
266
- // The jti single-use record behind private_key_jwt client auth.
267
- ["ADAPTERS_REPLAY_SEEN_SET", "core-replay-seen-set-memory"],
268
- ];
269
-
270
- for (const [variable, module] of cases) {
271
- it(`${variable}=memory is refused, naming ${module}`, async () => {
272
- const config = resolveConfig({ ...ALL_REDIS_ENV, [variable]: "memory" });
273
- await expect(boot(config)).rejects.toMatchObject({
274
- name: "BootError",
275
- reason: "replica-unsafe-adapter",
276
- details: { modules: [module] },
277
- });
278
- });
279
- }
280
-
281
- it("names every memory module together when every switch is memory", async () => {
282
- const config = resolveConfig({
283
- ...ALL_REDIS_ENV,
284
- ADAPTERS_USER_SESSION_STORES: "memory",
285
- ADAPTERS_CODE_REPOSITORY: "memory",
286
- ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
287
- SESSION_STORE_STORAGE_TYPE: "memory",
288
- ADAPTERS_RATE_LIMITER: "memory",
289
- ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
290
- ADAPTERS_REPLAY_SEEN_SET: "memory",
291
- ADAPTERS_CONSENT_STORE: "memory",
292
- });
293
- await expect(boot(config)).rejects.toMatchObject({
294
- reason: "replica-unsafe-adapter",
295
- details: {
296
- modules: expect.arrayContaining(cases.map(([, module]) => module)),
297
- },
298
- });
299
- });
300
-
301
- it("each standalone memory module declares its consequence on its own manifest", () => {
302
- // The declaration is what the guard reads, so it has to be on
303
- // the module and it has to say what breaks — the guard quotes it.
304
- const config = resolveConfig({
305
- ...ALL_REDIS_ENV,
306
- ADAPTERS_USER_SESSION_STORES: "memory",
307
- ADAPTERS_CODE_REPOSITORY: "memory",
308
- ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
309
- });
310
- const standaloneMemoryModules = modulesFor(config).filter((m) =>
311
- m.name.startsWith("standalone-in-memory-"),
312
- );
313
- expect(standaloneMemoryModules.map((m) => m.name).sort()).toEqual([
314
- "standalone-in-memory-code-repository",
315
- "standalone-in-memory-federation-token-store",
316
- "standalone-in-memory-session-stores",
317
- ]);
318
- for (const m of standaloneMemoryModules) {
319
- expect(replicaUnsafeReason(m), m.name).toBeDefined();
320
- expect((replicaUnsafeReason(m) ?? "").length, m.name).toBeGreaterThan(40);
321
- }
322
- });
323
-
324
- it("boots the all-Redis environment, with nothing declaring replica-unsafe state", async () => {
325
- const config = resolveConfig(ALL_REDIS_ENV);
326
- for (const m of modulesFor(config)) {
327
- expect(replicaUnsafeReason(m, sectionOf(config, m)), m.name).toBeUndefined();
328
- }
329
- handleRef = await boot(config);
330
- expect(handleRef).toBeDefined();
331
- });
332
- });
333
-
334
- describe("express-session's store declares its replica safety from its own section", () => {
335
- it("is the session package's sessionStoreModule, listed first", () => {
336
- expect(modulesFor(resolveConfig(ALL_REDIS_ENV))[0]).toBe(sessionStoreModule);
337
- });
338
-
339
- /** Each way the storage type is set, laid over the all-Redis environment. */
340
- const STORAGE: ReadonlyArray<readonly [string, Record<string, string>]> = [
341
- [
342
- "SESSION_STORE_STORAGE_TYPE=memory",
343
- { ...ALL_REDIS_ENV, SESSION_STORE_STORAGE_TYPE: "memory" },
344
- ],
345
- ["SESSION_STORE_STORAGE_TYPE=redis", ALL_REDIS_ENV],
346
- [
347
- "no SESSION_STORE_STORAGE_TYPE, the reference's default",
348
- without(ALL_REDIS_ENV, "SESSION_STORE_STORAGE_TYPE"),
349
- ],
350
- [
351
- "the variable it was renamed from, SESSION_STORAGE_TYPE=memory",
352
- { ...without(ALL_REDIS_ENV, "SESSION_STORE_STORAGE_TYPE"), SESSION_STORAGE_TYPE: "memory" },
353
- ],
354
- ];
355
- const MODES: ReadonlyArray<
356
- readonly [string, (env: Record<string, string>) => Record<string, string>]
357
- > = [
358
- ["multi", (env) => ({ ...env, CORE_DEPLOYMENT_MODE: "multi" })],
359
- ["single", (env) => ({ ...env, CORE_DEPLOYMENT_MODE: "single" })],
360
- ["unset", (env) => without(env, "CORE_DEPLOYMENT_MODE")],
361
- ];
362
-
363
- /** What a boot of `modules` settles as: the refusal, or the replica-safety warnings it logged. */
364
- async function outcomeOf(config: BothPhases, modules: readonly Module[]): Promise<unknown> {
365
- const warn = vi.fn();
366
- const logger = {
367
- warn,
368
- info: vi.fn(),
369
- error: vi.fn(),
370
- debug: vi.fn(),
371
- trace: vi.fn(),
372
- fatal: vi.fn(),
373
- child: vi.fn(),
374
- };
375
- try {
376
- const handle = await createApp({
377
- modules: [...modules],
378
- bootstrapComponents: { config, logger, pathResolver: (s: string) => s } as never,
379
- });
380
- await handle.dispose();
381
- } catch (err) {
382
- const e = err as { reason?: string; message?: string; details?: unknown };
383
- return { refused: { reason: e.reason, message: e.message, details: e.details } };
384
- }
385
- return {
386
- warned: warn.mock.calls.filter(([, event]) => event === "replica_unsafe_adapters"),
387
- };
388
- }
389
-
390
- for (const [storage, env] of STORAGE) {
391
- for (const [mode, withMode] of MODES) {
392
- it(`${storage} under ${mode}`, async () => {
393
- const config = resolveConfig(withMode(env));
394
- const modules = modulesFor(config);
395
- expect(modules[0]).toBe(sessionStoreModule);
396
- const outcome = await outcomeOf(config, modules);
397
- if (
398
- storage === "SESSION_STORE_STORAGE_TYPE=redis" ||
399
- storage === "no SESSION_STORE_STORAGE_TYPE, the reference's default"
400
- ) {
401
- // A shared store: nothing is refused or warned about.
402
- expect(outcome).toEqual({ warned: [] });
403
- }
404
- if (storage === "SESSION_STORE_STORAGE_TYPE=memory") {
405
- const named = { modules: ["session-store"] };
406
- expect(outcome).toEqual(
407
- mode === "multi"
408
- ? {
409
- refused: expect.objectContaining({
410
- reason: "replica-unsafe-adapter",
411
- message: expect.stringContaining(
412
- "session-store: the express-session store forks per replica",
413
- ),
414
- details: expect.objectContaining(named),
415
- }),
416
- }
417
- : {
418
- warned:
419
- mode === "unset"
420
- ? [[expect.objectContaining(named), "replica_unsafe_adapters"]]
421
- : [],
422
- },
423
- );
424
- }
425
- if (storage === "the variable it was renamed from, SESSION_STORAGE_TYPE=memory") {
426
- // Refused as renamed before the replica-safety guard reads anything.
427
- expect(outcome).toEqual({
428
- refused: expect.objectContaining({ reason: "environment-variable-renamed" }),
429
- });
430
- }
431
- });
432
- }
433
- }
434
- });
435
-
436
- describe('adapters.federationTokenStore = "redis" in the standalone', () => {
437
- let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
438
-
439
- afterEach(async () => {
440
- await handleRef?.dispose();
441
- handleRef = undefined;
442
- });
443
-
444
- it("reaches phase one's adapters from ADAPTERS_FEDERATION_TOKEN_STORE", () => {
445
- expect(resolveConfig(ALL_REDIS_ENV).adapters.federationTokenStore).toBe("redis");
446
- expect(
447
- resolveConfig(without(ALL_REDIS_ENV, "ADAPTERS_FEDERATION_TOKEN_STORE")).adapters
448
- .federationTokenStore,
449
- ).toBe("memory");
450
- });
451
-
452
- it("selects the Redis module and the shared clients module, not the memory one", () => {
453
- const modules = modulesFor(resolveConfig(ALL_REDIS_ENV));
454
- const names = modules.map((m) => m.name);
455
- expect(names).toContain("redis-federation-token-store");
456
- expect(names).toContain("redis-clients");
457
- expect(names).not.toContain("standalone-in-memory-federation-token-store");
458
- // Exactly one provider for the slot: both modules provide it, so
459
- // selecting both would be a boot-time slot collision.
460
- const providers = modules.filter((m) =>
461
- Object.keys(m.provides ?? {}).includes("federationTokenStore"),
462
- );
463
- expect(providers.map((m) => m.name)).toEqual(["redis-federation-token-store"]);
464
- });
465
-
466
- it("pulls the shared clients module in for the federation store alone", () => {
467
- // Every other store on memory, single replica: the Redis federation
468
- // store still needs `federationTokenStoreClient`, which only the shared
469
- // clients module provides. Without this the branch fails stage 1 with
470
- // `missing-required-component`.
471
- const config = resolveConfig({
472
- ...ALL_REDIS_ENV,
473
- CORE_DEPLOYMENT_MODE: "single",
474
- ADAPTERS_USER_SESSION_STORES: "memory",
475
- ADAPTERS_CODE_REPOSITORY: "memory",
476
- ADAPTERS_RATE_LIMITER: "memory",
477
- ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
478
- });
479
- const names = modulesFor(config).map((m) => m.name);
480
- expect(names).toContain("redis-federation-token-store");
481
- expect(names).toContain("redis-clients");
482
- });
483
-
484
- it("boots, and the resolved store is the Redis adapter", async () => {
485
- handleRef = await boot(resolveConfig(ALL_REDIS_ENV));
486
- const store = handleRef.components.federationTokenStore;
487
- expect(store?.kind).toBe("redis");
488
- });
489
-
490
- it("fails at boot, naming the key, when the encryption key is missing", async () => {
491
- // The store encrypts long-lived IdP refresh tokens at rest; a Redis
492
- // branch with no key is a misconfiguration to refuse at boot, not a
493
- // store that works until the first federation login.
494
- const config = resolveConfig(
495
- without(ALL_REDIS_ENV, "REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY"),
496
- );
497
- await expect(boot(config)).rejects.toSatisfy((err: unknown) => {
498
- const e = err as { message?: string; cause?: { message?: string } };
499
- return /encryption\.key/.test(`${e.message ?? ""} ${e.cause?.message ?? ""}`);
500
- });
501
- });
502
-
503
- it("keeps the memory branch: the default, in single mode, resolves the memory adapter", async () => {
504
- const config = resolveConfig({
505
- ...without(ALL_REDIS_ENV, "ADAPTERS_FEDERATION_TOKEN_STORE"),
506
- CORE_DEPLOYMENT_MODE: "single",
507
- });
508
- const names = modulesFor(config).map((m) => m.name);
509
- expect(names).toContain("standalone-in-memory-federation-token-store");
510
- expect(names).not.toContain("redis-federation-token-store");
511
- handleRef = await boot(config);
512
- expect(handleRef.components.federationTokenStore?.kind).toBe("memory");
513
- });
514
- });
515
-
516
- describe('consentStore.adapter = "redis" in the standalone', () => {
517
- let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
518
-
519
- afterEach(async () => {
520
- await handleRef?.dispose();
521
- handleRef = undefined;
522
- });
523
-
524
- it("selects the Redis consent module, providing both slots, and not the memory one", () => {
525
- const modules = modulesFor(resolveConfig(ALL_REDIS_ENV));
526
- const names = modules.map((m) => m.name);
527
- expect(names).toContain("redis-consent-store");
528
- expect(names).not.toContain("core-consent-store-memory");
529
- // Exactly one provider for each slot: the consent step refuses a
530
- // composition with one and not the other, and two would collide.
531
- for (const slot of ["consentStore", "pendingConsentStore"]) {
532
- const providers = modules.filter((m) => Object.keys(m.provides ?? {}).includes(slot));
533
- expect(
534
- providers.map((m) => m.name),
535
- slot,
536
- ).toEqual(["redis-consent-store"]);
537
- }
538
- });
539
-
540
- it("pulls the shared clients module in for the consent stores alone", () => {
541
- // Every other store on memory, single replica: the consent module still
542
- // needs its two client slots, which only the shared clients module
543
- // provides.
544
- const config = resolveConfig({
545
- ...ALL_REDIS_ENV,
546
- CORE_DEPLOYMENT_MODE: "single",
547
- ADAPTERS_USER_SESSION_STORES: "memory",
548
- ADAPTERS_CODE_REPOSITORY: "memory",
549
- ADAPTERS_RATE_LIMITER: "memory",
550
- ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
551
- ADAPTERS_REPLAY_SEEN_SET: "memory",
552
- ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
553
- });
554
- const names = buildModules(config, {
555
- keyStoreModule: testKeyStoreModule,
556
- repositoriesModule: testRepositoriesModule,
557
- refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
558
- }).map((m) => m.name);
559
- expect(names).toContain("redis-consent-store");
560
- expect(names).toContain("redis-clients");
561
- });
562
-
563
- it("boots under CORE_DEPLOYMENT_MODE=multi with both slots resolved to the Redis adapters", async () => {
564
- handleRef = await boot(resolveConfig(ALL_REDIS_ENV));
565
- expect(handleRef.components.consentStore?.kind).toBe("redis");
566
- expect(handleRef.components.pendingConsentStore?.kind).toBe("redis");
567
- });
568
- });
569
-
570
- describe("the Redis federation store's plaintext guard, booted from the shipped config", () => {
571
- let handleRef: Awaited<ReturnType<typeof boot>> | undefined;
572
- let warnSpy: ReturnType<typeof vi.spyOn>;
573
- let errorSpy: ReturnType<typeof vi.spyOn>;
574
- let origInsecure: string | undefined;
575
-
576
- beforeEach(() => {
577
- // vitest runs with NODE_ENV=test: not a production name, so every
578
- // refusal below comes from what the standalone passes, not from NODE_ENV.
579
- expect(process.env.NODE_ENV).not.toMatch(/^(production|staging)$/);
580
- origInsecure = process.env.FEDERATION_TOKENS_ALLOW_INSECURE;
581
- delete process.env.FEDERATION_TOKENS_ALLOW_INSECURE;
582
- warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
583
- errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
584
- });
585
-
586
- afterEach(async () => {
587
- await handleRef?.dispose();
588
- handleRef = undefined;
589
- if (origInsecure === undefined) delete process.env.FEDERATION_TOKENS_ALLOW_INSECURE;
590
- else process.env.FEDERATION_TOKENS_ALLOW_INSECURE = origInsecure;
591
- warnSpy.mockRestore();
592
- errorSpy.mockRestore();
593
- });
594
-
595
- /** All-Redis, with the federation store told to skip encryption. */
596
- const PLAINTEXT_ENV: Readonly<Record<string, string>> = {
597
- ...without(ALL_REDIS_ENV, "REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY"),
598
- REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_MODE: "allow-plaintext",
599
- };
600
-
601
- it('is refused under core.deployment.mode = "multi" in an environment that is not production', async () => {
602
- // The umbrella shape with plaintext. A multi-replica deployment is
603
- // never a development box. A development configuration under multi is
604
- // refused by the development mail sender first, so the environment here
605
- // is another name that is not production, with the SMTP sender's section.
606
- await expect(
607
- boot({ ...resolveConfig(PLAINTEXT_ENV), ...standardSmtpMailSenderConfigForTests() }, "test"),
608
- ).rejects.toSatisfy((err: unknown) =>
609
- /allow-plaintext[\s\S]*core\.deployment\.mode is "multi"/.test(messageChain(err)),
610
- );
611
- expect(errorSpy).not.toHaveBeenCalled();
612
- });
613
-
614
- it("is refused when the config was selected by CONFIG_ENV=production, whatever NODE_ENV says", async () => {
615
- // `app.mts` selects `production.conf` by `CONFIG_ENV || NODE_ENV` and
616
- // passes that name through `buildModules`; the guard reads that name,
617
- // not NODE_ENV alone.
618
- // Under production the template installs the SMTP sender's module, whose
619
- // section the package's builder carries.
620
- const config = {
621
- ...resolveConfig({ ...PLAINTEXT_ENV, CORE_DEPLOYMENT_MODE: "single" }),
622
- ...standardSmtpMailSenderConfigForTests(),
623
- };
624
- await expect(boot(config, "production")).rejects.toSatisfy((err: unknown) =>
625
- /allow-plaintext[\s\S]*the environment is "production"/.test(messageChain(err)),
626
- );
627
- });
628
-
629
- it("boots with the plaintext warning in a development environment on a single replica", async () => {
630
- const config = resolveConfig({ ...PLAINTEXT_ENV, CORE_DEPLOYMENT_MODE: "single" });
631
- handleRef = await boot(config, "development");
632
- expect(handleRef.components.federationTokenStore?.kind).toBe("redis");
633
- expect(warnSpy).toHaveBeenCalledWith(
634
- { store: "federation-tokens", mode: "allow-plaintext" },
635
- "federation_store_plaintext",
636
- );
637
- });
638
-
639
- it("keeps the FEDERATION_TOKENS_ALLOW_INSECURE=1 escape hatch under multi, logged at error", async () => {
640
- process.env.FEDERATION_TOKENS_ALLOW_INSECURE = "1";
641
- // Under multi the development mail sender refuses a development
642
- // configuration, so the environment here is another name that is not
643
- // production, with the SMTP sender's section its module reads.
644
- handleRef = await boot(
645
- { ...resolveConfig(PLAINTEXT_ENV), ...standardSmtpMailSenderConfigForTests() },
646
- "test",
647
- );
648
- expect(errorSpy).toHaveBeenCalledWith(
649
- expect.objectContaining({
650
- store: "federation-tokens",
651
- deploymentMode: "multi",
652
- override: "FEDERATION_TOKENS_ALLOW_INSECURE",
653
- }),
654
- "federation_store_plaintext_override",
655
- );
656
- });
657
- });