@cldmv/gh-broker 0.8.0 → 0.9.0

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 (92) hide show
  1. package/README.md +40 -10
  2. package/config.example.json +17 -1
  3. package/deploy/README.md +3 -2
  4. package/deploy/install.mjs +5 -3
  5. package/dist/index.mjs +4261 -1642
  6. package/dist/index.mjs.map +4 -4
  7. package/package.json +12 -2
  8. package/docs/TOOLS.md +0 -538
  9. package/docs/agent-user-model.md +0 -89
  10. package/docs/changelog/v0/v0.2.0.md +0 -26
  11. package/docs/changelog/v0/v0.2.1.md +0 -23
  12. package/docs/changelog/v0/v0.2.2.md +0 -23
  13. package/docs/changelog/v0/v0.2.3.md +0 -33
  14. package/docs/changelog/v0/v0.2.4.md +0 -25
  15. package/docs/changelog/v0/v0.2.5.md +0 -29
  16. package/docs/changelog/v0/v0.3.0.md +0 -84
  17. package/docs/changelog/v0/v0.3.1.md +0 -30
  18. package/docs/changelog/v0/v0.3.2.md +0 -24
  19. package/docs/changelog/v0/v0.3.3.md +0 -28
  20. package/docs/changelog/v0/v0.4.0.md +0 -58
  21. package/docs/changelog/v0/v0.4.1.md +0 -33
  22. package/docs/changelog/v0/v0.4.2.md +0 -45
  23. package/docs/changelog/v0/v0.5.0.md +0 -39
  24. package/docs/changelog/v0/v0.5.1.md +0 -28
  25. package/docs/changelog/v0/v0.6.0.md +0 -52
  26. package/docs/changelog/v0/v0.6.1.md +0 -48
  27. package/docs/changelog/v0/v0.7.0.md +0 -71
  28. package/docs/changelog/v0/v0.8.0.md +0 -115
  29. package/docs/deploy-architecture.md +0 -84
  30. package/src/appauth.ts +0 -56
  31. package/src/audit.ts +0 -34
  32. package/src/availability.ts +0 -42
  33. package/src/catalog.ts +0 -182
  34. package/src/config.ts +0 -157
  35. package/src/index.ts +0 -107
  36. package/src/paths.ts +0 -60
  37. package/src/router.ts +0 -299
  38. package/src/secrets.ts +0 -167
  39. package/src/server.ts +0 -91
  40. package/src/types.ts +0 -128
  41. package/src/upstream.ts +0 -85
  42. package/src/verbs/ghcr/archive.ts +0 -314
  43. package/src/verbs/ghcr/config.ts +0 -142
  44. package/src/verbs/ghcr/jobs.ts +0 -141
  45. package/src/verbs/ghcr/push.ts +0 -192
  46. package/src/verbs/ghcr/registry.ts +0 -275
  47. package/src/verbs/ghcr/tar.ts +0 -203
  48. package/src/verbs/ghcr_push.ts +0 -264
  49. package/src/verbs/issue_labels.ts +0 -329
  50. package/src/verbs/link_issue.ts +0 -84
  51. package/src/verbs/npm-registry.ts +0 -168
  52. package/src/verbs/npm-tarball.ts +0 -76
  53. package/src/verbs/npm_scaffold.ts +0 -325
  54. package/src/verbs/release-analysis.ts +0 -212
  55. package/src/verbs/repo-tier.ts +0 -90
  56. package/src/verbs/report_ci_status.ts +0 -358
  57. package/src/verbs/report_release_prs.ts +0 -238
  58. package/src/verbs/report_security_alerts.ts +0 -442
  59. package/src/verbs/report_unlabeled.ts +0 -194
  60. package/src/verbs/rest-client.ts +0 -181
  61. package/src/verbs/review_thread.ts +0 -138
  62. package/tests/appauth.test.vitest.mts +0 -76
  63. package/tests/audit.test.vitest.mts +0 -48
  64. package/tests/catalog.test.vitest.mts +0 -234
  65. package/tests/config-merge.test.vitest.mts +0 -325
  66. package/tests/config.test.vitest.mts +0 -388
  67. package/tests/credentials.test.vitest.mts +0 -127
  68. package/tests/ghcr-archive.test.vitest.mts +0 -439
  69. package/tests/ghcr-config.test.vitest.mts +0 -96
  70. package/tests/ghcr-push.test.vitest.mts +0 -526
  71. package/tests/ghcr-registry.test.vitest.mts +0 -206
  72. package/tests/helpers/ghcr-fixtures.mts +0 -373
  73. package/tests/install-args.test.vitest.mts +0 -152
  74. package/tests/issue-labels.test.vitest.mts +0 -362
  75. package/tests/npm-scaffold.test.vitest.mts +0 -479
  76. package/tests/npm-setup.test.vitest.mts +0 -196
  77. package/tests/paths.test.vitest.mts +0 -57
  78. package/tests/prompt.test.vitest.mts +0 -128
  79. package/tests/release-prs.test.vitest.mts +0 -247
  80. package/tests/rest-client.test.vitest.mts +0 -115
  81. package/tests/router.test.vitest.mts +0 -505
  82. package/tests/run-vitest.mjs +0 -51
  83. package/tests/secret-store.test.vitest.mts +0 -167
  84. package/tests/secrets.test.vitest.mts +0 -208
  85. package/tests/security-alerts.test.vitest.mts +0 -338
  86. package/tests/server.test.vitest.mts +0 -200
  87. package/tests/smoke.test.vitest.mts +0 -177
  88. package/tests/system.test.vitest.mts +0 -355
  89. package/tests/upstream.test.vitest.mts +0 -91
  90. package/tests/verb-helpers.test.vitest.mts +0 -377
  91. package/tests/verbs.test.vitest.mts +0 -624
  92. package/tsconfig.json +0 -21
