@cldmv/gh-broker 0.0.0-stage → 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 (104) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +279 -2
  3. package/config.example.json +294 -0
  4. package/config.sample.json +45 -0
  5. package/deploy/README.md +150 -0
  6. package/deploy/gh-broker.service +57 -0
  7. package/deploy/install.mjs +409 -0
  8. package/deploy/install.sh +29 -0
  9. package/deploy/lib/config-merge.mjs +214 -0
  10. package/deploy/lib/credentials.mjs +143 -0
  11. package/deploy/lib/install-args.mjs +144 -0
  12. package/deploy/lib/npm-setup.mjs +164 -0
  13. package/deploy/lib/prompt.mjs +98 -0
  14. package/deploy/lib/secret-store.mjs +132 -0
  15. package/deploy/lib/smoke.mjs +137 -0
  16. package/deploy/lib/system.mjs +246 -0
  17. package/dist/index.mjs +24406 -0
  18. package/dist/index.mjs.map +7 -0
  19. package/docs/TOOLS.md +538 -0
  20. package/docs/agent-user-model.md +89 -0
  21. package/docs/changelog/v0/v0.2.0.md +26 -0
  22. package/docs/changelog/v0/v0.2.1.md +23 -0
  23. package/docs/changelog/v0/v0.2.2.md +23 -0
  24. package/docs/changelog/v0/v0.2.3.md +33 -0
  25. package/docs/changelog/v0/v0.2.4.md +25 -0
  26. package/docs/changelog/v0/v0.2.5.md +29 -0
  27. package/docs/changelog/v0/v0.3.0.md +84 -0
  28. package/docs/changelog/v0/v0.3.1.md +30 -0
  29. package/docs/changelog/v0/v0.3.2.md +24 -0
  30. package/docs/changelog/v0/v0.3.3.md +28 -0
  31. package/docs/changelog/v0/v0.4.0.md +58 -0
  32. package/docs/changelog/v0/v0.4.1.md +33 -0
  33. package/docs/changelog/v0/v0.4.2.md +45 -0
  34. package/docs/changelog/v0/v0.5.0.md +39 -0
  35. package/docs/changelog/v0/v0.5.1.md +28 -0
  36. package/docs/changelog/v0/v0.6.0.md +52 -0
  37. package/docs/changelog/v0/v0.6.1.md +48 -0
  38. package/docs/changelog/v0/v0.7.0.md +71 -0
  39. package/docs/changelog/v0/v0.8.0.md +115 -0
  40. package/docs/deploy-architecture.md +84 -0
  41. package/package.json +71 -5
  42. package/src/appauth.ts +56 -0
  43. package/src/audit.ts +34 -0
  44. package/src/availability.ts +42 -0
  45. package/src/catalog.ts +182 -0
  46. package/src/config.ts +157 -0
  47. package/src/index.ts +107 -0
  48. package/src/paths.ts +60 -0
  49. package/src/router.ts +299 -0
  50. package/src/secrets.ts +167 -0
  51. package/src/server.ts +91 -0
  52. package/src/types.ts +128 -0
  53. package/src/upstream.ts +85 -0
  54. package/src/verbs/ghcr/archive.ts +314 -0
  55. package/src/verbs/ghcr/config.ts +142 -0
  56. package/src/verbs/ghcr/jobs.ts +141 -0
  57. package/src/verbs/ghcr/push.ts +192 -0
  58. package/src/verbs/ghcr/registry.ts +275 -0
  59. package/src/verbs/ghcr/tar.ts +203 -0
  60. package/src/verbs/ghcr_push.ts +264 -0
  61. package/src/verbs/issue_labels.ts +329 -0
  62. package/src/verbs/link_issue.ts +84 -0
  63. package/src/verbs/npm-registry.ts +168 -0
  64. package/src/verbs/npm-tarball.ts +76 -0
  65. package/src/verbs/npm_scaffold.ts +325 -0
  66. package/src/verbs/release-analysis.ts +212 -0
  67. package/src/verbs/repo-tier.ts +90 -0
  68. package/src/verbs/report_ci_status.ts +358 -0
  69. package/src/verbs/report_release_prs.ts +238 -0
  70. package/src/verbs/report_security_alerts.ts +442 -0
  71. package/src/verbs/report_unlabeled.ts +194 -0
  72. package/src/verbs/rest-client.ts +181 -0
  73. package/src/verbs/review_thread.ts +138 -0
  74. package/tests/appauth.test.vitest.mts +76 -0
  75. package/tests/audit.test.vitest.mts +48 -0
  76. package/tests/catalog.test.vitest.mts +234 -0
  77. package/tests/config-merge.test.vitest.mts +325 -0
  78. package/tests/config.test.vitest.mts +388 -0
  79. package/tests/credentials.test.vitest.mts +127 -0
  80. package/tests/ghcr-archive.test.vitest.mts +439 -0
  81. package/tests/ghcr-config.test.vitest.mts +96 -0
  82. package/tests/ghcr-push.test.vitest.mts +526 -0
  83. package/tests/ghcr-registry.test.vitest.mts +206 -0
  84. package/tests/helpers/ghcr-fixtures.mts +373 -0
  85. package/tests/install-args.test.vitest.mts +152 -0
  86. package/tests/issue-labels.test.vitest.mts +362 -0
  87. package/tests/npm-scaffold.test.vitest.mts +479 -0
  88. package/tests/npm-setup.test.vitest.mts +196 -0
  89. package/tests/paths.test.vitest.mts +57 -0
  90. package/tests/prompt.test.vitest.mts +128 -0
  91. package/tests/release-prs.test.vitest.mts +247 -0
  92. package/tests/rest-client.test.vitest.mts +115 -0
  93. package/tests/router.test.vitest.mts +505 -0
  94. package/tests/run-vitest.mjs +51 -0
  95. package/tests/secret-store.test.vitest.mts +167 -0
  96. package/tests/secrets.test.vitest.mts +208 -0
  97. package/tests/security-alerts.test.vitest.mts +338 -0
  98. package/tests/server.test.vitest.mts +200 -0
  99. package/tests/smoke.test.vitest.mts +177 -0
  100. package/tests/system.test.vitest.mts +355 -0
  101. package/tests/upstream.test.vitest.mts +91 -0
  102. package/tests/verb-helpers.test.vitest.mts +377 -0
  103. package/tests/verbs.test.vitest.mts +624 -0
  104. package/tsconfig.json +21 -0
