@cldmv/gh-broker 0.7.0 → 0.8.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 (90) hide show
  1. package/README.md +134 -40
  2. package/config.example.json +21 -8
  3. package/config.sample.json +45 -0
  4. package/deploy/README.md +73 -15
  5. package/deploy/install.mjs +166 -101
  6. package/deploy/install.sh +0 -0
  7. package/deploy/lib/config-merge.mjs +174 -16
  8. package/deploy/lib/credentials.mjs +143 -0
  9. package/deploy/lib/install-args.mjs +144 -0
  10. package/deploy/lib/npm-setup.mjs +164 -0
  11. package/deploy/lib/secret-store.mjs +20 -2
  12. package/deploy/lib/system.mjs +26 -2
  13. package/dist/index.mjs +1638 -202
  14. package/dist/index.mjs.map +4 -4
  15. package/docs/TOOLS.md +192 -1
  16. package/docs/changelog/v0/v0.8.0.md +115 -0
  17. package/docs/deploy-architecture.md +13 -11
  18. package/package.json +7 -7
  19. package/src/availability.ts +42 -0
  20. package/src/catalog.ts +18 -1
  21. package/src/config.ts +29 -2
  22. package/src/router.ts +43 -2
  23. package/src/secrets.ts +34 -4
  24. package/src/types.ts +25 -1
  25. package/src/verbs/ghcr/archive.ts +314 -0
  26. package/src/verbs/ghcr/config.ts +142 -0
  27. package/src/verbs/ghcr/jobs.ts +141 -0
  28. package/src/verbs/ghcr/push.ts +192 -0
  29. package/src/verbs/ghcr/registry.ts +275 -0
  30. package/src/verbs/ghcr/tar.ts +203 -0
  31. package/src/verbs/ghcr_push.ts +264 -0
  32. package/src/verbs/npm-registry.ts +168 -0
  33. package/src/verbs/npm-tarball.ts +76 -0
  34. package/src/verbs/npm_scaffold.ts +325 -0
  35. package/src/verbs/release-analysis.ts +3 -1
  36. package/tests/catalog.test.vitest.mts +55 -1
  37. package/tests/config-merge.test.vitest.mts +275 -8
  38. package/tests/config.test.vitest.mts +75 -1
  39. package/tests/credentials.test.vitest.mts +127 -0
  40. package/tests/ghcr-archive.test.vitest.mts +439 -0
  41. package/tests/ghcr-config.test.vitest.mts +96 -0
  42. package/tests/ghcr-push.test.vitest.mts +526 -0
  43. package/tests/ghcr-registry.test.vitest.mts +206 -0
  44. package/tests/helpers/ghcr-fixtures.mts +373 -0
  45. package/tests/install-args.test.vitest.mts +152 -0
  46. package/tests/issue-labels.test.vitest.mts +15 -0
  47. package/tests/npm-scaffold.test.vitest.mts +479 -0
  48. package/tests/npm-setup.test.vitest.mts +196 -0
  49. package/tests/paths.test.vitest.mts +5 -0
  50. package/tests/prompt.test.vitest.mts +128 -0
  51. package/tests/release-prs.test.vitest.mts +10 -0
  52. package/tests/router.test.vitest.mts +108 -2
  53. package/tests/secret-store.test.vitest.mts +64 -3
  54. package/tests/secrets.test.vitest.mts +53 -0
  55. package/tests/security-alerts.test.vitest.mts +10 -0
  56. package/tests/server.test.vitest.mts +187 -7
  57. package/tests/smoke.test.vitest.mts +177 -0
  58. package/tests/system.test.vitest.mts +355 -0
  59. package/tests/verb-helpers.test.vitest.mts +46 -0
  60. package/.configs/fix-headers.json +0 -3
  61. package/.configs/vitest.config.mjs +0 -44
  62. package/.github/dependabot.yml +0 -111
  63. package/.github/workflows/branch-retention.yml +0 -44
  64. package/.github/workflows/bundle-size.yml +0 -52
  65. package/.github/workflows/ci.yml +0 -389
  66. package/.github/workflows/cla.yml +0 -66
  67. package/.github/workflows/codeql.yml +0 -87
  68. package/.github/workflows/dependabot-auto-merge.yml +0 -62
  69. package/.github/workflows/dependabot-recreate.yml +0 -67
  70. package/.github/workflows/dependency-review.yml +0 -46
  71. package/.github/workflows/feature-pr.yml +0 -75
  72. package/.github/workflows/hotfix-redirector.yml +0 -67
  73. package/.github/workflows/hotfixes-release.yml +0 -70
  74. package/.github/workflows/labeler.yml +0 -48
  75. package/.github/workflows/master-commit-audit.yml +0 -56
  76. package/.github/workflows/member-auto-merge.yml +0 -75
  77. package/.github/workflows/next-release.yml +0 -76
  78. package/.github/workflows/next-reset.yml +0 -50
  79. package/.github/workflows/pr-notify.yml +0 -44
  80. package/.github/workflows/pr-title-normalizer.yml +0 -52
  81. package/.github/workflows/provenance.yml +0 -51
  82. package/.github/workflows/publish.yml +0 -146
  83. package/.github/workflows/release-merge.yml +0 -104
  84. package/.github/workflows/release-notify.yml +0 -41
  85. package/.github/workflows/scorecard.yml +0 -64
  86. package/.github/workflows/stale.yml +0 -50
  87. package/.github/workflows/tag-health.yml +0 -70
  88. package/.github/workflows/update-major-version-tags.yml +0 -94
  89. package/.github/workflows/v4-bootstrap.yml +0 -115
  90. package/.github/workflows/welcome.yml +0 -42
