@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.
- package/README.ja.md +3 -1
- package/README.md +5 -2
- package/package.json +3 -3
- package/templates/standalone/.env.example +7 -2
- package/templates/standalone/README.ja.md +17 -11
- package/templates/standalone/README.md +47 -21
- package/templates/standalone/config/application.conf +1 -0
- package/templates/standalone/config/clients.yaml.example +8 -0
- package/templates/standalone/config/development.conf +2 -2
- package/templates/standalone/config/mailpit.conf +10 -5
- package/templates/standalone/config/reference.conf +9 -3
- package/templates/standalone/docker-compose.mailpit.yml +3 -0
- package/templates/standalone/package.json +6 -6
- package/templates/standalone/src/__tests__/adapters.test.mts +12 -6
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +13 -5
- package/templates/standalone/src/__tests__/config-notices.test.mts +4 -1
- package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +48 -0
- package/templates/standalone/src/__tests__/core-federations.test.mts +9 -6
- package/templates/standalone/src/__tests__/core-sections.test.mts +7 -5
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +91 -87
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +4 -0
- package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +113 -0
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +81 -14
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +8 -4
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +79 -21
- package/templates/standalone/src/__tests__/own-modules.test.mts +38 -12
- package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +214 -0
- package/templates/standalone/src/__tests__/replica-safety.test.mts +4 -0
- package/templates/standalone/src/__tests__/repositories-logger.test.mts +119 -0
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +10 -3
- package/templates/standalone/src/__tests__/sections.test.mts +24 -0
- package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +4 -0
- package/templates/standalone/src/__tests__/startup-log-lines.test.mts +148 -0
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +8 -4
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +2 -1
- package/templates/standalone/src/adapters.mts +2 -2
- package/templates/standalone/src/mfaSwitch.mts +6 -7
- package/templates/standalone/src/modules.mts +21 -9
- package/templates/standalone/src/rootRenames.mts +12 -13
- package/templates/standalone/src/sections.mts +12 -2
- package/templates/standalone/tests/package-lock.json +95 -95
- package/templates/standalone/tests/package.json +1 -1
- 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
|
|
359
|
-
it("reads the switch as
|
|
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
|
-
|
|
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
|
|
113
|
-
// The template's reference.conf ships the switch required and binds
|
|
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
|
-
|
|
120
|
-
expect(
|
|
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 ?? "
|
|
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
|
|
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
|
|
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
|
|
105
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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. `
|
|
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: {
|
|
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
|
|
21
|
-
* name at
|
|
22
|
-
* variable a module declares renamed (`environment-variable-renamed`)
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
|
45
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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({
|
|
250
|
+
.object({
|
|
251
|
+
url: z.string(),
|
|
252
|
+
password: z.string().optional(),
|
|
253
|
+
assumeNoEviction: coerceBooleanFromEnv.default(false),
|
|
254
|
+
})
|
|
245
255
|
.strict();
|
|
246
256
|
|
|
247
257
|
/**
|