@o3co/create-auth-provider 0.17.0-rc.2 → 0.17.0-rc.4

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 (43) hide show
  1. package/README.ja.md +3 -1
  2. package/README.md +5 -2
  3. package/package.json +3 -3
  4. package/templates/standalone/.env.example +7 -2
  5. package/templates/standalone/README.ja.md +17 -11
  6. package/templates/standalone/README.md +47 -21
  7. package/templates/standalone/config/application.conf +1 -0
  8. package/templates/standalone/config/clients.yaml.example +8 -0
  9. package/templates/standalone/config/development.conf +2 -2
  10. package/templates/standalone/config/mailpit.conf +10 -5
  11. package/templates/standalone/config/reference.conf +9 -3
  12. package/templates/standalone/docker-compose.mailpit.yml +3 -0
  13. package/templates/standalone/package.json +6 -6
  14. package/templates/standalone/src/__tests__/adapters.test.mts +12 -6
  15. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +13 -5
  16. package/templates/standalone/src/__tests__/config-notices.test.mts +4 -1
  17. package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +48 -0
  18. package/templates/standalone/src/__tests__/core-federations.test.mts +9 -6
  19. package/templates/standalone/src/__tests__/core-sections.test.mts +7 -5
  20. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +91 -87
  21. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +4 -0
  22. package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +113 -0
  23. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +81 -14
  24. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +8 -4
  25. package/templates/standalone/src/__tests__/mfa-switch.test.mts +79 -21
  26. package/templates/standalone/src/__tests__/own-modules.test.mts +38 -12
  27. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +214 -0
  28. package/templates/standalone/src/__tests__/replica-safety.test.mts +4 -0
  29. package/templates/standalone/src/__tests__/repositories-logger.test.mts +119 -0
  30. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +10 -3
  31. package/templates/standalone/src/__tests__/sections.test.mts +24 -0
  32. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +4 -0
  33. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +148 -0
  34. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +8 -4
  35. package/templates/standalone/src/__tests__/two-phase-config.test.mts +2 -1
  36. package/templates/standalone/src/adapters.mts +2 -2
  37. package/templates/standalone/src/mfaSwitch.mts +6 -7
  38. package/templates/standalone/src/modules.mts +21 -9
  39. package/templates/standalone/src/rootRenames.mts +12 -13
  40. package/templates/standalone/src/sections.mts +12 -2
  41. package/templates/standalone/tests/package-lock.json +95 -95
  42. package/templates/standalone/tests/package.json +1 -1
  43. package/templates/versions.json +18 -18
