@cldmv/gh-broker 0.7.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 (107) hide show
  1. package/README.md +165 -41
  2. package/config.example.json +38 -9
  3. package/config.sample.json +45 -0
  4. package/deploy/README.md +76 -17
  5. package/deploy/install.mjs +171 -104
  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 +5864 -1809
  14. package/dist/index.mjs.map +4 -4
  15. package/package.json +18 -8
  16. package/.configs/fix-headers.json +0 -3
  17. package/.configs/vitest.config.mjs +0 -44
  18. package/.github/dependabot.yml +0 -111
  19. package/.github/workflows/branch-retention.yml +0 -44
  20. package/.github/workflows/bundle-size.yml +0 -52
  21. package/.github/workflows/ci.yml +0 -389
  22. package/.github/workflows/cla.yml +0 -66
  23. package/.github/workflows/codeql.yml +0 -87
  24. package/.github/workflows/dependabot-auto-merge.yml +0 -62
  25. package/.github/workflows/dependabot-recreate.yml +0 -67
  26. package/.github/workflows/dependency-review.yml +0 -46
  27. package/.github/workflows/feature-pr.yml +0 -75
  28. package/.github/workflows/hotfix-redirector.yml +0 -67
  29. package/.github/workflows/hotfixes-release.yml +0 -70
  30. package/.github/workflows/labeler.yml +0 -48
  31. package/.github/workflows/master-commit-audit.yml +0 -56
  32. package/.github/workflows/member-auto-merge.yml +0 -75
  33. package/.github/workflows/next-release.yml +0 -76
  34. package/.github/workflows/next-reset.yml +0 -50
  35. package/.github/workflows/pr-notify.yml +0 -44
  36. package/.github/workflows/pr-title-normalizer.yml +0 -52
  37. package/.github/workflows/provenance.yml +0 -51
  38. package/.github/workflows/publish.yml +0 -146
  39. package/.github/workflows/release-merge.yml +0 -104
  40. package/.github/workflows/release-notify.yml +0 -41
  41. package/.github/workflows/scorecard.yml +0 -64
  42. package/.github/workflows/stale.yml +0 -50
  43. package/.github/workflows/tag-health.yml +0 -70
  44. package/.github/workflows/update-major-version-tags.yml +0 -94
  45. package/.github/workflows/v4-bootstrap.yml +0 -115
  46. package/.github/workflows/welcome.yml +0 -42
  47. package/docs/TOOLS.md +0 -347
  48. package/docs/agent-user-model.md +0 -89
  49. package/docs/changelog/v0/v0.2.0.md +0 -26
  50. package/docs/changelog/v0/v0.2.1.md +0 -23
  51. package/docs/changelog/v0/v0.2.2.md +0 -23
  52. package/docs/changelog/v0/v0.2.3.md +0 -33
  53. package/docs/changelog/v0/v0.2.4.md +0 -25
  54. package/docs/changelog/v0/v0.2.5.md +0 -29
  55. package/docs/changelog/v0/v0.3.0.md +0 -84
  56. package/docs/changelog/v0/v0.3.1.md +0 -30
  57. package/docs/changelog/v0/v0.3.2.md +0 -24
  58. package/docs/changelog/v0/v0.3.3.md +0 -28
  59. package/docs/changelog/v0/v0.4.0.md +0 -58
  60. package/docs/changelog/v0/v0.4.1.md +0 -33
  61. package/docs/changelog/v0/v0.4.2.md +0 -45
  62. package/docs/changelog/v0/v0.5.0.md +0 -39
  63. package/docs/changelog/v0/v0.5.1.md +0 -28
  64. package/docs/changelog/v0/v0.6.0.md +0 -52
  65. package/docs/changelog/v0/v0.6.1.md +0 -48
  66. package/docs/changelog/v0/v0.7.0.md +0 -71
  67. package/docs/deploy-architecture.md +0 -82
  68. package/src/appauth.ts +0 -56
  69. package/src/audit.ts +0 -34
  70. package/src/catalog.ts +0 -165
  71. package/src/config.ts +0 -130
  72. package/src/index.ts +0 -107
  73. package/src/paths.ts +0 -60
  74. package/src/router.ts +0 -258
  75. package/src/secrets.ts +0 -137
  76. package/src/server.ts +0 -91
  77. package/src/types.ts +0 -104
  78. package/src/upstream.ts +0 -85
  79. package/src/verbs/issue_labels.ts +0 -329
  80. package/src/verbs/link_issue.ts +0 -84
  81. package/src/verbs/release-analysis.ts +0 -210
  82. package/src/verbs/repo-tier.ts +0 -90
  83. package/src/verbs/report_ci_status.ts +0 -358
  84. package/src/verbs/report_release_prs.ts +0 -238
  85. package/src/verbs/report_security_alerts.ts +0 -442
  86. package/src/verbs/report_unlabeled.ts +0 -194
  87. package/src/verbs/rest-client.ts +0 -181
  88. package/src/verbs/review_thread.ts +0 -138
  89. package/tests/appauth.test.vitest.mts +0 -76
  90. package/tests/audit.test.vitest.mts +0 -48
  91. package/tests/catalog.test.vitest.mts +0 -180
  92. package/tests/config-merge.test.vitest.mts +0 -58
  93. package/tests/config.test.vitest.mts +0 -314
  94. package/tests/issue-labels.test.vitest.mts +0 -347
  95. package/tests/paths.test.vitest.mts +0 -52
  96. package/tests/release-prs.test.vitest.mts +0 -237
  97. package/tests/rest-client.test.vitest.mts +0 -115
  98. package/tests/router.test.vitest.mts +0 -399
  99. package/tests/run-vitest.mjs +0 -51
  100. package/tests/secret-store.test.vitest.mts +0 -106
  101. package/tests/secrets.test.vitest.mts +0 -155
  102. package/tests/security-alerts.test.vitest.mts +0 -328
  103. package/tests/server.test.vitest.mts +0 -20
  104. package/tests/upstream.test.vitest.mts +0 -91
  105. package/tests/verb-helpers.test.vitest.mts +0 -331
  106. package/tests/verbs.test.vitest.mts +0 -624
  107. package/tsconfig.json +0 -21