@@ -0,0 +1,196 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { createNpmToken, wantsOtp } from "../deploy/lib/npm-setup.mjs";
3
+
4
+ const SESSION = "npm_SESSIONTOKEN";
5
+ const NEW_TOKEN = "npm_NEWGRANULARTOKEN";
6
+ const PASSWORD = "hunter2-secret";
7
+
8
+ type Reply = { status: number; body?: unknown; headers?: Record<string, string> };
9
+ const reply = (r: Reply) => ({
10
+ status: r.status,
11
+ headers: { get: (n: string) => r.headers?.[n.toLowerCase()] ?? null },
12
+ text: async () => (typeof r.body === "string" ? r.body : JSON.stringify(r.body ?? {}))
13
+ });
14
+
15
+ /** A scripted registry: each route pops its next reply. */
16
+ function fakeNpm(script: { login?: Reply; polls?: Reply[]; tokens?: Reply[]; logout?: Reply | Error }) {
17
+ const calls: Array<{ method: string; url: string; headers: Record<string, string>; body?: any }> = [];
18
+ const polls = [...(script.polls ?? [{ status: 200, body: { token: SESSION } }])];
19
+ const tokens = [...(script.tokens ?? [{ status: 201, body: { token: NEW_TOKEN, expires: "2027-01-29T00:00:00.000Z", key: "k" } }])];
20
+ const fetch = vi.fn(async (url: string, init: any) => {
21
+ calls.push({ method: init.method, url, headers: init.headers, ...(init.body ? { body: JSON.parse(init.body) } : {}) });
22
+ const path = new URL(url).pathname;
23
+ if (path === "/-/v1/login") return reply(script.login ?? { status: 200, body: { loginUrl: "https://www.npmjs.com/login?next=/login/cli/abc", doneUrl: "https://registry.npmjs.org/-/v1/done?authId=abc" } });
24
+ if (path === "/-/v1/done") return reply(polls.shift() ?? { status: 500 });
25
+ if (path === "/-/npm/v1/tokens") return reply(tokens.shift() ?? { status: 500 });
26
+ if (path.startsWith("/-/user/token/")) {
27
+ if (script.logout instanceof Error) throw script.logout;
28
+ return reply(script.logout ?? { status: 200, body: { ok: true } });
29
+ }
30
+ throw new Error(`unexpected ${init.method} ${url}`);
31
+ });
32
+ return { fetch, calls };
33
+ }
34
+
35
+ /** Every answer, the OTP included, is read through the hidden prompt, in call order. */
36
+ function prompts(answers: string[]) {
37
+ return { promptHidden: vi.fn(async () => answers.shift() ?? "") };
38
+ }
39
+
40
+ const run = (npm: ReturnType<typeof fakeNpm>, p = prompts([PASSWORD, "111111"]), extra: Record<string, unknown> = {}) => {
41
+ const logs: string[] = [];
42
+ const done = createNpmToken({ scopes: ["@cldmv"], tokenName: "gh-broker test", fetch: npm.fetch as any, log: (m: string) => logs.push(m), ...p, ...extra });
43
+ return { done, logs, p };
44
+ };
45
+
46
+ describe("createNpmToken", () => {
47
+ it("logs in through the web flow, creates a scoped read-write token without 2FA bypass, and revokes the session", async () => {
48
+ const npm = fakeNpm({});
49
+ const { done, logs } = run(npm);
50
+ await expect(done).resolves.toEqual({ token: NEW_TOKEN, expires: "2027-01-29T00:00:00.000Z" });
51
+ expect(logs.join("\n")).toContain("https://www.npmjs.com/login?next=/login/cli/abc");
52
+
53
+ const create = npm.calls.find((c) => c.url.endsWith("/-/npm/v1/tokens"))!;
54
+ expect(create.headers.authorization).toBe(`Bearer ${SESSION}`);
55
+ expect(create.headers["npm-otp"]).toBe("111111");
56
+ expect(create.body).toEqual({ name: "gh-broker test", scopes: ["@cldmv"], packages_and_scopes_permission: "read-write", expires: 90, password: PASSWORD });
57
+ expect(create.body).not.toHaveProperty("bypass_2fa");
58
+
59
+ const logout = npm.calls.at(-1)!;
60
+ expect(logout.method).toBe("DELETE");
61
+ expect(logout.url).toBe(`https://registry.npmjs.org/-/user/token/${SESSION}`);
62
+ expect(logs).toContain(" npm login session revoked");
63
+ });
64
+
65
+ it("waits while the login is pending, honouring retry-after", async () => {
66
+ vi.useFakeTimers();
67
+ try {
68
+ const npm = fakeNpm({ polls: [{ status: 202, headers: { "retry-after": "2" } }, { status: 202 }, { status: 200, body: { token: SESSION } }] });
69
+ const { done } = run(npm);
70
+ await vi.advanceTimersByTimeAsync(3000);
71
+ await expect(done).resolves.toMatchObject({ token: NEW_TOKEN });
72
+ expect(npm.calls.filter((c) => c.url.includes("/-/v1/done"))).toHaveLength(3);
73
+ } finally {
74
+ vi.useRealTimers();
75
+ }
76
+ });
77
+
78
+ it("gives up when the login is never approved", async () => {
79
+ const npm = fakeNpm({ polls: Array.from({ length: 5 }, () => ({ status: 202, headers: { "retry-after": "0.001" } })) });
80
+ await expect(run(npm, undefined, { pollTimeoutMs: -1 }).done).rejects.toThrow(/timed out waiting for approval/);
81
+ });
82
+
83
+ it("re-prompts for the OTP when npm rejects it", async () => {
84
+ const npm = fakeNpm({ tokens: [{ status: 401, headers: { "www-authenticate": "OTP" } }, { status: 201, body: { token: NEW_TOKEN } }] });
85
+ const p = prompts([PASSWORD, "111111", "222222"]);
86
+ await expect(run(npm, p).done).resolves.toEqual({ token: NEW_TOKEN });
87
+ const creates = npm.calls.filter((c) => c.url.endsWith("/-/npm/v1/tokens"));
88
+ expect(creates.map((c) => c.headers["npm-otp"])).toEqual(["111111", "222222"]);
89
+ // password + first OTP + the replacement OTP: the password is not asked for again
90
+ expect(p.promptHidden).toHaveBeenCalledTimes(3);
91
+ });
92
+
93
+ it("re-prompts for the password (and a new OTP) when npm rejects it", async () => {
94
+ const npm = fakeNpm({ tokens: [{ status: 401, body: { error: "invalid password" } }, { status: 201, body: { token: NEW_TOKEN } }] });
95
+ const p = prompts([PASSWORD, "111111", "right-password", "222222"]);
96
+ await expect(run(npm, p).done).resolves.toEqual({ token: NEW_TOKEN });
97
+ expect(npm.calls.filter((c) => c.url.endsWith("/-/npm/v1/tokens")).map((c) => c.body.password)).toEqual([PASSWORD, "right-password"]);
98
+ });
99
+
100
+ it("stops after three attempts and still revokes the session", async () => {
101
+ const otpNo = { status: 401, headers: { "www-authenticate": "OTP" } };
102
+ const npm = fakeNpm({ tokens: [otpNo, otpNo, { status: 401, body: { error: "You must provide a one-time pass." } }] });
103
+ const { done, logs } = run(npm, prompts([PASSWORD, "1", "2", "3"]));
104
+ await expect(done).rejects.toThrow(/npm token creation failed \(HTTP 401\)/);
105
+ expect(npm.calls.at(-1)!.method).toBe("DELETE");
106
+ expect(logs).toContain(" npm login session revoked");
107
+ });
108
+
109
+ it("fails on an unexpected registry error, revoking the session", async () => {
110
+ const npm = fakeNpm({ tokens: [{ status: 400, body: { message: "expires must be at most 90 days" } }] });
111
+ await expect(run(npm).done).rejects.toThrow(/HTTP 400\): expires must be at most 90 days/);
112
+ expect(npm.calls.at(-1)!.method).toBe("DELETE");
113
+ });
114
+
115
+ it("fails when npm returns no token", async () => {
116
+ await expect(run(fakeNpm({ tokens: [{ status: 201, body: {} }] })).done).rejects.toThrow(/did not return it/);
117
+ });
118
+
119
+ it("reports an unrevokable session as a note, not a failure", async () => {
120
+ const a = run(fakeNpm({ logout: { status: 500 } }));
121
+ await expect(a.done).resolves.toMatchObject({ token: NEW_TOKEN });
122
+ expect(a.logs.at(-1)).toMatch(/could not revoke the npm login session \(HTTP 500\)/);
123
+ const b = run(fakeNpm({ logout: new Error("socket hang up") }));
124
+ await expect(b.done).resolves.toMatchObject({ token: NEW_TOKEN });
125
+ expect(b.logs.at(-1)).toMatch(/could not revoke the npm login session \(socket hang up\)/);
126
+ });
127
+
128
+ it.each([
129
+ [{ status: 404, body: "Not Found" }, /could not start \(HTTP 404\): Not Found/],
130
+ [{ status: 200, body: { loginUrl: "http://insecure", doneUrl: "https://x" } }, /could not start/]
131
+ ] as Array<[Reply, RegExp]>)("fails when the web login cannot start (%#)", async (login, msg) => {
132
+ const npm = fakeNpm({ login });
133
+ await expect(run(npm).done).rejects.toThrow(msg);
134
+ expect(npm.calls).toHaveLength(1);
135
+ });
136
+
137
+ it.each([
138
+ [{ status: 200, body: {} }, /finished without a session/],
139
+ [{ status: 403, body: { error: "denied" } }, /web login failed \(HTTP 403\): denied/]
140
+ ] as Array<[Reply, RegExp]>)("fails when the login poll goes wrong (%#)", async (poll, msg) => {
141
+ await expect(run(fakeNpm({ polls: [poll] })).done).rejects.toThrow(msg);
142
+ });
143
+
144
+ it("never logs the password, the OTP or any token", async () => {
145
+ const { done, logs } = run(fakeNpm({}));
146
+ await done;
147
+ const all = logs.join("\n");
148
+ for (const secret of [PASSWORD, "111111", SESSION, NEW_TOKEN]) expect(all).not.toContain(secret);
149
+ });
150
+
151
+ it("adds org read-write only when orgs are given", async () => {
152
+ const npm = fakeNpm({});
153
+ await run(npm, undefined, { orgs: ["cldmv"] }).done;
154
+ expect(npm.calls.find((c) => c.url.endsWith("/tokens"))!.body).toMatchObject({ scopes: ["@cldmv"], orgs: ["cldmv"], orgs_permission: "read-write" });
155
+ });
156
+
157
+ it("rejects a login URL that does not parse", async () => {
158
+ const npm = fakeNpm({ login: { status: 200, body: { loginUrl: "not a url", doneUrl: "https://registry.npmjs.org/-/v1/done" } } });
159
+ await expect(run(npm).done).rejects.toThrow(/could not start/);
160
+ });
161
+
162
+ it("sends no npm-otp header when the code is left empty", async () => {
163
+ const npm = fakeNpm({});
164
+ await run(npm, prompts([PASSWORD, ""])).done;
165
+ expect(npm.calls.find((c) => c.url.endsWith("/tokens"))!.headers).not.toHaveProperty("npm-otp");
166
+ });
167
+
168
+ it("uses the global fetch when none is passed", async () => {
169
+ const npm = fakeNpm({});
170
+ vi.stubGlobal("fetch", npm.fetch);
171
+ try {
172
+ const logs: string[] = [];
173
+ await expect(createNpmToken({ scopes: ["@cldmv"], tokenName: "t", log: (m: string) => logs.push(m), ...prompts([PASSWORD, "111111"]) })).resolves.toMatchObject({ token: NEW_TOKEN });
174
+ } finally {
175
+ vi.unstubAllGlobals();
176
+ }
177
+ });
178
+
179
+ it("uses a custom registry and token lifetime", async () => {
180
+ const npm = fakeNpm({ login: { status: 200, body: { loginUrl: "https://npm.example.test/login", doneUrl: "https://npm.example.test/-/v1/done?x" } } });
181
+ await run(npm, undefined, { registry: "https://npm.example.test/", days: 30 }).done;
182
+ expect(npm.calls[0]!.url).toBe("https://npm.example.test/-/v1/login");
183
+ expect(npm.calls.find((c) => c.url.endsWith("/tokens"))!.body.expires).toBe(30);
184
+ });
185
+ });
186
+
187
+ describe("wantsOtp", () => {
188
+ const res = (status: number, h: Record<string, string> = {}) => ({ status, headers: { get: (n: string) => h[n] ?? null } });
189
+ it("recognises npm's OTP signals and nothing else", () => {
190
+ expect(wantsOtp(res(401, { "www-authenticate": "OTP" }), {})).toBe(true);
191
+ expect(wantsOtp(res(401), { error: "You must provide a one-time pass." })).toBe(true);
192
+ expect(wantsOtp(res(401), { error: "bad password" })).toBe(false);
193
+ expect(wantsOtp(res(401), undefined)).toBe(false);
194
+ expect(wantsOtp(res(403, { "www-authenticate": "OTP" }), {})).toBe(false);
195
+ });
196
+ });
@@ -46,6 +46,11 @@ describe("paths", () => {
46
46
  const win = await import("../src/paths.ts");
47
47
  expect(win.configDir()).toContain("gh-broker");
48
48
 
49
+ vi.resetModules();
50
+ vi.doMock("node:process", () => ({ platform: "win32", env: {} }));
51
+ const winDefault = await import("../src/paths.ts");
52
+ expect(winDefault.configDir()).toContain("C:\\ProgramData");
53
+
49
54
  vi.doUnmock("node:process");
50
55
  vi.resetModules();
51
56
  });
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+
3
+ // Stand-ins for process.stdin / process.stdout so the raw-mode reader can be driven by hand.
4
+ const h = vi.hoisted(() => ({ stdin: null as any, stdout: null as any }));
5
+ vi.mock("node:process", async () => {
6
+ const { EventEmitter } = await import("node:events");
7
+ class FakeStdin extends EventEmitter {
8
+ isTTY = true;
9
+ raw: boolean[] = [];
10
+ paused = false;
11
+ setRawMode(v: boolean) {
12
+ this.raw.push(v);
13
+ }
14
+ resume() {
15
+ this.paused = false;
16
+ }
17
+ pause() {
18
+ this.paused = true;
19
+ }
20
+ }
21
+ h.stdin = new FakeStdin();
22
+ h.stdout = { out: "", write(s: string) { this.out += s; return true; } };
23
+ return { stdin: h.stdin, stdout: h.stdout };
24
+ });
25
+
26
+ import { promptLine, promptHidden, confirm, choose } from "../deploy/lib/prompt.mjs";
27
+
28
+ const type = (s: string) => h.stdin.emit("data", Buffer.from(s, "utf8"));
29
+
30
+ beforeEach(() => {
31
+ h.stdout.out = "";
32
+ h.stdin.removeAllListeners("data");
33
+ h.stdin.raw = [];
34
+ h.stdin.isTTY = true;
35
+ });
36
+
37
+ describe("promptLine / promptHidden", () => {
38
+ it("echoes typed characters and returns the trimmed line on Enter", async () => {
39
+ const p = promptLine("name? ");
40
+ type(" ab");
41
+ type("c \r");
42
+ expect(await p).toBe("abc");
43
+ expect(h.stdout.out).toBe("name? abc \n");
44
+ expect(h.stdin.raw).toEqual([true, false]);
45
+ expect(h.stdin.paused).toBe(true);
46
+ expect(h.stdin.listenerCount("data")).toBe(0);
47
+ });
48
+
49
+ it("does not echo hidden input", async () => {
50
+ const p = promptHidden("secret: ");
51
+ type("hunter2\n");
52
+ expect(await p).toBe("hunter2");
53
+ expect(h.stdout.out).toBe("secret: \n");
54
+ });
55
+
56
+ it("handles Backspace (DEL and BS), including on empty input", async () => {
57
+ const p = promptLine("? ");
58
+ type("\x7f"); // nothing to delete
59
+ type("abc\x7f\x08d\n");
60
+ expect(await p).toBe("ad");
61
+ expect(h.stdout.out).toContain("\b \b");
62
+
63
+ const q = promptHidden("? ");
64
+ h.stdout.out = "";
65
+ type("ab\x7f\n");
66
+ expect(await q).toBe("a");
67
+ expect(h.stdout.out).not.toContain("\b \b");
68
+ });
69
+
70
+ it("returns what was typed on Ctrl-D, and rejects on Ctrl-C", async () => {
71
+ const p = promptLine("? ");
72
+ type("xy\x04");
73
+ expect(await p).toBe("xy");
74
+
75
+ const q = promptLine("? ");
76
+ type("xy\x03");
77
+ await expect(q).rejects.toThrow("aborted");
78
+ });
79
+
80
+ it("skips raw mode when stdin is not a TTY", async () => {
81
+ h.stdin.isTTY = false;
82
+ const p = promptLine("? ");
83
+ type("ok\n");
84
+ expect(await p).toBe("ok");
85
+ expect(h.stdin.raw).toEqual([]);
86
+ });
87
+ });
88
+
89
+ describe("confirm", () => {
90
+ it.each([
91
+ ["y\n", true],
92
+ ["YES\n", true],
93
+ ["n\n", false],
94
+ ["\n", false],
95
+ ["yeah\n", false]
96
+ ])("%j -> %s", async (input, expected) => {
97
+ const p = confirm("sure?");
98
+ type(input);
99
+ expect(await p).toBe(expected);
100
+ expect(h.stdout.out).toContain("sure? [y/N] ");
101
+ });
102
+ });
103
+
104
+ describe("choose", () => {
105
+ const opts = [
106
+ { key: "a", label: "Alpha" },
107
+ { key: "b", label: "Beta" }
108
+ ];
109
+
110
+ it("lists the options and returns the chosen key", async () => {
111
+ const p = choose("pick", opts);
112
+ type("2\n");
113
+ expect(await p).toBe("b");
114
+ expect(h.stdout.out).toContain("pick\n 1) Alpha\n 2) Beta\n");
115
+ });
116
+
117
+ it("uses the default on empty input, and falls back to it on invalid input", async () => {
118
+ let p = choose("pick", opts, 2);
119
+ type("\n");
120
+ expect(await p).toBe("b");
121
+ p = choose("pick", opts);
122
+ type("9\n");
123
+ expect(await p).toBe("a");
124
+ p = choose("pick", opts);
125
+ type("zz\n");
126
+ expect(await p).toBe("a");
127
+ });
128
+ });
@@ -203,6 +203,16 @@ describe("reportReleasePrs", () => {
203
203
  expect(out.releasePrs).toEqual([]);
204
204
  });