@@ -102,6 +102,10 @@ vi.mock("ioredis", () => {
102
102
  quit: async () => "OK",
103
103
  disconnect: () => undefined,
104
104
  ping: async () => "PONG",
105
+ // The Redis stores' eviction gate reads the policy from INFO memory: a
106
+ // default server's.
107
+ info: async (section: string) =>
108
+ section === "memory" ? "# Memory\r\nmaxmemory_policy:noeviction\r\n" : null,
105
109
  };
106
110
  const makeMockRedis = (): object =>
107
111
  new Proxy(
@@ -0,0 +1,119 @@
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 repositories module builds its user repository with the composition's
19
+ * logger: the yaml user builder's `user_repository_in_memory` line reaches the
20
+ * `logger` bootstrap component (the template's pino logger, at its level),
21
+ * not the console.
22
+ */
23
+
24
+ import fs from "node:fs";
25
+ import os from "node:os";
26
+ import path from "node:path";
27
+ import {
28
+ type BootstrapMap,
29
+ createApp,
30
+ defineModule,
31
+ type Logger,
32
+ type UserRepository,
33
+ } from "@o3co/auth-provider-core";
34
+ import {
35
+ CORE_RELOCATIONS,
36
+ makeValidCoreConfig,
37
+ renamedVariableCaptures,
38
+ } from "@o3co/auth-provider-core/testing";
39
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
40
+ import { repositoriesModuleFor } from "../modules.mjs";
41
+
42
+ const spyLogger = () => {
43
+ const logger = {
44
+ trace: vi.fn(),
45
+ debug: vi.fn(),
46
+ info: vi.fn(),
47
+ warn: vi.fn(),
48
+ error: vi.fn(),
49
+ fatal: vi.fn(),
50
+ child: vi.fn(),
51
+ };
52
+ logger.child.mockReturnValue(logger);
53
+ return logger;
54
+ };
55
+
56
+ describe("the repositories module's user repository", () => {
57
+ let dir: string;
58
+ beforeEach(() => {
59
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "repositories-logger-"));
60
+ });
61
+ afterEach(() => {
62
+ fs.rmSync(dir, { recursive: true, force: true });
63
+ vi.restoreAllMocks();
64
+ });
65
+
66
+ it("warns user_repository_in_memory on the composition's logger, built through createApp, and not on the console", async () => {
67
+ const usersPath = path.join(dir, "users.yaml");
68
+ fs.writeFileSync(usersPath, 'alice:\n password: "plainpass"\n');
69
+ const clientsPath = path.join(dir, "clients.yaml");
70
+ fs.writeFileSync(clientsPath, "");
71
+ const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
72
+ const logger = spyLogger();
73
+ let built: UserRepository | undefined;
74
+ const reader = defineModule({
75
+ name: "test:user-repository-reader",
76
+ requires: ["userRepository"] as const,
77
+ provides: {
78
+ userRepositoryRead: ({ userRepository }: { userRepository: UserRepository }) => {
79
+ built = userRepository;
80
+ return true;
81
+ },
82
+ },
83
+ lifecycle: { userRepositoryRead: { eager: true } },
84
+ } as never);
85
+
86
+ const repositories = repositoriesModuleFor({ client: "yaml", user: "yaml" });
87
+ const handle = await createApp({
88
+ modules: [repositories, reader],
89
+ bootstrapComponents: {
90
+ config: {
91
+ ...makeValidCoreConfig(),
92
+ repositories: {
93
+ client: { yaml: { path: clientsPath } },
94
+ user: { yaml: { path: usersPath }, http: {} },
95
+ },
96
+ // What a resolution with none of the renamed variables set captures.
97
+ "renamed-variables": renamedVariableCaptures({
98
+ modules: [repositories],
99
+ core: CORE_RELOCATIONS,
100
+ env: {},
101
+ }),
102
+ },
103
+ pathResolver: (p: string) => p,
104
+ logger: logger as unknown as Logger,
105
+ } as unknown as BootstrapMap,
106
+ });
107
+ try {
108
+ expect((await built?.authenticate("alice", "plainpass"))?.username).toBe("alice");
109
+ expect(
110
+ logger.warn.mock.calls.filter(([, event]) => event === "user_repository_in_memory"),
111
+ ).toEqual([[{ store: "userRepository", adapter: "yaml" }, "user_repository_in_memory"]]);
112
+ expect(
113
+ consoleWarn.mock.calls.filter((args) => args.includes("user_repository_in_memory")),
114
+ ).toEqual([]);
115
+ } finally {
116
+ await handle.dispose();
117
+ }
118
+ });
119
+ });
@@ -32,6 +32,7 @@ import { buildModules } from "../buildModules.mjs";
32
32
  import { readOwnLayers, readSwitches, resolveConfigPaths } from "../configPath.mjs";
33
33
  import { templateReference } from "../modules.mjs";
34
34
  import { type Adapters, repositoriesSectionSchema } from "../sections.mjs";
35
+ import { configuredMfaMode } from "./configured-mfa-mode.fixture.mjs";
35
36
 
36
37
  const standaloneDir = fileURLToPath(new URL("../..", import.meta.url));
37
38
  const configDir = fileURLToPath(new URL("../../config", import.meta.url));
@@ -355,8 +356,8 @@ function dotenvExample(): Record<string, string> {
355
356
  return env;
356
357
  }
357
358
 
358
- describe("the production compose leaves MFA on, for the deployment to decide", () => {
359
- it("reads the switch as required, so a deployment that sets nothing about MFA is refused naming MFA_MODE=off", () => {
359
+ describe("the production compose leaves MFA to the configuration's switch, for the deployment to decide", () => {
360
+ it("reads the switch as the configuration's files write it: on, a deployment that sets nothing about MFA is refused naming MFA_MODE=off; off, it installs nothing of MFA", () => {
360
361
  // `environment:` wins over `env_file`, so the compose file's block is
361
362
  // laid over the `.env` copied from `.env.example`.
362
363
  const env = { ...dotenvExample(), ...bootableEnv("/docker-compose.production.yml") };
@@ -365,8 +366,14 @@ describe("the production compose leaves MFA on, for the deployment to decide", (
365
366
  expect(configEnv).toBe("production");
366
367
  const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, configEnv);
367
368
  const switches = readSwitches(readOwnLayers([envConfPath, applicationConfPath], { env }));
368
- expect(switches.mfaMode).toBe("required");
369
+ const mode = configuredMfaMode(configEnv);
370
+ expect(switches.mfaMode).toBe(mode);
369
371
 
372
+ if (mode === "off") {
373
+ const names = buildModules(switches, { environment: configEnv }).map((m) => m.name);
374
+ expect(names.filter((name) => /mfa/i.test(name))).toEqual([]);
375
+ return;
376
+ }
370
377
  let err: unknown;
371
378
  try {
372
379
  buildModules(switches, { environment: configEnv });
@@ -443,12 +443,36 @@ describe("redis-clients", () => {
443
443
  expect(redisClientsSectionSchema.parse({ url: "redis://r:6379", password: "p" })).toEqual({
444
444
  url: "redis://r:6379",
445
445
  password: "p",
446
+ assumeNoEviction: false,
446
447
  });
447
448
  expect(redisClientsSectionSchema.parse({ url: "redis://r:6379" })).toEqual({
448
449
  url: "redis://r:6379",
450
+ assumeNoEviction: false,
449
451
  });
450
452
  });
451
453
 
454
+ it.each([
455
+ [true, true],
456
+ ["true", true],
457
+ ["1", true],
458
+ ["false", false],
459
+ ["", false],
460
+ ])("reads assumeNoEviction %j as %s", (given, read) => {
461
+ expect(
462
+ redisClientsSectionSchema.parse({ url: "redis://r:6379", assumeNoEviction: given })
463
+ .assumeNoEviction,
464
+ ).toBe(read);
465
+ });
466
+
467
+ it("refuses an assumeNoEviction it cannot read as a boolean, naming the key", () => {
468
+ const parsed = redisClientsSectionSchema.safeParse({
469
+ url: "redis://r:6379",
470
+ assumeNoEviction: "yes",
471
+ });
472
+ expect(parsed.success).toBe(false);
473
+ expect(parsed.error?.issues[0]?.path).toEqual(["assumeNoEviction"]);
474
+ });
475
+
452
476
  it.each([
453
477
  ["no URL", {}],
454
478
  ["a null URL", { url: null }],
@@ -74,6 +74,10 @@ vi.mock("ioredis", () => {
74
74
  quit: async () => "OK",
75
75
  disconnect: () => undefined,
76
76
  ping: async () => "PONG",
77
+ // The Redis stores' eviction gate reads the policy from INFO memory: a
78
+ // default server's.
79
+ info: async (section: string) =>
80
+ section === "memory" ? "# Memory\r\nmaxmemory_policy:noeviction\r\n" : null,
77
81
  };
78
82
  const makeMockRedis = (): object =>
79
83
  new Proxy(
@@ -0,0 +1,148 @@
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
+ * What the template writes while it starts: `src/app.mts` in a process of its
19
+ * own, in development on the memory stores, until it listens and then until
20
+ * it has shut down. Every line on stdout and on stderr is one JSON object from
21
+ * the structured logger — a warning a store gives at boot included.
22
+ */
23
+
24
+ import { type ChildProcess, spawn } from "node:child_process";
25
+ import { generateKeyPairSync } from "node:crypto";
26
+ import { mkdtempSync, writeFileSync } from "node:fs";
27
+ import { createRequire } from "node:module";
28
+ import { tmpdir } from "node:os";
29
+ import { join } from "node:path";
30
+ import { fileURLToPath } from "node:url";
31
+ import { describe, expect, it } from "vitest";
32
+
33
+ const templateRoot = fileURLToPath(new URL("../..", import.meta.url));
34
+ const tsxCli = createRequire(import.meta.url).resolve("tsx/cli");
35
+
36
+ const keyPair = generateKeyPairSync("ed25519", {
37
+ publicKeyEncoding: { type: "spki", format: "pem" },
38
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
39
+ });
40
+
41
+ /** An empty client registry: a scaffolded project's clone has no `config/clients.yaml`. */
42
+ const clientsFile = (() => {
43
+ const file = join(mkdtempSync(join(tmpdir(), "startup-log-lines-")), "clients.yaml");
44
+ writeFileSync(file, "");
45
+ return file;
46
+ })();
47
+
48
+ /**
49
+ * Development, with every store the `adapters` section selects in memory.
50
+ * The refresh-token family store has no memory choice in the template; its
51
+ * shared Redis socket points at a port nothing answers, so the run reaches no
52
+ * real server. Nothing is inherited from the test runner's environment but
53
+ * PATH, so its NODE_ENV and LOGGING_LEVEL do not reach the child.
54
+ */
55
+ const ENV: Readonly<Record<string, string>> = {
56
+ PATH: process.env.PATH ?? "",
57
+ CONFIG_ENV: "development",
58
+ NODE_ENV: "development",
59
+ LOGGING_LEVEL: "info",
60
+ HTTP_PORT: "0",
61
+ OAUTH_JWT_ISSUER: "http://localhost:3000",
62
+ KEY_STORE_LOCAL_PRIVATE_KEY: keyPair.privateKey,
63
+ KEY_STORE_LOCAL_PUBLIC_KEY: keyPair.publicKey,
64
+ SESSION_STORE_SECRET: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
65
+ SESSION_STORE_SECURE: "false",
66
+ SESSION_STORE_NAME: "auth.sid",
67
+ SESSION_STORE_STORAGE_TYPE: "memory",
68
+ ADAPTERS_RATE_LIMITER: "memory",
69
+ ADAPTERS_ATTEMPT_COUNTER: "memory",
70
+ ADAPTERS_USER_SESSION_STORES: "memory",
71
+ ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
72
+ ADAPTERS_REPLAY_SEEN_SET: "memory",
73
+ ADAPTERS_CODE_REPOSITORY: "memory",
74
+ ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
75
+ ADAPTERS_MFA_FACTOR_STORE: "memory",
76
+ ADAPTERS_MFA_TRANSACTION_STORE: "memory",
77
+ REDIS_CLIENTS_URL: "redis://127.0.0.1:9",
78
+ REPOSITORIES_CLIENT_YAML_PATH: clientsFile,
79
+ REPOSITORIES_USER_HTTP_AUTHENTICATE_URL: "http://127.0.0.1:9/authenticate",
80
+ REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL: "http://127.0.0.1:9/authenticate-by-token",
81
+ };
82
+
83
+ interface Run {
84
+ readonly stdout: string;
85
+ readonly stderr: string;
86
+ readonly listened: boolean;
87
+ }
88
+
89
+ /** Starts the template, stops it once it listens (or gives up), and returns what it wrote. */
90
+ function startAndStop(timeoutMs: number): Promise<Run> {
91
+ return new Promise((resolve, reject) => {
92
+ const child: ChildProcess = spawn(process.execPath, [tsxCli, "src/app.mts"], {
93
+ cwd: templateRoot,
94
+ env: ENV,
95
+ stdio: ["ignore", "pipe", "pipe"],
96
+ });
97
+ let stdout = "";
98
+ let stderr = "";
99
+ let listened = false;
100
+ const stop = (): void => {
101
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
102
+ };
103
+ const deadline = setTimeout(stop, timeoutMs);
104
+ child.stdout?.setEncoding("utf8").on("data", (chunk: string) => {
105
+ stdout += chunk;
106
+ if (!listened && stdout.includes('"msg":"server_listening"')) {
107
+ listened = true;
108
+ stop();
109
+ }
110
+ });
111
+ child.stderr?.setEncoding("utf8").on("data", (chunk: string) => {
112
+ stderr += chunk;
113
+ });
114
+ child.on("error", (err) => {
115
+ clearTimeout(deadline);
116
+ reject(err);
117
+ });
118
+ child.on("close", () => {
119
+ clearTimeout(deadline);
120
+ resolve({ stdout, stderr, listened });
121
+ });
122
+ });
123
+ }
124
+
125
+ /** Each line of `text` that is not one JSON object. */
126
+ function linesThatAreNotJsonObjects(text: string): string[] {
127
+ return text
128
+ .split("\n")
129
+ .filter((line) => line.trim() !== "")
130
+ .filter((line) => {
131
+ try {
132
+ const parsed: unknown = JSON.parse(line);
133
+ return typeof parsed !== "object" || parsed === null || Array.isArray(parsed);
134
+ } catch {
135
+ return true;
136
+ }
137
+ });
138
+ }
139
+
140
+ describe("the template's startup output", () => {
141
+ it("is one JSON object per line on stdout and stderr, in development on the memory stores", async () => {
142
+ const run = await startAndStop(15_000);
143
+
144
+ expect(run.listened, `the template did not listen; stderr:\n${run.stderr}`).toBe(true);
145
+ expect(linesThatAreNotJsonObjects(run.stdout)).toEqual([]);
146
+ expect(linesThatAreNotJsonObjects(run.stderr)).toEqual([]);
147
+ }, 30_000);
148
+ });
@@ -29,6 +29,7 @@ import {
29
29
  type Switches,
30
30
  } from "../configPath.mjs";
31
31
  import { templateReference } from "../modules.mjs";
32
+ import { configuredMfaMode } from "./configured-mfa-mode.fixture.mjs";
32
33
  import { type BothPhases, withSwitches } from "./library-references.fixture.mjs";
33
34
 
34
35
  // config/ is two levels above this test file:
@@ -109,15 +110,18 @@ describe("three-tier HOCON resolution (env → application.conf → reference.co
109
110
  expect(grants(config).authorizationCode?.enabled).toBe("false");
110
111
  });
111
112
 
112
- it("reads the MFA switch, mfaMode, as required where MFA_MODE is unset, and installs no MFA module under MFA_MODE=off", () => {
113
- // The template's reference.conf ships the switch required and binds MFA_MODE.
113
+ it("reads the MFA switch, mfaMode, as the files write it where MFA_MODE is unset, and installs no MFA module under MFA_MODE=off", () => {
114
+ // The template's reference.conf ships the switch required and binds
115
+ // MFA_MODE; a project's application.conf may write it over that.
114
116
  const mfaNames = (config: Switches) =>
115
117
  buildModules(config)
116
118
  .map((m) => m.name)
117
119
  .filter((name) => /mfa/i.test(name));
118
120
  const shipped = buildResolvedConfig("development");
119
- expect(shipped.mfaMode).toBe("required");
120
- expect(mfaNames(shipped)).toContain("mfa");
121
+ const configured = configuredMfaMode("development");
122
+ expect(shipped.mfaMode).toBe(configured);
123
+ if (configured === "off") expect(mfaNames(shipped)).toEqual([]);
124
+ else expect(mfaNames(shipped)).toContain("mfa");
121
125
  const off = buildResolvedConfig("development", { MFA_MODE: "off" });
122
126
  expect(off.mfaMode).toBe("off");
123
127
  expect(mfaNames(off)).toEqual([]);
@@ -69,6 +69,7 @@ import {
69
69
  type Switches,
70
70
  } from "../configPath.mjs";
71
71
  import { MFA_SWITCH } from "../mfaSwitch.mjs";
72
+ import { configuredMfaMode } from "./configured-mfa-mode.fixture.mjs";
72
73
 
73
74
  const configDir = fileURLToPath(new URL("../../config", import.meta.url));
74
75
 
@@ -270,7 +271,7 @@ describe("phase one reads federation-grants.enabled as the federation-grants mod
270
271
  it("reads the MFA switch as its own mfaMode, as the environment sets it, and no key of the MFA module's", () => {
271
272
  for (const [name, env] of Object.entries(ENVIRONMENTS)) {
272
273
  const switches = readSwitches(readOwnLayers(ownFiles("production"), { env }));
273
- expect(switches.mfaMode, name).toBe(env.MFA_MODE ?? "required");
274
+ expect(switches.mfaMode, name).toBe(env.MFA_MODE ?? configuredMfaMode("production"));
274
275
  expect(switches, name).not.toHaveProperty("mfa");
275
276
  }
276
277
  });
@@ -21,7 +21,7 @@
21
21
  * moved from, and the variables renamed with them, are refused here, before
22
22
  * any module is chosen, in the words and under the reasons boot refuses a
23
23
  * module's with (`config-path-relocated`, `environment-variable-renamed`): a
24
- * renamed variable set alone, or beside its new name at a different value, is
24
+ * renamed variable set, alone or beside its new name at any value, is
25
25
  * refused, naming the variables and never a value.
26
26
  */
27
27
 
@@ -128,7 +128,7 @@ export function readAdapters(
128
128
  `Configuration sets ${moved.length} path(s) that moved: ${moved
129
129
  .map(
130
130
  ({ from, to, environmentVariable }) =>
131
- `${from} has moved to ${to}; see CHANGELOG. Write it there (environment variable ${environmentVariable}) and remove this field from your config (or unset the environment variable that sets it).`,
131
+ `${from} has moved to ${to}; see the upgrade guide (docs/upgrading-from-v0.16.0.md). Write it there (environment variable ${environmentVariable}) and remove this field from your config (or unset the environment variable that sets it).`,
132
132
  )
133
133
  .join(" ")}`,
134
134
  moved,
@@ -38,6 +38,7 @@
38
38
  */
39
39
 
40
40
  import {
41
+ isDevelopmentEnvironment,
41
42
  type Module,
42
43
  memoryMfaFactorStoreModule,
43
44
  memoryMfaTransactionStoreModule,
@@ -96,13 +97,11 @@ export function readMfaSwitch(
96
97
  return result.data;
97
98
  }
98
99
 
99
- /** The names the memory MFA stores are let in under. */
100
- const DEVELOPMENT_NAMES: ReadonlySet<string> = new Set(["development", "test"]);
101
-
102
100
  /**
103
101
  * Why the MFA stores may not be kept in memory under `environment` and
104
- * `CONFIG_ENV` and `NODE_ENV` wherever they are set: each name that is not
105
- * development or test, or that nothing is named. None lifts another's.
102
+ * `CONFIG_ENV` and `NODE_ENV` wherever they are set, as core's
103
+ * `isDevelopmentEnvironment` reads them: each name that is not development
104
+ * or test, or that nothing is named. None lifts another's.
106
105
  */
107
106
  function memoryRefusals(environment: string | undefined): string[] {
108
107
  const named: [string, string][] =
@@ -113,10 +112,10 @@ function memoryRefusals(environment: string | undefined): string[] {
113
112
  }
114
113
  if (named.length === 0) return ["no environment is named"];
115
114
  return named.flatMap(([label, value]) => {
115
+ if (isDevelopmentEnvironment([value])) return [];
116
116
  const production = productionEnvironmentIn([value]);
117
117
  if (production !== undefined) return [`${label} is "${production}"`];
118
- const name = readEnvironmentName(value) ?? "";
119
- return DEVELOPMENT_NAMES.has(name) ? [] : [`${label} "${name}" is not development or test`];
118
+ return [`${label} "${readEnvironmentName(value)}" is not development or test`];
120
119
  });
121
120
  }
122
121
 
@@ -240,7 +240,10 @@ export function repositoriesModuleFor(selection: RepositorySelection): Module {
240
240
  // Forwarded into the repository factories so client/user adapters can
241
241
  // register disposal callbacks (file-watch closers, say). Without it, a
242
242
  // builder's `ctx.lifecycle?.register` is a no-op and those resources leak.
243
- optional: ["lifecycleRegistrar"] as const,
243
+ // The logger is the composition's, so what a builder logs (the yaml user
244
+ // repository's `user_repository_in_memory`) goes where the template's
245
+ // logging goes, at its level, rather than to the console.
246
+ optional: ["lifecycleRegistrar", "logger"] as const,
244
247
  provides: {
245
248
  clientRepository: async ({ section, lifecycleRegistrar }) => {
246
249
  const { clientFactory } = createRepositoryFactories({ lifecycle: lifecycleRegistrar });
@@ -250,8 +253,11 @@ export function repositoriesModuleFor(selection: RepositorySelection): Module {
250
253
  path: path.resolve(process.cwd(), file.path),
251
254
  });
252
255
  },
253
- userRepository: async ({ section, lifecycleRegistrar }) => {
254
- const { userFactory } = createRepositoryFactories({ lifecycle: lifecycleRegistrar });
256
+ userRepository: async ({ section, lifecycleRegistrar, logger }) => {
257
+ const { userFactory } = createRepositoryFactories({
258
+ lifecycle: lifecycleRegistrar,
259
+ logger,
260
+ });
255
261
  registerBuiltinAdapters({ userFactory });
256
262
  return userFactory.create(
257
263
  flattenAdapterConfig({
@@ -338,7 +344,7 @@ export const inMemorySessionStoresModule: Module = defineModule({
338
344
  * the `adapters.userSessionStores` selection.
339
345
  *
340
346
  * The memory adapter comes through core's factory: that is where its
341
- * "dev/test only" boot warning lives.
347
+ * "dev/test only" boot warning lives, said on the composition's `logger`.
342
348
  */
343
349
  export const inMemoryFederationTokenStoreModule: Module = defineModule({
344
350
  name: "standalone-in-memory-federation-token-store",
@@ -347,10 +353,11 @@ export const inMemoryFederationTokenStoreModule: Module = defineModule({
347
353
  reason:
348
354
  "upstream federation tokens fork per replica — a token stored by the replica that completed the federation callback is missing on the others, so a session cannot refresh its upstream token from another replica, and logout removes only the tokens the replica it lands on can see",
349
355
  },
356
+ optional: ["logger"] as const,
350
357
  provides: {
351
- federationTokenStore: async () => {
358
+ federationTokenStore: async ({ logger }) => {
352
359
  const factory = createFederationTokenStoreFactory();
353
- registerBuiltinFederationTokenStores(factory);
360
+ registerBuiltinFederationTokenStores(factory, logger);
354
361
  return factory.create({ type: "memory" });
355
362
  },
356
363
  },
@@ -408,7 +415,8 @@ export function auditSinkModuleFor(sink: string): Module {
408
415
  * `redis-clients`, which moved from `refreshTokenFamilyStore.redis` with its
409
416
  * variables (`REDIS_CLIENTS_*`). Per-store Redis instances belong in a
410
417
  * custom composition root. An empty URL throws rather than falling back to
411
- * localhost. `io.quit()` is registered once with `lifecycleRegistrar`, so
418
+ * localhost. `assumeNoEviction` is handed to every client, for the Redis
419
+ * stores' eviction gate. `io.quit()` is registered once with `lifecycleRegistrar`, so
412
420
  * `handle.dispose()` closes the connection.
413
421
  *
414
422
  * `buildModules` adds this module only when some composed module needs a
@@ -639,7 +647,11 @@ type StandaloneRedisClients = ReturnType<typeof makeIoredisClients> & {
639
647
  // registrar (tests that seed none), each call creates a fresh client.
640
648
  const clientsCache = new WeakMap<LifecycleRegistrar, StandaloneRedisClients>();
641
649
  function getOrCreateClients(
642
- section: { readonly url: string; readonly password?: string | undefined },
650
+ section: {
651
+ readonly url: string;
652
+ readonly password?: string | undefined;
653
+ readonly assumeNoEviction: boolean;
654
+ },
643
655
  lifecycleRegistrar: LifecycleRegistrar | undefined,
644
656
  readinessRegistrar?: ReadinessRegistrar,
645
657
  injectedLogger?: Logger,
@@ -684,7 +696,7 @@ function getOrCreateClients(
684
696
  // (`refreshTokenFamilyClient.duplicate()`), which inherit no listeners from
685
697
  // `io`; passing the logger lets those report through the same channel.
686
698
  const clients: StandaloneRedisClients = {
687
- ...makeIoredisClients(io, { logger }),
699
+ ...makeIoredisClients(io, { logger, assumeNoEviction: section.assumeNoEviction }),
688
700
  federationGrantStoreClient: makeIoredisFederationGrantStoreClient(io),
689
701
  federationGrantIntentStoreClient: makeIoredisFederationGrantIntentStoreClient(io),
690
702
  };
@@ -17,12 +17,12 @@
17
17
  /**
18
18
  * The variables renamed with a path of the composition root's own layers,
19
19
  * which no module declares: the adapter selections and the federations the
20
- * template ships. Phase one refuses an old name set alone, or beside its new
21
- * name at a different value, before any module is chosen, as boot refuses a
22
- * variable a module declares renamed (`environment-variable-renamed`); the
23
- * two at one value are accepted. Boot's own check cannot hold these: an
24
- * adapter selection decides which modules are chosen before boot, and a
25
- * federation's key sits in core's section, whose renames core alone declares.
20
+ * template ships. Phase one refuses an old name set, alone or beside its new
21
+ * name at any value, before any module is chosen, as boot refuses a
22
+ * variable a module declares renamed (`environment-variable-renamed`).
23
+ * Boot's own check cannot hold these: an adapter selection decides which
24
+ * modules are chosen before boot, and a federation's key sits in core's
25
+ * section, whose renames core alone declares.
26
26
  */
27
27
 
28
28
  import { variablesRenamed } from "./bootRefusal.mjs";
@@ -41,9 +41,9 @@ export interface RootRename {
41
41
 
42
42
  /**
43
43
  * Refuses, with an `environment-variable-renamed` `BootError`, an old name in
44
- * `env` set alone (`unset`), or beside its new name at a different value
45
- * (`different`); the two at one value are accepted. Every such rename is
46
- * named at once; no value is quoted or carried.
44
+ * `env` set alone (`unset`), or beside its new name (`different`, whatever
45
+ * either holds), in the words core refuses a module's rename in. Every such
46
+ * rename is named at once; no value is compared, quoted or carried.
47
47
  */
48
48
  export function refuseRenamedVariables(
49
49
  env: Readonly<Record<string, string>>,
@@ -51,18 +51,17 @@ export function refuseRenamedVariables(
51
51
  ): void {
52
52
  const refused = renames.flatMap((rename) => {
53
53
  const { from, to } = rename;
54
- const old = env[from];
55
- if (old === undefined || env[to] === old) return [];
54
+ if (env[from] === undefined) return [];
56
55
  return [
57
56
  { ...rename, state: env[to] === undefined ? ("unset" as const) : ("different" as const) },
58
57
  ];
59
58
  });
60
59
  if (refused.length === 0) return;
61
60
  const named = refused.map(({ from, to, path, state }) => {
62
- const renamed = `${from} was renamed ${to}, the variable ${path} is bound to; see CHANGELOG.`;
61
+ const renamed = `${from} was renamed ${to}, the variable ${path} is bound to; see the upgrade guide (docs/upgrading-from-v0.16.0.md).`;
63
62
  return state === "unset"
64
63
  ? `${renamed} Set ${to} instead and unset ${from}.`
65
- : `${renamed} ${to} is set to a different value: keep the one you mean in ${to} and unset ${from}.`;
64
+ : `${renamed} ${to} is set as well: keep the value you mean in ${to} and unset ${from}.`;
66
65
  });
67
66
  throw variablesRenamed(
68
67
  `The environment sets ${refused.length} variable(s) that were renamed: ${named.join(" ")}`,
@@ -29,6 +29,7 @@
29
29
  import {
30
30
  checkSerializedOrigin,
31
31
  checkTrustedProxyEntry,
32
+ coerceBooleanFromEnv,
32
33
  describeSerializedOriginRejection,
33
34
  describeTrustedProxyEntryRejection,
34
35
  isWellFormedKid,
@@ -239,9 +240,18 @@ export const keyStoreSectionSchema = z
239
240
  })
240
241
  .strict();
241
242
 
242
- /** `redis-clients`: the one Redis connection every Redis-backed store shares. */
243
+ /**
244
+ * `redis-clients`: the one Redis connection every Redis-backed store shares.
245
+ * `assumeNoEviction` is the operator's assertion that the server runs
246
+ * `maxmemory-policy noeviction`, read by the Redis stores' eviction gate only
247
+ * where the server will not report its policy.
248
+ */
243
249
  export const redisClientsSectionSchema = z
244
- .object({ url: z.string(), password: z.string().optional() })
250
+ .object({
251
+ url: z.string(),
252
+ password: z.string().optional(),
253
+ assumeNoEviction: coerceBooleanFromEnv.default(false),
254
+ })
245
255
  .strict();
246
256
 
247
257
  /**