@cldmv/gh-broker 0.8.0 → 0.10.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 (106) hide show
  1. package/README.md +48 -13
  2. package/config.example.json +86 -3
  3. package/config.sample.json +1 -1
  4. package/deploy/README.md +58 -18
  5. package/deploy/cli.mjs +38 -0
  6. package/deploy/gh-broker.service +9 -2
  7. package/deploy/install.mjs +100 -41
  8. package/deploy/lib/agent-cli.mjs +166 -0
  9. package/deploy/lib/agents.mjs +128 -0
  10. package/deploy/lib/cli.mjs +72 -0
  11. package/deploy/lib/config-merge.mjs +29 -8
  12. package/deploy/lib/install-args.mjs +3 -2
  13. package/deploy/lib/mcp-config.mjs +211 -0
  14. package/deploy/lib/otp-cli.mjs +136 -0
  15. package/deploy/lib/otp.mjs +325 -0
  16. package/deploy/lib/secret-store.mjs +1 -0
  17. package/deploy/lib/system.mjs +24 -12
  18. package/deploy/real-deps.mjs +102 -0
  19. package/dist/index.mjs +5171 -1686
  20. package/dist/index.mjs.map +4 -4
  21. package/package.json +14 -3
  22. package/docs/TOOLS.md +0 -538
  23. package/docs/agent-user-model.md +0 -89
  24. package/docs/changelog/v0/v0.2.0.md +0 -26
  25. package/docs/changelog/v0/v0.2.1.md +0 -23
  26. package/docs/changelog/v0/v0.2.2.md +0 -23
  27. package/docs/changelog/v0/v0.2.3.md +0 -33
  28. package/docs/changelog/v0/v0.2.4.md +0 -25
  29. package/docs/changelog/v0/v0.2.5.md +0 -29
  30. package/docs/changelog/v0/v0.3.0.md +0 -84
  31. package/docs/changelog/v0/v0.3.1.md +0 -30
  32. package/docs/changelog/v0/v0.3.2.md +0 -24
  33. package/docs/changelog/v0/v0.3.3.md +0 -28
  34. package/docs/changelog/v0/v0.4.0.md +0 -58
  35. package/docs/changelog/v0/v0.4.1.md +0 -33
  36. package/docs/changelog/v0/v0.4.2.md +0 -45
  37. package/docs/changelog/v0/v0.5.0.md +0 -39
  38. package/docs/changelog/v0/v0.5.1.md +0 -28
  39. package/docs/changelog/v0/v0.6.0.md +0 -52
  40. package/docs/changelog/v0/v0.6.1.md +0 -48
  41. package/docs/changelog/v0/v0.7.0.md +0 -71
  42. package/docs/changelog/v0/v0.8.0.md +0 -115
  43. package/docs/deploy-architecture.md +0 -84
  44. package/src/appauth.ts +0 -56
  45. package/src/audit.ts +0 -34
  46. package/src/availability.ts +0 -42
  47. package/src/catalog.ts +0 -182
  48. package/src/config.ts +0 -157
  49. package/src/index.ts +0 -107
  50. package/src/paths.ts +0 -60
  51. package/src/router.ts +0 -299
  52. package/src/secrets.ts +0 -167
  53. package/src/server.ts +0 -91
  54. package/src/types.ts +0 -128
  55. package/src/upstream.ts +0 -85
  56. package/src/verbs/ghcr/archive.ts +0 -314
  57. package/src/verbs/ghcr/config.ts +0 -142
  58. package/src/verbs/ghcr/jobs.ts +0 -141
  59. package/src/verbs/ghcr/push.ts +0 -192
  60. package/src/verbs/ghcr/registry.ts +0 -275
  61. package/src/verbs/ghcr/tar.ts +0 -203
  62. package/src/verbs/ghcr_push.ts +0 -264
  63. package/src/verbs/issue_labels.ts +0 -329
  64. package/src/verbs/link_issue.ts +0 -84
  65. package/src/verbs/npm-registry.ts +0 -168
  66. package/src/verbs/npm-tarball.ts +0 -76
  67. package/src/verbs/npm_scaffold.ts +0 -325
  68. package/src/verbs/release-analysis.ts +0 -212
  69. package/src/verbs/repo-tier.ts +0 -90
  70. package/src/verbs/report_ci_status.ts +0 -358
  71. package/src/verbs/report_release_prs.ts +0 -238
  72. package/src/verbs/report_security_alerts.ts +0 -442
  73. package/src/verbs/report_unlabeled.ts +0 -194
  74. package/src/verbs/rest-client.ts +0 -181
  75. package/src/verbs/review_thread.ts +0 -138
  76. package/tests/appauth.test.vitest.mts +0 -76
  77. package/tests/audit.test.vitest.mts +0 -48
  78. package/tests/catalog.test.vitest.mts +0 -234
  79. package/tests/config-merge.test.vitest.mts +0 -325
  80. package/tests/config.test.vitest.mts +0 -388
  81. package/tests/credentials.test.vitest.mts +0 -127
  82. package/tests/ghcr-archive.test.vitest.mts +0 -439
  83. package/tests/ghcr-config.test.vitest.mts +0 -96
  84. package/tests/ghcr-push.test.vitest.mts +0 -526
  85. package/tests/ghcr-registry.test.vitest.mts +0 -206
  86. package/tests/helpers/ghcr-fixtures.mts +0 -373
  87. package/tests/install-args.test.vitest.mts +0 -152
  88. package/tests/issue-labels.test.vitest.mts +0 -362
  89. package/tests/npm-scaffold.test.vitest.mts +0 -479
  90. package/tests/npm-setup.test.vitest.mts +0 -196
  91. package/tests/paths.test.vitest.mts +0 -57
  92. package/tests/prompt.test.vitest.mts +0 -128
  93. package/tests/release-prs.test.vitest.mts +0 -247
  94. package/tests/rest-client.test.vitest.mts +0 -115
  95. package/tests/router.test.vitest.mts +0 -505
  96. package/tests/run-vitest.mjs +0 -51
  97. package/tests/secret-store.test.vitest.mts +0 -167
  98. package/tests/secrets.test.vitest.mts +0 -208
  99. package/tests/security-alerts.test.vitest.mts +0 -338
  100. package/tests/server.test.vitest.mts +0 -200
  101. package/tests/smoke.test.vitest.mts +0 -177
  102. package/tests/system.test.vitest.mts +0 -355
  103. package/tests/upstream.test.vitest.mts +0 -91
  104. package/tests/verb-helpers.test.vitest.mts +0 -377
  105. package/tests/verbs.test.vitest.mts +0 -624
  106. 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
- });