205
205
 
206
+ it("stops with rate-limited when the changelog lookup itself is rate limited", async () => {
207
+ const octokit = octokitWith(async (route) => {
208
+ if (route === "GET /search/issues") return search([hit("a", 1, "release: v1.0.0")]);
209
+ if (route === "GET /repos/{owner}/{repo}/pulls/{pull_number}") return pull();
210
+ throw status(429, "Too Many Requests");
211
+ });
212
+ const out = await reportReleasePrs(octokit, { owner: "cldmv" });
213
+ expect(out.truncatedReason).toBe("rate-limited");
214
+ });
215
+
206
216
  it("omits retryAfterSeconds when GitHub sent none", async () => {
207
217
  const octokit = octokitWith(async () => { throw status(429, "Too Many Requests"); });
208
218
  const out = await reportReleasePrs(octokit, { owner: "cldmv" });
@@ -70,7 +70,7 @@ describe("Router.call", () => {
70
70
  beforeEach(() => {
71
71
  vi.clearAllMocks();
72
72
  vi.spyOn(console, "log").mockImplementation(() => {}); // silence audit output
73
- secrets = { resolve: vi.fn((ref: string) => `token:${ref}`) };
73
+ secrets = { resolve: vi.fn((ref: string) => `token:${ref}`), has: vi.fn(() => true) };
74
74
  upstream = { forward: vi.fn().mockResolvedValue({ content: [{ type: "text", text: "ok" }] }) };
75
75
  });
76
76
 
@@ -87,7 +87,12 @@ describe("Router.call", () => {
87
87
  it("advertisedTools returns the config tools", () => {
88
88
  const cfg = makeConfig();
89
89
  const r = new Router(cfg, secrets as any, upstream as any);
90
- expect(r.advertisedTools()).toBe(cfg.tools);
90
+ expect(r.advertisedTools()).toEqual(cfg.tools);
91
+ });
92
+
93
+ it("advertisedTools leaves out a tool whose optional integration is not set up", () => {
94
+ const cfg = makeConfig({ tools: [{ name: "get_me", mode: "read" }, { name: "npm_scaffold", mode: "write", custom: true }] });
95
+ expect(new Router(cfg, secrets as any, upstream as any).advertisedTools().map((t) => t.name)).toEqual(["get_me"]);
91
96
  });
92
97
 
93
98
  it("forwards a read call with the resolved read PAT", async () => {
@@ -152,6 +157,56 @@ describe("Router.call", () => {
152
157
  expect(out).toEqual({ content: [{ type: "text", text: "thread PRRT_1: isResolved=true" }] });
153
158
  });
154
159
 
160
+ it("npm_scaffold is unavailable without an npm block, before any credential is read", async () => {
161
+ const r = new Router(makeConfig({ tools: [{ name: "npm_scaffold", mode: "write", custom: true }] }), secrets as any, upstream as any);
162
+ await expect(r.call("npm_scaffold", { owner: "cldmv", repo: "x", package: "@cldmv/x", otp: "123456" })).rejects.toThrow(
163
+ /tool "npm_scaffold" is not available on this broker: the npm integration is not configured/
164
+ );
165
+ expect(secrets.resolve).not.toHaveBeenCalled();
166
+ });
167
+
168
+ it("npm_scaffold is unavailable when its token is not stored", async () => {
169
+ secrets.has.mockImplementation((ref: string) => ref !== "cred:npm-token");
170
+ const cfg = makeConfig({ tools: [{ name: "npm_scaffold", mode: "write", custom: true }], npm: { token: "cred:npm-token", scopes: ["@cldmv"] } });
171
+ const r = new Router(cfg, secrets as any, upstream as any);
172
+ await expect(r.call("npm_scaffold", { owner: "cldmv", repo: "x", package: "@cldmv/x", otp: "123456" })).rejects.toThrow(/its credential "npm-token" is not stored/);
173
+ expect(secrets.resolve).not.toHaveBeenCalled();
174
+ });
175
+
176
+ it("npm_scaffold reads GitHub on the READ PAT and resolves the npm token", async () => {
177
+ requestMock.mockRejectedValue(Object.assign(new Error("Not Found"), { status: 404 }));
178
+ const cfg = makeConfig({ tools: [{ name: "npm_scaffold", mode: "write", custom: true }], npm: { token: "cred:npm-token", scopes: ["@cldmv"] } });
179
+ const r = new Router(cfg, secrets as any, upstream as any);
180
+ await expect(r.call("npm_scaffold", { owner: "cldmv", repo: "x", package: "@cldmv/x", otp: "123456" })).rejects.toThrow(/repository cldmv\/x not found/);
181
+ expect(Octokit).toHaveBeenLastCalledWith({ auth: "token:cred:cldmv-read" });
182
+ expect(secrets.resolve).toHaveBeenCalledWith("cred:npm-token");
183
+ });
184
+
185
+ it("npm_scaffold publishes through the configured registry and returns the verb's result", async () => {
186
+ const b64 = (x: string) => Buffer.from(x).toString("base64");
187
+ requestMock.mockImplementation(async (route: string, p: any) => {
188
+ if (route === "GET /repos/{owner}/{repo}") return { data: { full_name: "CLDMV/x", private: false, default_branch: "master" } };
189
+ if (p.path === "package.json") return { data: { type: "file", encoding: "base64", content: b64('{"name":"@cldmv/x"}') } };
190
+ return { data: { type: "file", encoding: "base64", content: b64("name: publish") } };
191
+ });
192
+ const urls: string[] = [];
193
+ const fetchMock = vi.fn(async (url: string, init: any) => {
194
+ urls.push(`${init.method} ${url}`);
195
+ return { status: init.method === "GET" ? 404 : 200, headers: { get: () => null }, text: async () => "{}" };
196
+ });
197
+ vi.stubGlobal("fetch", fetchMock);
198
+ try {
199
+ const cfg = makeConfig({ tools: [{ name: "npm_scaffold", mode: "write", custom: true }], npm: { token: "cred:npm-token", scopes: ["@cldmv"], registry: "https://npm.example.test" } });
200
+ const r = new Router(cfg, secrets as any, upstream as any);
201
+ const out = await r.call("npm_scaffold", { owner: "cldmv", repo: "x", package: "@cldmv/x", otp: "123456" });
202
+ expect(JSON.parse((out.content[0] as any).text)).toMatchObject({ status: "needs_otp", done: ["publish"], remaining: ["trust"] });
203
+ expect(urls).toEqual(["GET https://npm.example.test/@cldmv%2fx?write=true", "PUT https://npm.example.test/@cldmv%2fx"]);
204
+ expect(fetchMock.mock.calls[1]![1].headers.authorization).toBe("Bearer token:cred:npm-token");
205
+ } finally {
206
+ vi.unstubAllGlobals();
207
+ }
208
+ });
209
+
155
210
  it("throws for an allowlisted custom verb that has no implementation", async () => {
156
211
  const cfg = makeConfig({ tools: [{ name: "weird_verb", mode: "read", custom: true }] });
157
212
  const r = new Router(cfg, secrets as any, upstream as any);
@@ -397,3 +452,54 @@ describe("Router.call", () => {
397
452
  });
398
453
  });
399
454
  });
455
+
456
+ describe("Router.call — GHCR verbs", () => {
457
+ const ghcr = { dropDir: "/srv/gh-broker/ghcr-drop", protectReleaseTags: true, maxArchiveBytes: 1024, owners: { cldmv: { username: "u", credential: "cred:cldmv-packages", images: ["hoot"] } } };
458
+ const tools = [
459
+ { name: "ghcr_push", mode: "write", custom: true },
460
+ { name: "ghcr_push_status", mode: "read", custom: true },
461
+ { name: "ghcr_tags", mode: "read", custom: true }
462
+ ];
463
+ let secrets: { resolve: ReturnType<typeof vi.fn> };
464
+ beforeEach(() => {
465
+ vi.spyOn(console, "log").mockImplementation(() => {});
466
+ secrets = { resolve: vi.fn((ref: string) => `token:${ref}`), has: vi.fn(() => true) };
467
+ });
468
+ afterEach(() => vi.restoreAllMocks());
469
+
470
+ it("dispatches ghcr_push_status to the GHCR service and returns JSON", async () => {
471
+ const r = new Router(makeConfig({ tools, ghcr }), secrets as any, { forward: vi.fn() } as any);
472
+ expect(await r.call("ghcr_push_status", { owner: "cldmv" })).toEqual({ content: [{ type: "text", text: JSON.stringify({ jobs: [] }) }] });
473
+ });
474
+
475
+ it("passes GHCR refusals through (allowlist checked before any network call or GHCR credential read)", async () => {
476
+ const r = new Router(makeConfig({ tools, ghcr }), secrets as any, { forward: vi.fn() } as any);
477
+ await expect(r.call("ghcr_tags", { owner: "cldmv", image: "private" })).rejects.toThrow(/not on the allowlist/);
478
+ await expect(r.call("ghcr_push", { owner: "cldmv", image: "private", tag: "t", archive: "a.tar" })).rejects.toThrow(/not on the allowlist/);
479
+ expect(secrets.resolve).not.toHaveBeenCalledWith("cred:cldmv-packages");
480
+ });
481
+
482
+ it("audits and rethrows a GHCR call that names no owner", async () => {
483
+ const r = new Router(makeConfig({ tools, ghcr }), secrets as any, { forward: vi.fn() } as any);
484
+ await expect(r.call("ghcr_push_status", {})).rejects.toThrow();
485
+ });
486
+
487
+ it("refuses GHCR verbs when config.ghcr is absent", async () => {
488
+ const r = new Router(makeConfig({ tools }), secrets as any, { forward: vi.fn() } as any);
489
+ await expect(r.call("ghcr_push", { owner: "cldmv", image: "hoot", tag: "t", archive: "a" })).rejects.toThrow(/tool "ghcr_push" is not available on this broker: GHCR pushes are not configured/);
490
+ });
491
+
492
+ it("refuses an owner whose GHCR credential is not stored, while another owner's is", async () => {
493
+ const ghcr = {
494
+ dropDir: "/srv/x/drop",
495
+ protectReleaseTags: true,
496
+ maxArchiveBytes: 1,
497
+ owners: { cldmv: { username: "u", credential: "cred:cldmv-packages", images: ["hoot"] }, other: { username: "o", credential: "cred:other-packages", images: ["x"] } }
498
+ };
499
+ secrets.has.mockImplementation((ref: string) => ref === "cred:other-packages");
500
+ const r = new Router(makeConfig({ tools, ghcr }), secrets as any, { forward: vi.fn() } as any);
501
+ await expect(r.call("ghcr_push_status", { owner: "cldmv" })).rejects.toThrow(/owner "cldmv"'s GHCR credential "cldmv-packages" is not stored/);
502
+ await expect(r.call("ghcr_push_status", { owner: "other" })).resolves.toMatchObject({ content: [{ type: "text" }] });
503
+ expect(secrets.resolve).not.toHaveBeenCalledWith("cred:cldmv-packages");
504
+ });
505
+ });
@@ -5,17 +5,43 @@ vi.mock("node:fs", () => ({
5
5
  readFileSync: vi.fn(),
6
6
  writeFileSync: vi.fn(),
7
7
  statSync: vi.fn(() => ({ size: 8 })),
8
- rmSync: vi.fn()
8
+ rmSync: vi.fn(),
9
+ chmodSync: vi.fn(),
10
+ chownSync: vi.fn()
9
11
  }));
10
12
 
11
- import { readFileSync, existsSync, writeFileSync, rmSync } from "node:fs";
12
- import { readSecretsFile, removeSecretsFile, referencedCredNames, detectSecretsProvider } from "../deploy/lib/secret-store.mjs";
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";
13
15
 
14
16
  const mockRead = vi.mocked(readFileSync);
15
17
  const mockExists = vi.mocked(existsSync);
16
18
  const mockWrite = vi.mocked(writeFileSync);
17
19
  const mockRm = vi.mocked(rmSync);
18
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
+
19
45
  describe("readSecretsFile", () => {
20
46
  beforeEach(() => mockRead.mockReset());
21
47
 
@@ -24,6 +50,12 @@ describe("readSecretsFile", () => {
24
50
  expect(readSecretsFile("/x")).toEqual({ a: "tok", e: "ok" });
25
51
  });
26
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
+
27
59
  it("returns {} for a non-object or unparseable file", () => {
28
60
  mockRead.mockReturnValue("[1,2,3]" as any);
29
61
  expect(readSecretsFile("/x")).toEqual({});
@@ -56,6 +88,20 @@ describe("removeSecretsFile", () => {
56
88
  });
57
89
  });
58
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
+
59
105
  describe("referencedCredNames", () => {
60
106
  it("collects distinct cred names from owners + app, ignoring non-cred refs", () => {
61
107
  const cfg = {
@@ -63,6 +109,21 @@ describe("referencedCredNames", () => {
63
109
  app: { privateKey: "cred:k" }
64
110
  };
65
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/);
66
127
  });
67
128
 
68
129
  it("returns [] with no owners", () => {
@@ -107,12 +107,46 @@ describe("loadSecretStore (file provider)", () => {
107
107
  expect(() => loadSecretStore(config(undefined, { secrets: { provider: "vault", path: SECRETS } }))).toThrow(/unknown provider/);
108
108
  });
109
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
+
110
122
  it("rejects a non-cred config.app.privateKey (would inline a secret in the config)", () => {
111
123
  secretsFile({ "cldmv-read": "r", "cldmv-write": "w" });
112
124
  expect(() => loadSecretStore(config(undefined, { app: { appId: 1, privateKey: "-----BEGIN PRIVATE KEY-----" } }))).toThrow(
113
125
  /config.app.privateKey must be a "cred:<name>" ref/
114
126
  );
115
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
+ });
116
150
  });
117
151
 
118
152
  describe("loadSecretStore (systemd-creds provider, the default)", () => {
@@ -152,4 +186,23 @@ describe("loadSecretStore (systemd-creds provider, the default)", () => {
152
186
  expect(store.resolve("cred:cldmv-read")).toBe("rtok");
153
187
  expect(store.resolve("cred:cldmv-write")).toBe("wtok");
154
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
+ });
155
208
  });