@@ -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
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ *
3
+ * @Project: @cldmv/gh-broker
4
+ * @Filename: /deploy/lib/prompt.mjs
5
+ * @Date: 2026-09-06T10:05:35-07:00 (1788714335)
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
+ import { stdin, stdout } from "node:process";
17
+
18
+ // Control codes, by number, so no literal control characters live in this file.
19
+ const LF = 10; // Enter
20
+ const CR = 13; // Enter
21
+ const EOT = 4; // Ctrl-D
22
+ const ETX = 3; // Ctrl-C
23
+ const DEL = 127; // Backspace
24
+ const BS = 8; // Backspace
25
+
26
+ /**
27
+ * Raw-mode line reader. `echo` controls whether typed characters are shown:
28
+ * visible prompts echo them, secret prompts don't. Handles Enter, Ctrl-C (abort),
29
+ * Ctrl-D, and Backspace. Falls back to non-raw when stdin isn't a TTY.
30
+ */
31
+ function readLine(question, echo) {
32
+ return new Promise((resolve, reject) => {
33
+ stdout.write(question);
34
+ let input = "";
35
+ const onData = (chunk) => {
36
+ const s = chunk.toString("utf8");
37
+ for (const ch of s) {
38
+ const code = ch.charCodeAt(0);
39
+ if (code === LF || code === CR || code === EOT) {
40
+ cleanup();
41
+ stdout.write("\n");
42
+ return resolve(input.trim());
43
+ }
44
+ if (code === ETX) {
45
+ cleanup();
46
+ stdout.write("\n");
47
+ return reject(new Error("aborted"));
48
+ }
49
+ if (code === DEL || code === BS) {
50
+ if (input.length > 0) {
51
+ input = input.slice(0, -1);
52
+ if (echo) stdout.write("\b \b");
53
+ }
54
+ continue;
55
+ }
56
+ input += ch;
57
+ if (echo) stdout.write(ch);
58
+ }
59
+ };
60
+ const cleanup = () => {
61
+ stdin.removeListener("data", onData);
62
+ if (stdin.isTTY) stdin.setRawMode(false);
63
+ stdin.pause();
64
+ };
65
+ if (stdin.isTTY) stdin.setRawMode(true);
66
+ stdin.resume();
67
+ stdin.on("data", onData);
68
+ });
69
+ }
70
+
71
+ /** Ask a visible question; typed characters are echoed. Returns the trimmed answer. */
72
+ export function promptLine(question) {
73
+ return readLine(question, true);
74
+ }
75
+
76
+ /** Ask for a secret; typed characters are NOT echoed. Never prints the value. */
77
+ export function promptHidden(question) {
78
+ return readLine(question, false);
79
+ }
80
+
81
+ /** Yes/no confirmation. Defaults to no unless the answer starts with y/Y. */
82
+ export async function confirm(question) {
83
+ const a = await promptLine(`${question} [y/N] `);
84
+ return /^y(es)?$/i.test(a);
85
+ }
86
+
87
+ /**
88
+ * Present numbered options ({ key, label }) and return the chosen key.
89
+ * Empty input picks the 1-based default `def`.
90
+ */
91
+ export async function choose(question, options, def = 1) {
92
+ stdout.write(`${question}\n`);
93
+ options.forEach((o, i) => stdout.write(` ${i + 1}) ${o.label}\n`));
94
+ const a = await promptLine(`Choose [${def}]: `);
95
+ const n = a === "" ? def : parseInt(a, 10);
96
+ const idx = Number.isInteger(n) && n >= 1 && n <= options.length ? n - 1 : def - 1;
97
+ return options[idx].key;
98
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ *
3
+ * @Project: @cldmv/gh-broker
4
+ * @Filename: /deploy/lib/secret-store.mjs
5
+ * @Date: 2026-09-06T10:05:35-07:00 (1788714335)
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:12:11-07:00 (1793473931)
11
+ * -----
12
+ * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
+ *
14
+ */
15
+
16
+ import { writeFileSync, chmodSync, chownSync, readFileSync, existsSync, statSync, rmSync } from "node:fs";
17
+
18
+ /**
19
+ * Write the secrets map ({ "<name>": "<token>" }) to `path` as JSON, mode 0640,
20
+ * owned by uid:gid (root:<service-group>) so the service user reads it and the
21
+ * agent user (not in that group, dir 0750) cannot. The file provider counterpart
22
+ * to src/secrets.ts. Never logs a value.
23
+ */
24
+ export function writeSecretsFile(path, secrets, { uid = 0, gid, mode = 0o640 } = {}) {
25
+ const json = JSON.stringify(secrets, null, "\t") + "\n";
26
+ writeFileSync(path, json, { mode });
27
+ chmodSync(path, mode);
28
+ if (gid !== undefined) chownSync(path, uid, gid);
29
+ }
30
+
31
+ /**
32
+ * Read an existing secrets file so an upgrade preserves tokens the operator isn't
33
+ * re-entering. Returns {} if the file is absent or unreadable.
34
+ */
35
+ export function readSecretsFile(path) {
36
+ if (!existsSync(path)) return {};
37
+ try {
38
+ const v = JSON.parse(readFileSync(path, "utf8"));
39
+ if (!v || typeof v !== "object" || Array.isArray(v)) return {};
40
+ // Keep only non-empty string values — a non-string (number/object) must never
41
+ // be treated as a token (it would crash encryption or be written back invalid).
42
+ const out = {};
43
+ for (const [name, val] of Object.entries(v)) if (typeof val === "string" && val.length > 0) out[name] = val;
44
+ return out;
45
+ } catch {
46
+ return {};
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Remove a plaintext secrets file — used when the operator switches to the
52
+ * systemd-creds provider, so the tokens don't linger on disk and defeat
53
+ * "encrypted at rest". Best-effort zero-overwrite before unlink. No-op if absent.
54
+ */
55
+ export function removeSecretsFile(path) {
56
+ if (!existsSync(path)) return;
57
+ try {
58
+ writeFileSync(path, Buffer.alloc(statSync(path).size, 0));
59
+ } catch {
60
+ // overwrite is best-effort; unlink still happens below
61
+ }
62
+ rmSync(path, { force: true });
63
+ }
64
+
65
+ const SAFE_CRED_NAME = /^[A-Za-z0-9._-]+$/;
66
+
67
+ /**
68
+ * Constrain a credential name to a safe charset — it becomes a `.cred` filename
69
+ * and is interpolated into a systemd drop-in line written as root, so a name with
70
+ * path separators, "..", colons, or control characters must be rejected.
71
+ */
72
+ export function assertSafeCredName(name) {
73
+ if (name === "." || name === ".." || !SAFE_CRED_NAME.test(name)) {
74
+ throw new Error(`gh-broker: unsafe credential name "${name}" (allowed: letters, digits, ".", "_", "-")`);
75
+ }
76
+ return name;
77
+ }
78
+
79
+ /**
80
+ * The distinct cred names a config's owners, app and ghcr owners reference (strips the "cred:"
81
+ * prefix). Validates each name — this is the single source feeding the encrypted
82
+ * `.cred` filenames and the systemd drop-in, so hardening here covers both.
83
+ */
84
+ export function referencedCredNames(config) {
85
+ const names = new Set();
86
+ const add = (ref) => {
87
+ if (typeof ref === "string" && ref.startsWith("cred:")) names.add(assertSafeCredName(ref.slice("cred:".length)));
88
+ };
89
+ for (const owner of Object.values(config.owners ?? {})) {
90
+ add(owner.read);
91
+ add(owner.write);
92
+ }
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);
96
+ return [...names];
97
+ }
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
+
115
+ /**
116
+ * Works out which secrets backend an EXISTING install already uses, so a re-run keeps it
117
+ * instead of asking again. Precedence: the provider recorded in the deployed config, then
118
+ * what is actually on disk (encrypted `.cred` files → systemd-creds; tokens in the secrets
119
+ * file → file). Returns undefined when it is a first install, or when both backends have
120
+ * credentials and nothing is recorded (ambiguous — the caller should ask).
121
+ *
122
+ * @param {{ configured?: unknown, credNames?: string[], fileTokenNames?: string[] }} found
123
+ * @returns {"systemd-creds" | "file" | undefined}
124
+ */
125
+ export function detectSecretsProvider({ configured, credNames = [], fileTokenNames = [] } = {}) {
126
+ if (configured === "systemd-creds" || configured === "file") return configured;
127
+ const hasCreds = credNames.length > 0;
128
+ const hasFile = fileTokenNames.length > 0;
129
+ if (hasCreds && !hasFile) return "systemd-creds";
130
+ if (hasFile && !hasCreds) return "file";
131
+ return undefined;
132
+ }