@@ -1,51 +0,0 @@
1
- /**
2
- *
3
- * @Project: @cldmv/gh-broker
4
- * @Filename: /tests/run-vitest.mjs
5
- * @Date: 2026-08-02T16:55:52-07:00 (1785714952)
6
- * @Author: Nate Corcoran <CLDMV>
7
- * @Email: <Shinrai@users.noreply.github.com>
8
- * -----
9
- * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
10
- * @Last modified time: 2026-10-02T11:30:58-07:00 (1790965858)
11
- * -----
12
- * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
- *
14
- */
15
-
16
- /**
17
- * @fileoverview OOM-safe Vitest runner — delegates to @cldmv/vitest-runner, which
18
- * spawns each test file in its own child process and (under coverage) uses a
19
- * blob-per-file + `--mergeReports` strategy so a single process never holds
20
- * coverage for the whole suite.
21
- */
22
- import path from "node:path";
23
- import { fileURLToPath } from "node:url";
24
- import { run } from "@cldmv/vitest-runner";
25
-
26
- const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
27
- const argv = process.argv.slice(2);
28
-
29
- const delimiter = argv.indexOf("--");
30
- const forwarded = delimiter === -1 ? argv.filter((a) => a.startsWith("-")) : argv.slice(0, delimiter);
31
- const testPatterns = delimiter === -1 ? argv.filter((a) => !a.startsWith("-")) : argv.slice(delimiter + 1);
32
-
33
- const coverageQuiet = forwarded.includes("--coverage-quiet");
34
- const coverage = coverageQuiet || forwarded.includes("--coverage");
35
- const passthrough = forwarded.filter((a) => a !== "--coverage" && a !== "--coverage-quiet");
36
-
37
- const parsedWorkers = parseInt(process.env.VITEST_WORKERS ?? "", 10);
38
- const workers = Number.isInteger(parsedWorkers) && parsedWorkers > 0 ? parsedWorkers : 4;
39
-
40
- const code = await run({
41
- cwd: root,
42
- testDir: "tests",
43
- vitestConfig: ".configs/vitest.config.mjs",
44
- testFilePattern: /\.test\.vitest\.mts$/,
45
- testPatterns,
46
- workers,
47
- coverageQuiet,
48
- vitestArgs: [...(coverage ? ["--coverage"] : []), ...passthrough],
49
- nodeEnv: process.env.NODE_ENV || "development"
50
- });
51
- process.exit(code);
@@ -1,167 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
2
-
3
- vi.mock("node:fs", () => ({
4
- existsSync: vi.fn(() => true),
5
- readFileSync: vi.fn(),
6
- writeFileSync: vi.fn(),
7
- statSync: vi.fn(() => ({ size: 8 })),
8
- rmSync: vi.fn(),
9
- chmodSync: vi.fn(),
10
- chownSync: vi.fn()
11
- }));
12
-
13
- import { readFileSync, existsSync, writeFileSync, rmSync, chmodSync, chownSync } from "node:fs";
14
- import { writeSecretsFile, readSecretsFile, removeSecretsFile, referencedCredNames, optionalCredNames, detectSecretsProvider } from "../deploy/lib/secret-store.mjs";
15
-
16
- const mockRead = vi.mocked(readFileSync);
17
- const mockExists = vi.mocked(existsSync);
18
- const mockWrite = vi.mocked(writeFileSync);
19
- const mockRm = vi.mocked(rmSync);
20
-
21
- describe("writeSecretsFile", () => {
22
- beforeEach(() => {
23
- vi.mocked(writeFileSync).mockReset();
24
- vi.mocked(chmodSync).mockReset();
25
- vi.mocked(chownSync).mockReset();
26
- });
27
-
28
- it("writes tab-indented JSON at 0640 and chowns to root:<gid> when a gid is given", () => {
29
- writeSecretsFile("/etc/s.json", { a: "tok" }, { gid: 42 });
30
- expect(writeFileSync).toHaveBeenCalledWith("/etc/s.json", '{\n\t"a": "tok"\n}\n', { mode: 0o640 });
31
- expect(chmodSync).toHaveBeenCalledWith("/etc/s.json", 0o640);
32
- expect(chownSync).toHaveBeenCalledWith("/etc/s.json", 0, 42);
33
- });
34
-
35
- it("honours a custom uid/mode, and skips chown without a gid (including no options at all)", () => {
36
- writeSecretsFile("/etc/s.json", {}, { uid: 7, gid: 9, mode: 0o600 });
37
- expect(chmodSync).toHaveBeenCalledWith("/etc/s.json", 0o600);
38
- expect(chownSync).toHaveBeenCalledWith("/etc/s.json", 7, 9);
39
- vi.mocked(chownSync).mockClear();
40
- writeSecretsFile("/etc/s.json", {});
41
- expect(chownSync).not.toHaveBeenCalled();
42
- });
43
- });
44
-
45
- describe("readSecretsFile", () => {
46
- beforeEach(() => mockRead.mockReset());
47
-
48
- it("keeps only non-empty string values (drops numbers/objects/empties)", () => {
49
- mockRead.mockReturnValue(JSON.stringify({ a: "tok", b: 123, c: "", d: { x: 1 }, e: "ok" }) as any);
50
- expect(readSecretsFile("/x")).toEqual({ a: "tok", e: "ok" });
51
- });
52
-
53
- it("returns {} when the file is absent", () => {
54
- mockExists.mockReturnValueOnce(false);
55
- expect(readSecretsFile("/x")).toEqual({});
56
- expect(mockRead).not.toHaveBeenCalled();
57
- });
58
-
59
- it("returns {} for a non-object or unparseable file", () => {
60
- mockRead.mockReturnValue("[1,2,3]" as any);
61
- expect(readSecretsFile("/x")).toEqual({});
62
- mockRead.mockReturnValue("not json" as any);
63
- expect(readSecretsFile("/x")).toEqual({});
64
- });
65
- });
66
-
67
- describe("removeSecretsFile", () => {
68
- beforeEach(() => {
69
- mockExists.mockReset().mockReturnValue(true);
70
- mockWrite.mockReset();
71
- mockRm.mockReset();
72
- });
73
-
74
- it("zero-overwrites then unlinks an existing file", () => {
75
- removeSecretsFile("/x/secrets.json");
76
- expect(mockWrite).toHaveBeenCalledTimes(1);
77
- const buf = mockWrite.mock.calls[0][1] as Buffer;
78
- expect(Buffer.isBuffer(buf)).toBe(true);
79
- expect(buf.every((b) => b === 0)).toBe(true);
80
- expect(mockRm).toHaveBeenCalledWith("/x/secrets.json", { force: true });
81
- });
82
-
83
- it("is a no-op when the file is absent", () => {
84
- mockExists.mockReturnValue(false);
85
- removeSecretsFile("/x/secrets.json");
86
- expect(mockWrite).not.toHaveBeenCalled();
87
- expect(mockRm).not.toHaveBeenCalled();
88
- });
89
- });
90
-
91
- describe("optionalCredNames", () => {
92
- it("lists the npm token and nothing required", () => {
93
- const cfg = { owners: { o: { read: "cred:a", write: "cred:b" } }, app: { privateKey: "cred:k" }, npm: { token: "cred:npm-token", scopes: ["@cldmv"] } };
94
- expect(optionalCredNames(cfg)).toEqual(["npm-token"]);
95
- expect(optionalCredNames({ owners: cfg.owners })).toEqual([]);
96
- expect(optionalCredNames({ npm: { token: "npm_inline" } })).toEqual([]);
97
- expect(optionalCredNames({ ...cfg, ghcr: { owners: { cldmv: { credential: "cred:cldmv-packages" }, x: { credential: "inline" } } } })).toEqual(["npm-token", "cldmv-packages"]);
98
- });
99
-
100
- it("rejects an unsafe cred name", () => {
101
- expect(() => optionalCredNames({ npm: { token: "cred:../x" } })).toThrow(/unsafe credential name/);
102
- });
103
- });
104
-
105
- describe("referencedCredNames", () => {
106
- it("collects distinct cred names from owners + app, ignoring non-cred refs", () => {
107
- const cfg = {
108
- owners: { o1: { read: "cred:a", write: "cred:b" }, o2: { read: "cred:a", write: "cred:c" } },
109
- app: { privateKey: "cred:k" }
110
- };
111
- expect(referencedCredNames(cfg).sort()).toEqual(["a", "b", "c", "k"]);
112
- expect(referencedCredNames({ owners: { o: { read: "plain-token", write: 5 } } })).toEqual([]);
113
- });
114
-
115
- it("includes the npm token ref", () => {
116
- const cfg = { owners: { o: { read: "cred:a", write: "cred:b" } }, npm: { token: "cred:npm-token", scopes: ["@cldmv"] } };
117
- expect(referencedCredNames(cfg).sort()).toEqual(["a", "b", "npm-token"]);
118
- });
119
-
120
- it("includes each ghcr owner's credential, so the installer prompts for it", () => {
121
- const cfg = {
122
- owners: { cldmv: { read: "cred:cldmv-read", write: "cred:cldmv-write" } },
123
- ghcr: { owners: { cldmv: { credential: "cred:cldmv-packages" } } }
124
- };
125
- expect(referencedCredNames(cfg).sort()).toEqual(["cldmv-packages", "cldmv-read", "cldmv-write"]);
126
- expect(() => referencedCredNames({ ghcr: { owners: { o: { credential: "cred:../x" } } } })).toThrow(/unsafe credential name/);
127
- });
128
-
129
- it("returns [] with no owners", () => {
130
- expect(referencedCredNames({})).toEqual([]);
131
- });
132
-
133
- it("rejects an unsafe cred name (path separator / traversal)", () => {
134
- expect(() => referencedCredNames({ owners: { o: { read: "cred:../x", write: "cred:ok" } } })).toThrow(/unsafe credential name/);
135
- expect(() => referencedCredNames({ owners: { o: { read: "cred:a/b", write: "cred:ok" } } })).toThrow(/unsafe credential name/);
136
- });
137
- });
138
-
139
- describe("detectSecretsProvider", () => {
140
- it("trusts the provider recorded in the deployed config, whatever is on disk", () => {
141
- expect(detectSecretsProvider({ configured: "file", credNames: ["a"], fileTokenNames: [] })).toBe("file");
142
- expect(detectSecretsProvider({ configured: "systemd-creds", credNames: [], fileTokenNames: ["a"] })).toBe("systemd-creds");
143
- });
144
-
145
- it("ignores an unrecognised configured value and falls back to what is on disk", () => {
146
- expect(detectSecretsProvider({ configured: "vault", credNames: ["a"] })).toBe("systemd-creds");
147
- expect(detectSecretsProvider({ configured: 42, fileTokenNames: ["a"] })).toBe("file");
148
- });
149
-
150
- it("infers systemd-creds from encrypted .cred files when nothing is recorded", () => {
151
- expect(detectSecretsProvider({ credNames: ["cldmv-read", "cldmv-write"] })).toBe("systemd-creds");
152
- });
153
-
154
- it("infers file from tokens in the secrets file when nothing is recorded", () => {
155
- expect(detectSecretsProvider({ fileTokenNames: ["cldmv-read"] })).toBe("file");
156
- });
157
-
158
- it("returns undefined on a first install, so the installer asks", () => {
159
- expect(detectSecretsProvider({})).toBeUndefined();
160
- expect(detectSecretsProvider()).toBeUndefined();
161
- expect(detectSecretsProvider({ credNames: [], fileTokenNames: [] })).toBeUndefined();
162
- });
163
-
164
- it("returns undefined when both backends hold credentials and nothing is recorded (ambiguous)", () => {
165
- expect(detectSecretsProvider({ credNames: ["a"], fileTokenNames: ["a"] })).toBeUndefined();
166
- });
167
- });
@@ -1,208 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
-
3
- // secrets.ts reads the JSON secrets file via node:fs.readFileSync.
4
- vi.mock("node:fs", () => ({ readFileSync: vi.fn() }));
5
-
6
- import { readFileSync } from "node:fs";
7
- import { loadSecretStore } from "../src/secrets.ts";
8
-
9
- const mockRead = vi.mocked(readFileSync);
10
- const SECRETS = "/x/secrets.json";
11
-
12
- function config(owners: Record<string, unknown> = { cldmv: { read: "cred:cldmv-read", write: "cred:cldmv-write" } }, extra: Record<string, unknown> = {}): any {
13
- return { listen: "unix:/x", upstream: "https://x", tools: [], owners, secrets: { provider: "file", path: SECRETS }, ...extra };
14
- }
15
-
16
- /**
17
- * Serve the secrets file body (object → JSON, or a raw string). fileStore reads
18
- * exactly one file, so this returns the body unconditionally.
19
- */
20
- function secretsFile(obj: Record<string, unknown> | string) {
21
- const body = typeof obj === "string" ? obj : JSON.stringify(obj);
22
- mockRead.mockReset();
23
- mockRead.mockReturnValue(body as any);
24
- }
25
-
26
- describe("loadSecretStore (file provider)", () => {
27
- beforeEach(() => mockRead.mockReset());
28
-
29
- it("resolves cred refs from the secrets file", () => {
30
- secretsFile({ "cldmv-read": "readtok", "cldmv-write": "writetok" });
31
- const store = loadSecretStore(config());
32
- expect(store.resolve("cred:cldmv-read")).toBe("readtok");
33
- expect(store.resolve("cred:cldmv-write")).toBe("writetok");
34
- });
35
-
36
- it("reads the secrets file once, not per credential", () => {
37
- secretsFile({ "cldmv-read": "r", "cldmv-write": "w" });
38
- const store = loadSecretStore(config());
39
- expect(mockRead).toHaveBeenCalledTimes(1);
40
- store.resolve("cred:cldmv-read");
41
- store.resolve("cred:cldmv-read");
42
- expect(mockRead).toHaveBeenCalledTimes(1);
43
- });
44
-
45
- it("throws when the secrets file is unreadable", () => {
46
- mockRead.mockReset();
47
- // Only the fileStore read throws; later reads (e.g. vitest error formatting)
48
- // must not, or they'd mask the wrapped message under test.
49
- mockRead.mockImplementationOnce(() => {
50
- throw new Error("ENOENT");
51
- });
52
- expect(() => loadSecretStore(config())).toThrow(/cannot read\/parse secrets file/);
53
- });
54
-
55
- it("throws when the file is not a JSON object", () => {
56
- secretsFile("[1,2,3]");
57
- expect(() => loadSecretStore(config())).toThrow(/must be a JSON object/);
58
- });
59
-
60
- it("throws (eager) when a referenced credential is missing in the file", () => {
61
- secretsFile({ "cldmv-write": "w" });
62
- expect(() => loadSecretStore(config())).toThrow(/credential "cldmv-read" missing or empty/);
63
- });
64
-
65
- it("throws when a referenced credential is empty", () => {
66
- secretsFile({ "cldmv-read": "", "cldmv-write": "w" });
67
- expect(() => loadSecretStore(config())).toThrow(/credential "cldmv-read" missing or empty/);
68
- });
69
-
70
- it("strips a single trailing newline (matches the systemd-creds provider)", () => {
71
- secretsFile({ "cldmv-read": "readtok\n", "cldmv-write": "writetok\r\n" });
72
- const store = loadSecretStore(config());
73
- expect(store.resolve("cred:cldmv-read")).toBe("readtok");
74
- expect(store.resolve("cred:cldmv-write")).toBe("writetok");
75
- });
76
-
77
- it("rejects a credential that is only a newline (empty after normalization)", () => {
78
- secretsFile({ "cldmv-read": "\n", "cldmv-write": "w" });
79
- expect(() => loadSecretStore(config())).toThrow(/credential "cldmv-read" missing or empty/);
80
- });
81
-
82
- it("rejects an owner ref that is not cred:<name>", () => {
83
- secretsFile({ "cldmv-read": "r", "cldmv-write": "w" });
84
- expect(() => loadSecretStore(config({ cldmv: { read: "plain-ref", write: "cred:cldmv-write" } }))).toThrow(/must be "cred:<name>"/);
85
- });
86
-
87
- it("rejects a malformed ref at resolve time", () => {
88
- secretsFile({ "cldmv-read": "r", "cldmv-write": "w" });
89
- const store = loadSecretStore(config());
90
- expect(() => store.resolve("not-a-cred-ref")).toThrow(/bad credential ref/);
91
- });
92
-
93
- it("rejects an owner cred name with a path separator (traversal guard)", () => {
94
- secretsFile({ "cldmv-read": "r", "cldmv-write": "w" });
95
- expect(() => loadSecretStore(config({ cldmv: { read: "cred:../../etc/shadow", write: "cred:cldmv-write" } }))).toThrow(/unsafe credential name/);
96
- });
97
-
98
- it("rejects an unsafe cred name at resolve time", () => {
99
- secretsFile({ "cldmv-read": "r", "cldmv-write": "w" });
100
- const store = loadSecretStore(config());
101
- expect(() => store.resolve("cred:..")).toThrow(/unsafe credential name/);
102
- expect(() => store.resolve("cred:a/b")).toThrow(/unsafe credential name/);
103
- });
104
-
105
- it("rejects an unknown provider", () => {
106
- secretsFile({ "cldmv-read": "r", "cldmv-write": "w" });
107
- expect(() => loadSecretStore(config(undefined, { secrets: { provider: "vault", path: SECRETS } }))).toThrow(/unknown provider/);
108
- });
109
-
110
- it("treats the ghcr credentials as optional: startup does not need them, has() reports them", () => {
111
- const ghcr = { dropDir: "/srv/x/y", protectReleaseTags: true, maxArchiveBytes: 1, owners: { cldmv: { username: "u", credential: "cred:cldmv-packages", images: ["hoot"] } } };
112
- secretsFile({ "cldmv-read": "r", "cldmv-write": "w" });
113
- expect(loadSecretStore(config(undefined, { ghcr })).has("cred:cldmv-packages")).toBe(false);
114
- secretsFile({ "cldmv-read": "r", "cldmv-write": "w", "cldmv-packages": "p" });
115
- const store = loadSecretStore(config(undefined, { ghcr }));
116
- expect(store.has("cred:cldmv-packages")).toBe(true);
117
- expect(store.resolve("cred:cldmv-packages")).toBe("p");
118
- const bad = { ...ghcr, owners: { cldmv: { ...ghcr.owners.cldmv, credential: "cred:../x" } } };
119
- expect(() => loadSecretStore(config(undefined, { ghcr: bad }))).toThrow(/unsafe credential name/);
120
- });
121
-
122
- it("rejects a non-cred config.app.privateKey (would inline a secret in the config)", () => {
123
- secretsFile({ "cldmv-read": "r", "cldmv-write": "w" });
124
- expect(() => loadSecretStore(config(undefined, { app: { appId: 1, privateKey: "-----BEGIN PRIVATE KEY-----" } }))).toThrow(
125
- /config.app.privateKey must be a "cred:<name>" ref/
126
- );
127
- });
128
-
129
- it("requires a valid config.app.privateKey cred to be stored", () => {
130
- secretsFile({ "cldmv-read": "r", "cldmv-write": "w", "app-key": "pem" });
131
- const store = loadSecretStore(config(undefined, { app: { appId: 1, privateKey: "cred:app-key" } }));
132
- expect(store.resolve("cred:app-key")).toBe("pem");
133
- secretsFile({ "cldmv-read": "r", "cldmv-write": "w" });
134
- expect(() => loadSecretStore(config(undefined, { app: { appId: 1, privateKey: "cred:app-key" } }))).toThrow(/app-key/);
135
- });
136
-
137
- it("treats the npm token as optional: has() reports it, startup does not require it", () => {
138
- const npm = { token: "cred:npm-token", scopes: ["@cldmv"] };
139
- secretsFile({ "cldmv-read": "r", "cldmv-write": "w", "npm-token": "npm_x" });
140
- const store = loadSecretStore(config(undefined, { npm }));
141
- expect(store.has("cred:npm-token")).toBe(true);
142
- expect(store.resolve("cred:npm-token")).toBe("npm_x");
143
- secretsFile({ "cldmv-read": "r", "cldmv-write": "w" });
144
- const without = loadSecretStore(config(undefined, { npm }));
145
- expect(without.has("cred:npm-token")).toBe(false);
146
- expect(without.has("cred:cldmv-read")).toBe(true);
147
- expect(() => loadSecretStore(config(undefined, { npm: { ...npm, token: "npm_inline" } }))).toThrow(/config.npm.token must be a "cred:<name>" ref/);
148
- expect(() => loadSecretStore(config(undefined, { npm: { ...npm, token: "cred:../x" } }))).toThrow(/unsafe credential name/);
149
- });
150
- });
151
-
152
- describe("loadSecretStore (systemd-creds provider, the default)", () => {
153
- const CREDS = "/run/creds";
154
- const original = process.env.CREDENTIALS_DIRECTORY;
155
- function scConfig(): any {
156
- return {
157
- listen: "unix:/x",
158
- upstream: "https://x",
159
- tools: [],
160
- owners: { cldmv: { read: "cred:cldmv-read", write: "cred:cldmv-write" } },
161
- secrets: { provider: "systemd-creds" }
162
- };
163
- }
164
-
165
- beforeEach(() => {
166
- mockRead.mockReset();
167
- process.env.CREDENTIALS_DIRECTORY = CREDS;
168
- });
169
- afterEach(() => {
170
- if (original === undefined) delete process.env.CREDENTIALS_DIRECTORY;
171
- else process.env.CREDENTIALS_DIRECTORY = original;
172
- });
173
-
174
- it("throws when $CREDENTIALS_DIRECTORY is unset", () => {
175
- delete process.env.CREDENTIALS_DIRECTORY;
176
- expect(() => loadSecretStore(scConfig())).toThrow(/\$CREDENTIALS_DIRECTORY unset/);
177
- });
178
-
179
- it("resolves creds from $CREDENTIALS_DIRECTORY, stripping a trailing newline", () => {
180
- mockRead.mockImplementation((p: any) => {
181
- if (String(p).endsWith("cldmv-read")) return "rtok\n" as any;
182
- if (String(p).endsWith("cldmv-write")) return "wtok" as any;
183
- throw new Error(`ENOENT: ${p}`);
184
- });
185
- const store = loadSecretStore(scConfig());
186
- expect(store.resolve("cred:cldmv-read")).toBe("rtok");
187
- expect(store.resolve("cred:cldmv-write")).toBe("wtok");
188
- });
189
-
190
- it("defaults to systemd-creds when config.secrets is absent", () => {
191
- mockRead.mockReturnValue("tok" as any);
192
- const cfg = scConfig();
193
- delete cfg.secrets;
194
- expect(loadSecretStore(cfg).resolve("cred:cldmv-read")).toBe("tok");
195
- });
196
-
197
- it("fails startup when a required credential cannot be read", () => {
198
- mockRead.mockImplementation(() => {
199
- throw new Error("ENOENT");
200
- });
201
- expect(() => loadSecretStore(scConfig())).toThrow(/credential "cldmv-read" not available: ENOENT/);
202
- });
203
-
204
- it("fails startup when a required credential is empty", () => {
205
- mockRead.mockReturnValue("\n" as any);
206
- expect(() => loadSecretStore(scConfig())).toThrow(/credential "cldmv-read" is empty/);
207
- });
208
- });