@@ -0,0 +1,143 @@
1
+ /**
2
+ *
3
+ * @Project: @cldmv/gh-broker
4
+ * @Filename: /deploy/lib/credentials.mjs
5
+ * @Date: 2026-10-31T12:53:32-07:00 (1793476412)
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-31T12:58:15-07:00 (1793476695)
11
+ * -----
12
+ * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
+ *
14
+ */
15
+
16
+ /**
17
+ * Where each credential's value comes from, and what gets stored, for the installer's
18
+ * secrets step. Kept out of install.mjs (which needs root and a systemd host) so the
19
+ * decisions are testable; the filesystem and systemd operations are passed in.
20
+ *
21
+ * Precedence for a value: explicit (--secret, then --secrets-file, then env
22
+ * GH_BROKER_SECRET_<NAME>) → a newly created npm token (when this run creates one) → the
23
+ * plaintext secrets file → decrypting an existing .cred → an interactive prompt. An explicit
24
+ * value, or a newly created token, replaces a stored one (rotation); otherwise a stored value
25
+ * is kept. Optional credentials (their tools are hidden without them) can be skipped and never
26
+ * fail the install. Every problem throws; install.mjs reports it and exits.
27
+ */
28
+
29
+ import { envSecretKey } from "./install-args.mjs";
30
+
31
+ /**
32
+ * @param {object} o
33
+ * @param {string[]} o.needed every cred name the config references
34
+ * @param {Set<string>} o.optional the ones that may be missing
35
+ * @param {Record<string, string>} o.cliSecrets --secret values
36
+ * @param {Record<string, unknown>} o.imported --secrets-file contents ({} when none)
37
+ * @param {Record<string, string | undefined>} o.env
38
+ * @param {Record<string, string>} o.existingFile current plaintext secrets file ({} when none)
39
+ * @param {(name: string) => string | undefined} o.decrypt an existing .cred's value, if any
40
+ * @param {(name: string) => boolean} o.mints whether this run creates `name` (the npm token)
41
+ * @param {(name: string) => Promise<string>} o.mint
42
+ * @param {string | undefined} o.appKeyName the App private key (multi-line; can't be prompted)
43
+ * @param {boolean} o.nonInteractive
44
+ * @param {(q: string) => Promise<string>} o.promptHidden
45
+ * @param {(m: string) => void} o.log
46
+ */
47
+ export function credentialPlan(o) {
48
+ const unknown = Object.keys(o.cliSecrets).filter((n) => !o.needed.includes(n));
49
+ if (unknown.length) throw new Error(`--secret names no credential this config uses: ${unknown.join(", ")} (it uses: ${o.needed.join(", ")})`);
50
+
51
+ const explicit = (name) => {
52
+ for (const v of [o.cliSecrets[name], o.imported?.[name], o.env[envSecretKey(name)]]) {
53
+ if (typeof v === "string" && v) return v;
54
+ }
55
+ return undefined;
56
+ };
57
+ const minting = (name) => o.mints(name) && explicit(name) === undefined;
58
+ /** A new value is supplied for this run, so a stored one is replaced. */
59
+ const replaces = (name) => explicit(name) !== undefined || minting(name);
60
+
61
+ /** The value for `name`, or undefined for a skipped optional credential. */
62
+ const resolve = async (name) => {
63
+ const given = explicit(name);
64
+ if (given) return given;
65
+ if (minting(name)) {
66
+ if (o.nonInteractive) throw new Error(`creating the npm token needs a browser login and prompts — run interactively, or supply an existing token via --secret, --secrets-file or env ${envSecretKey(name)}`);
67
+ return o.mint(name);
68
+ }
69
+ const fromFile = o.existingFile[name];
70
+ if (typeof fromFile === "string" && fromFile) return fromFile;
71
+ const dec = o.decrypt(name);
72
+ if (dec) {
73
+ o.log(` ${name}: migrated from systemd-creds`);
74
+ return dec;
75
+ }
76
+ if (o.optional.has(name)) {
77
+ if (o.nonInteractive) return undefined;
78
+ return (await o.promptHidden(` ${name} (input hidden; Enter to skip — its tools stay hidden): `)) || undefined;
79
+ }
80
+ if (o.nonInteractive) throw new Error(`missing credential "${name}" — provide via --secret, --secrets-file or env ${envSecretKey(name)}`);
81
+ if (name === o.appKeyName) {
82
+ throw new Error(`credential "${name}" is a GitHub App private key (multi-line PEM); the single-line prompt can't accept it — supply it via --secrets-file or env ${envSecretKey(name)}`);
83
+ }
84
+ const entered = await o.promptHidden(` ${name} (input hidden): `);
85
+ if (!entered) throw new Error(`empty value for "${name}"`);
86
+ return entered;
87
+ };
88
+
89
+ const skipped = (name) => o.log(` ${name}: not stored — its tools stay hidden until it is (re-run the installer to add it)`);
90
+ const replacedNote = (name) => {
91
+ if (minting(name)) o.log(` note: the previous ${name} is still valid until it expires — revoke it in your npm account's Access Tokens settings`);
92
+ };
93
+
94
+ /**
95
+ * systemd-creds backend. Returns the names that end up stored: only those go into the
96
+ * LoadCredentialEncrypted= drop-in, since a line for a missing file stops the unit.
97
+ * @param {{ exists: (name: string) => boolean, encrypt: (name: string, value: string) => void }} io
98
+ */
99
+ const storeSystemd = async (io) => {
100
+ const stored = [];
101
+ for (const name of o.needed) {
102
+ const existed = io.exists(name);
103
+ // A plaintext entry (file → creds migration) or a new value re-encrypts; otherwise keep.
104
+ if (existed && !o.existingFile[name] && !replaces(name)) {
105
+ o.log(` ${name}: existing systemd-creds kept`);
106
+ stored.push(name);
107
+ continue;
108
+ }
109
+ // Only a missing optional cred can resolve to nothing here: a re-encrypt always has a
110
+ // value (the explicit one, the new npm token, or the plaintext entry).
111
+ const value = await resolve(name);
112
+ if (value === undefined) {
113
+ skipped(name);
114
+ continue;
115
+ }
116
+ io.encrypt(name, value);
117
+ stored.push(name);
118
+ o.log(` ${name}: ${existed ? "rotated" : "encrypted"} (systemd-creds)`);
119
+ if (existed) replacedNote(name);
120
+ }
121
+ return stored;
122
+ };
123
+
124
+ /** file backend: the token map to write. */
125
+ const storeFile = async () => {
126
+ const tokens = { ...o.existingFile };
127
+ for (const name of o.needed) {
128
+ if (tokens[name] && !replaces(name)) continue;
129
+ const had = Boolean(tokens[name]);
130
+ // As above, only a missing optional cred can resolve to nothing.
131
+ const value = await resolve(name);
132
+ if (value === undefined) {
133
+ skipped(name);
134
+ continue;
135
+ }
136
+ tokens[name] = value;
137
+ if (had) replacedNote(name);
138
+ }
139
+ return tokens;
140
+ };
141
+
142
+ return { explicit, replaces, resolve, storeSystemd, storeFile };
143
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ *
3
+ * @Project: @cldmv/gh-broker
4
+ * @Filename: /deploy/lib/install-args.mjs
5
+ * @Date: 2026-10-31T12:31:29-07:00 (1793475089)
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-11-01T00:40:47-07:00 (1793518847)
11
+ * -----
12
+ * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
+ *
14
+ */
15
+
16
+ /**
17
+ * The installer's command-line parsing, kept apart from install.mjs (which needs root and
18
+ * a systemd host) so it can be tested. Every problem throws; install.mjs turns that into
19
+ * its usual fatal message.
20
+ */
21
+
22
+ import { assertSafeCredName } from "./secret-store.mjs";
23
+
24
+ export const NPM_SCOPE = /^@[a-z0-9][a-z0-9._~-]*$/;
25
+ export const NPM_ORG = /^[a-z0-9][a-z0-9._~-]*$/;
26
+ /** A GitHub owner (user or org) name, lowercase, as config.owners keys must be. */
27
+ export const GH_OWNER = /^[a-z0-9](?:[a-z0-9-]{0,38})$/;
28
+ const GH_LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
29
+ const IMAGE_GLOB = /^[a-z0-9*](?:[a-z0-9._*-]*[a-z0-9*])?(?:\/[a-z0-9*](?:[a-z0-9._*-]*[a-z0-9*])?)*$/;
30
+
31
+ /** env var that supplies the token for cred `name`: GH_BROKER_SECRET_<NAME>. */
32
+ export const envSecretKey = (name) => "GH_BROKER_SECRET_" + name.toUpperCase().replace(/[^A-Z0-9]/g, "_");
33
+
34
+ export function parseArgs(argv) {
35
+ const o = { positional: [], nonInteractive: false, createAgentUser: false, npm: false, npmScopes: [], npmOrgs: [], cliSecrets: {} };
36
+ // Consume the value after a value-taking flag, failing if it's missing or is
37
+ // itself a flag — so `--agent-user --secrets-provider file` can't silently take
38
+ // "--secrets-provider" as the username and proceed with an unintended user.
39
+ const value = (i, flag) => {
40
+ const v = argv[i];
41
+ if (v === undefined || v.startsWith("-")) throw new Error(`${flag} requires a value (see --help)`);
42
+ return v;
43
+ };
44
+ for (let i = 0; i < argv.length; i++) {
45
+ const a = argv[i];
46
+ if (a === "-h" || a === "--help") o.help = true;
47
+ else if (a === "-y" || a === "--non-interactive") o.nonInteractive = true;
48
+ else if (a === "--create-agent-user") o.createAgentUser = true;
49
+ else if (a === "--agent-user") o.agentUser = value(++i, a);
50
+ else if (a === "--secrets-provider") o.provider = value(++i, a);
51
+ else if (a === "--secrets-file") o.secretsFile = value(++i, a);
52
+ else if (a === "--secret") {
53
+ const pair = value(++i, a);
54
+ const eq = pair.indexOf("=");
55
+ if (eq <= 0 || eq === pair.length - 1) throw new Error("--secret takes <cred-name>=<token> (see --help)");
56
+ o.cliSecrets[assertSafeCredName(pair.slice(0, eq))] = pair.slice(eq + 1);
57
+ } else if (a === "--npm") o.npm = true;
58
+ else if (a === "--npm-scope") {
59
+ const s = value(++i, a);
60
+ if (!NPM_SCOPE.test(s)) throw new Error(`--npm-scope must be a lowercase npm scope like "@cldmv", got "${s}"`);
61
+ o.npmScopes.push(s);
62
+ } else if (a === "--npm-org") {
63
+ const org = value(++i, a);
64
+ if (!NPM_ORG.test(org)) throw new Error(`--npm-org must be a lowercase npm org name like "cldmv", got "${org}"`);
65
+ o.npmOrgs.push(org);
66
+ } else if (a === "--npm-token-days") {
67
+ const days = value(++i, a);
68
+ if (!/^[1-9][0-9]{0,2}$/.test(days)) throw new Error("--npm-token-days must be a whole number of days");
69
+ o.npmTokenDays = Number(days);
70
+ } else if (a === "--config") o.configFile = value(++i, a);
71
+ else if (a === "--owner") {
72
+ const org = value(++i, a);
73
+ if (!GH_OWNER.test(org)) throw new Error(`--owner must be a lowercase GitHub owner name like "acme", got "${org}"`);
74
+ (o.owners ??= []).push(org);
75
+ } else if (a === "--installation-id") {
76
+ const pair = value(++i, a);
77
+ const eq = pair.indexOf("=");
78
+ const org = pair.slice(0, eq);
79
+ const id = pair.slice(eq + 1);
80
+ if (eq <= 0 || !GH_OWNER.test(org) || !/^[1-9][0-9]{0,14}$/.test(id)) throw new Error("--installation-id takes <owner>=<numeric id> (see --help)");
81
+ (o.installationIds ??= {})[org] = Number(id);
82
+ } else if (a === "--default-owner") {
83
+ const org = value(++i, a);
84
+ if (!GH_OWNER.test(org)) throw new Error(`--default-owner must be a lowercase GitHub owner name like "acme", got "${org}"`);
85
+ o.defaultOwner = org;
86
+ } else if (a === "--listen") {
87
+ const addr = value(++i, a);
88
+ if (!/^unix:\/.+/.test(addr)) throw new Error(`--listen must be a "unix:<absolute path>" address, got "${addr}"`);
89
+ o.listen = addr;
90
+ } else if (a === "--upstream") {
91
+ const url = value(++i, a);
92
+ if (!/^https?:\/\/\S+$/.test(url)) throw new Error(`--upstream must be an http(s) URL, got "${url}"`);
93
+ o.upstream = url;
94
+ } else if (a === "--app-id") {
95
+ const id = value(++i, a);
96
+ if (!/^[1-9][0-9]{0,14}$/.test(id)) throw new Error("--app-id must be the numeric GitHub App id");
97
+ o.appId = Number(id);
98
+ } else if (a === "--app-private-key") o.appPrivateKey = assertSafeCredName(value(++i, a).replace(/^cred:/, ""));
99
+ else if (a === "--secrets-path") {
100
+ const path = value(++i, a);
101
+ if (!path.startsWith("/")) throw new Error(`--secrets-path must be an absolute path, got "${path}"`);
102
+ o.secretsPath = path;
103
+ } else if (a === "--npm-registry") {
104
+ const url = value(++i, a);
105
+ if (!/^https:\/\/\S+$/.test(url)) throw new Error(`--npm-registry must be an https URL, got "${url}"`);
106
+ o.npmRegistry = url;
107
+ } else if (a === "--ghcr-owner") {
108
+ const org = value(++i, a);
109
+ if (!GH_OWNER.test(org)) throw new Error(`--ghcr-owner must be a lowercase GitHub owner name like "acme", got "${org}"`);
110
+ o.ghcrOwner = org;
111
+ } else if (a === "--ghcr-username") {
112
+ const login = value(++i, a);
113
+ if (!GH_LOGIN.test(login)) throw new Error(`--ghcr-username must be a GitHub login, got "${login}"`);
114
+ o.ghcrUsername = login;
115
+ } else if (a === "--ghcr-image") {
116
+ const img = value(++i, a);
117
+ if (img.length > 255 || !IMAGE_GLOB.test(img)) throw new Error(`--ghcr-image must be a lowercase image name or glob like "app" or "app-*", got "${img}"`);
118
+ (o.ghcrImages ??= []).push(img);
119
+ } else if (a === "--ghcr-drop-dir") {
120
+ const dir = value(++i, a);
121
+ if (!dir.startsWith("/")) throw new Error(`--ghcr-drop-dir must be an absolute path, got "${dir}"`);
122
+ o.ghcrDropDir = dir;
123
+ } else if (a === "--ghcr-max-archive-bytes") {
124
+ const n = value(++i, a);
125
+ if (!/^[1-9][0-9]{0,15}$/.test(n) || !Number.isSafeInteger(Number(n))) throw new Error("--ghcr-max-archive-bytes must be a positive whole number of bytes");
126
+ o.ghcrMaxArchiveBytes = Number(n);
127
+ } else if (a === "--protect-release-tags") o.protectReleaseTags = true;
128
+ else if (a === "--no-protect-release-tags") o.protectReleaseTags = false;
129
+ else if (a.startsWith("-")) throw new Error(`unknown flag: ${a} (see --help)`);
130
+ else o.positional.push(a);
131
+ }
132
+ if (o.provider !== undefined && o.provider !== "systemd-creds" && o.provider !== "file") {
133
+ throw new Error(`--secrets-provider must be "systemd-creds" or "file"`);
134
+ }
135
+ if (o.npmRegistry !== undefined && !o.npm) throw new Error("--npm-registry only applies together with --npm");
136
+ if (o.appPrivateKey !== undefined && o.appId === undefined) throw new Error("--app-private-key needs --app-id");
137
+ if (o.ghcrOwner !== undefined && (o.ghcrUsername === undefined || !o.ghcrImages)) {
138
+ throw new Error("--ghcr-owner needs --ghcr-username and at least one --ghcr-image");
139
+ }
140
+ if (o.ghcrOwner === undefined && (o.ghcrUsername !== undefined || o.ghcrImages)) {
141
+ throw new Error("--ghcr-username and --ghcr-image need --ghcr-owner (several GHCR owners go in a --config file)");
142
+ }
143
+ return o;
144
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ *
3
+ * @Project: @cldmv/gh-broker
4
+ * @Filename: /deploy/lib/npm-setup.mjs
5
+ * @Date: 2026-10-31T10:58:54-07:00 (1793469534)
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-31T12:58:17-07:00 (1793476697)
11
+ * -----
12
+ * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
+ *
14
+ */
15
+
16
+ /**
17
+ * Creates the npm granular access token npm_scaffold uses, the way the npm CLI would, so the
18
+ * installer can drive it end to end:
19
+ *
20
+ * 1. web login POST /-/v1/login → show the login URL, poll the done URL until approved
21
+ * (what `npm login --auth-type=web` does). Yields a short-lived session.
22
+ * 2. token create POST /-/npm/v1/tokens with the account password and an OTP, both
23
+ * prompted for and held only for that request (what `npm token create`
24
+ * does). Scope read-write, an expiry, and NO 2FA bypass, so the token
25
+ * cannot write anything without a fresh OTP.
26
+ * 3. cleanup DELETE /-/user/token/<session>, on every path (what `npm logout` does).
27
+ *
28
+ * Nothing here logs or returns the password, the OTP or the session token; the new token is
29
+ * returned to the caller, which stores it in the secret backend.
30
+ */
31
+
32
+ export const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
33
+ export const DEFAULT_TOKEN_DAYS = 90;
34
+ const MAX_ATTEMPTS = 3;
35
+
36
+ const isHttpsUrl = (u) => {
37
+ try {
38
+ return new URL(u).protocol === "https:";
39
+ } catch {
40
+ return false;
41
+ }
42
+ };
43
+
44
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
45
+
46
+ async function readJson(res) {
47
+ const text = await res.text();
48
+ try {
49
+ return JSON.parse(text);
50
+ } catch {
51
+ return { _raw: text.slice(0, 300) };
52
+ }
53
+ }
54
+
55
+ const detail = (body) => {
56
+ const msg = [body?.error, body?.message, body?.reason, body?._raw].find((v) => typeof v === "string" && v);
57
+ return msg ? `: ${msg.slice(0, 300)}` : "";
58
+ };
59
+
60
+ /** npm's own signal for "this request needs a (valid) OTP": a 401 naming otp. */
61
+ export function wantsOtp(res, body) {
62
+ if (res.status !== 401) return false;
63
+ const auth = (res.headers.get("www-authenticate") ?? "").toLowerCase();
64
+ return auth.includes("otp") || /one-time pass/i.test(JSON.stringify(body ?? ""));
65
+ }
66
+
67
+ /**
68
+ * @param {object} o
69
+ * @param {string} [o.registry]
70
+ * @param {string[]} o.scopes e.g. ["@cldmv"]
71
+ * @param {string[]} [o.orgs] npm orgs to also grant read-write on (org permission)
72
+ * @param {string} o.tokenName shown in the npm token list
73
+ * @param {number} [o.days] token lifetime
74
+ * @param {(q: string) => Promise<string>} o.promptHidden
75
+ * @param {(m: string) => void} o.log
76
+ * @param {typeof fetch} [o.fetch]
77
+ * @param {number} [o.pollTimeoutMs] how long to wait for the browser approval
78
+ * @returns {Promise<{ token: string, expires?: string }>}
79
+ */
80
+ export async function createNpmToken(o) {
81
+ const registry = (o.registry ?? DEFAULT_NPM_REGISTRY).replace(/\/+$/, "");
82
+ const doFetch = o.fetch ?? globalThis.fetch;
83
+ const days = o.days ?? DEFAULT_TOKEN_DAYS;
84
+ const session = await webLogin(registry, doFetch, o);
85
+ try {
86
+ return await createToken(registry, doFetch, session, { ...o, days });
87
+ } finally {
88
+ try {
89
+ const res = await doFetch(`${registry}/-/user/token/${encodeURIComponent(session)}`, {
90
+ method: "DELETE",
91
+ headers: { authorization: `Bearer ${session}`, "npm-command": "logout" }
92
+ });
93
+ o.log(res.status >= 200 && res.status < 300 ? " npm login session revoked" : ` note: could not revoke the npm login session (HTTP ${res.status}); it expires on its own`);
94
+ } catch (err) {
95
+ o.log(` note: could not revoke the npm login session (${err.message}); it expires on its own`);
96
+ }
97
+ }
98
+ }
99
+
100
+ async function webLogin(registry, doFetch, o) {
101
+ const res = await doFetch(`${registry}/-/v1/login`, {
102
+ method: "POST",
103
+ headers: { "content-type": "application/json", accept: "application/json", "npm-auth-type": "web", "npm-command": "login" },
104
+ body: "{}"
105
+ });
106
+ const body = await readJson(res);
107
+ if (res.status < 200 || res.status >= 300 || !isHttpsUrl(body.loginUrl) || !isHttpsUrl(body.doneUrl)) {
108
+ throw new Error(`npm web login could not start (HTTP ${res.status})${detail(body)}`);
109
+ }
110
+ o.log(` Log in to npm in a browser (as the account that owns ${o.scopes.join(", ")}):\n\n ${body.loginUrl}\n\n Waiting for the login to be approved…`);
111
+ const deadline = Date.now() + (o.pollTimeoutMs ?? 10 * 60_000);
112
+ for (;;) {
113
+ const poll = await doFetch(body.doneUrl, { method: "GET", headers: { accept: "application/json", "npm-command": "login" } });
114
+ const content = await readJson(poll);
115
+ if (poll.status === 200) {
116
+ if (typeof content.token !== "string" || !content.token) throw new Error("npm web login finished without a session");
117
+ o.log(" npm login approved");
118
+ return content.token;
119
+ }
120
+ if (poll.status !== 202) throw new Error(`npm web login failed (HTTP ${poll.status})${detail(content)}`);
121
+ if (Date.now() > deadline) throw new Error("npm web login timed out waiting for approval");
122
+ const retry = Number(poll.headers.get("retry-after"));
123
+ await sleep(Number.isFinite(retry) && retry > 0 ? retry * 1000 : 1000);
124
+ }
125
+ }
126
+
127
+ async function createToken(registry, doFetch, session, o) {
128
+ let password = await o.promptHidden(" npm account password (input hidden, used once, not stored): ");
129
+ let otp = await o.promptHidden(" npm one-time password (input hidden): ");
130
+ const request = {
131
+ name: o.tokenName,
132
+ scopes: o.scopes,
133
+ packages_and_scopes_permission: "read-write",
134
+ ...(o.orgs?.length ? { orgs: o.orgs, orgs_permission: "read-write" } : {}),
135
+ expires: o.days
136
+ };
137
+ for (let attempt = 1; ; attempt++) {
138
+ const res = await doFetch(`${registry}/-/npm/v1/tokens`, {
139
+ method: "POST",
140
+ headers: {
141
+ authorization: `Bearer ${session}`,
142
+ "content-type": "application/json",
143
+ accept: "application/json",
144
+ "npm-command": "token",
145
+ ...(otp ? { "npm-otp": otp } : {})
146
+ },
147
+ body: JSON.stringify({ ...request, password })
148
+ });
149
+ const body = await readJson(res);
150
+ if (res.status >= 200 && res.status < 300) {
151
+ if (typeof body.token !== "string" || !body.token) throw new Error("npm created a token but did not return it");
152
+ return { token: body.token, ...(typeof body.expires === "string" ? { expires: body.expires } : {}) };
153
+ }
154
+ if (attempt >= MAX_ATTEMPTS) throw new Error(`npm token creation failed (HTTP ${res.status})${detail(body)}`);
155
+ if (wantsOtp(res, body)) {
156
+ otp = await o.promptHidden(" npm rejected the one-time password (expired?). New one-time password (input hidden): ");
157
+ } else if (res.status === 401 || res.status === 403) {
158
+ password = await o.promptHidden(" npm rejected the password. npm account password (input hidden): ");
159
+ otp = await o.promptHidden(" npm one-time password (input hidden): ");
160
+ } else {
161
+ throw new Error(`npm token creation failed (HTTP ${res.status})${detail(body)}`);
162
+ }
163
+ }
164
+ }
@@ -7,7 +7,7 @@
7
7
  * @Email: <Shinrai@users.noreply.github.com>
8
8
  * -----
9
9
  * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
10
- * @Last modified time: 2026-10-02T11:30:58-07:00 (1790965858)
10
+ * @Last modified time: 2026-10-31T12:12:11-07:00 (1793473931)
11
11
  * -----
12
12
  * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
13
  *
@@ -77,7 +77,7 @@ export function assertSafeCredName(name) {
77
77
  }
78
78
 
79
79
  /**
80
- * The distinct cred names a config's owners + app reference (strips the "cred:"
80
+ * The distinct cred names a config's owners, app and ghcr owners reference (strips the "cred:"
81
81
  * prefix). Validates each name — this is the single source feeding the encrypted
82
82
  * `.cred` filenames and the systemd drop-in, so hardening here covers both.
83
83
  */
@@ -91,9 +91,27 @@ export function referencedCredNames(config) {
91
91
  add(owner.write);
92
92
  }
93
93
  if (config.app) add(config.app.privateKey);
94
+ if (config.npm) add(config.npm.token);
95
+ for (const owner of Object.values(config.ghcr?.owners ?? {})) add(owner.credential);
94
96
  return [...names];
95
97
  }
96
98
 
99
+ /**
100
+ * The referenced cred names that are OPTIONAL: the broker hides the tools that need them
101
+ * when they aren't stored (src/availability.ts) instead of refusing to start, so the
102
+ * installer may skip them: the npm token and the GHCR credentials. The owners' PATs and
103
+ * the App key stay required.
104
+ */
105
+ export function optionalCredNames(config) {
106
+ const names = [];
107
+ const add = (ref) => {
108
+ if (typeof ref === "string" && ref.startsWith("cred:")) names.push(assertSafeCredName(ref.slice("cred:".length)));
109
+ };
110
+ add(config.npm?.token);
111
+ for (const owner of Object.values(config.ghcr?.owners ?? {})) add(owner.credential);
112
+ return names;
113
+ }
114
+
97
115
  /**
98
116
  * Works out which secrets backend an EXISTING install already uses, so a re-run keeps it
99
117
  * instead of asking again. Precedence: the provider recorded in the deployed config, then
@@ -7,14 +7,14 @@
7
7
  * @Email: <Shinrai@users.noreply.github.com>
8
8
  * -----
9
9
  * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
10
- * @Last modified time: 2026-10-02T11:30:58-07:00 (1790965858)
10
+ * @Last modified time: 2026-10-31T23:26:29-07:00 (1793514389)
11
11
  * -----
12
12
  * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
13
  *
14
14
  */
15
15
 
16
16
  import { spawnSync } from "node:child_process";
17
- import { existsSync, mkdirSync, chmodSync, chownSync, writeFileSync, cpSync, rmSync, symlinkSync, realpathSync } from "node:fs";
17
+ import { existsSync, mkdirSync, chmodSync, chownSync, lstatSync, writeFileSync, cpSync, rmSync, symlinkSync, realpathSync } from "node:fs";
18
18
  import { join, dirname } from "node:path";
19
19
 
20
20
  /** spawnSync wrapper: throws on non-zero unless opts.allowFail. Returns the result. */
@@ -171,9 +171,33 @@ export function currentNodeBin() {
171
171
  function mkdirp(path, mode, uid, gid) {
172
172
  if (!existsSync(path)) mkdirSync(path, { recursive: true, mode });
173
173
  chmodSync(path, mode);
174
+ // Every caller passes a resolved uid and gid (requireUid/requireGid throw otherwise), so the skipped-chown branch is unreachable.
175
+ /* v8 ignore else */
174
176
  if (uid !== undefined && gid !== undefined) chownSync(path, uid, gid);
175
177
  }
176
178
 
179
+ /**
180
+ * Create the GHCR drop directory: `2750 <agentUser>:<svcGroup>`. The agent (owner) writes
181
+ * archives there; the setgid bit gives every new file the service group, so the broker can
182
+ * read a group-readable archive while other users can't see in. A missing parent is created
183
+ * `0755 root:root`; an existing parent is never chmod-ed. Refuses a symlink at the path.
184
+ */
185
+ export function ensureDropDir(path, agentUser, svcGroup) {
186
+ const parent = dirname(path);
187
+ if (!existsSync(parent)) mkdirSync(parent, { recursive: true, mode: 0o755 });
188
+ let st;
189
+ try {
190
+ st = lstatSync(path); // lstat: a symlink (even a dangling one) is seen as itself
191
+ } catch {
192
+ st = undefined;
193
+ }
194
+ if (!st) mkdirSync(path, { mode: 0o750 });
195
+ else if (st.isSymbolicLink()) throw new Error(`gh-broker install: ${path} is a symlink — refusing to use it as the GHCR drop dir`);
196
+ else if (!st.isDirectory()) throw new Error(`gh-broker install: ${path} exists and is not a directory`);
197
+ chownSync(path, requireUid(agentUser), requireGid(svcGroup));
198
+ chmodSync(path, 0o2750); // after chown: chown clears the setgid bit
199
+ }
200
+
177
201
  /** Create /etc/gh-broker (0750 root:group) and /opt/gh-broker (0755 svcUser:group). */
178
202
  export function ensureDirs(configDir, appDir, svcUser, svcGroup) {
179
203
  const gid = requireGid(svcGroup);