@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
@@ -8,7 +8,7 @@
8
8
  * @Email: <Shinrai@users.noreply.github.com>
9
9
  * -----
10
10
  * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
11
- * @Last modified time: 2026-10-02T11:30:58-07:00 (1790965858)
11
+ * @Last modified time: 2026-10-31T12:58:15-07:00 (1793476695)
12
12
  * -----
13
13
  * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
14
14
  *
@@ -20,7 +20,7 @@
20
20
  * sudo node deploy/install.mjs [agent-user] [flags]
21
21
  *
22
22
  * Prerequisite: node >= 20. Idempotent: re-run to upgrade (config regenerated by
23
- * merge, existing secrets kept). Interactive by default; pass flags plus
23
+ * layering, existing secrets kept). Interactive by default; pass flags plus
24
24
  * --non-interactive for a single unattended command. See --help and
25
25
  * docs/deploy-architecture.md.
26
26
  */
@@ -32,12 +32,16 @@ import {
32
32
  ensureServiceUser, createAgentUser, addUserToGroup,
33
33
  pointNodeSymlinks, currentNodeBin, ensureDirs, installUnit,
34
34
  enableService, restartService, serviceActive, deployDist, writeConfigFile,
35
- trySystemdCredDecrypt, systemdCredsEncrypt, ensureCredsDir, writeCredsDropin, removeCredsDropin
35
+ trySystemdCredDecrypt, systemdCredsEncrypt, ensureCredsDir, writeCredsDropin, removeCredsDropin, ensureDropDir
36
36
  } from "./lib/system.mjs";
37
37
  import { promptLine, promptHidden, confirm, choose } from "./lib/prompt.mjs";
38
- import { mergeConfig } from "./lib/config-merge.mjs";
39
- import { writeSecretsFile, readSecretsFile, removeSecretsFile, referencedCredNames, detectSecretsProvider } from "./lib/secret-store.mjs";
38
+ import { buildConfig, readOperatorConfig, secretsBlock } from "./lib/config-merge.mjs";
39
+ import { writeSecretsFile, readSecretsFile, removeSecretsFile, referencedCredNames, optionalCredNames, detectSecretsProvider } from "./lib/secret-store.mjs";
40
+ import { parseArgs, NPM_SCOPE } from "./lib/install-args.mjs";
41
+ import { credentialPlan } from "./lib/credentials.mjs";
40
42
  import { smokeTest } from "./lib/smoke.mjs";
43
+ import { createNpmToken } from "./lib/npm-setup.mjs";
44
+ import { hostname } from "node:os";
41
45
 
42
46
  const REPO = dirname(dirname(fileURLToPath(import.meta.url)));
43
47
  const SVC_USER = "gh-broker";
@@ -71,52 +75,74 @@ Flags:
71
75
  (also positional; defaults to $SUDO_USER)
72
76
  --secrets-provider <p> systemd-creds | file (skips the prompt)
73
77
  --secrets-file <path> JSON { "<cred-name>": "<token>" } to import tokens from
78
+ --secret <name>=<tok> one token on the command line (repeatable). Visible in
79
+ the process list and shell history: prefer the file or env
74
80
  --create-agent-user create the agent user if missing (no confirm)
81
+ --npm set up the npm integration (npm_scaffold), or rotate its
82
+ token: walks through an npm web login and creates the token
83
+ --npm-scope <@scope> scope npm_scaffold may create packages in (repeatable;
84
+ default: the existing setting, else @<first owner>)
85
+ --npm-org <org> also grant the new npm token org read-write on <org>
86
+ (repeatable; default: the existing setting, else none)
87
+ --npm-token-days <n> lifetime of a newly created npm token (default 90)
75
88
  -y, --non-interactive never prompt; fail if a value/decision is missing
89
+
90
+ Config (layered, lowest to highest: shipped defaults, the deployed config, --config,
91
+ these flags). An upgrade run with none of them keeps the deployed owners and ghcr as is.
92
+ --config <file> JSON config file in the deployed config's shape, merged on top
93
+ of the deployed one. Holds only "cred:" refs, never tokens;
94
+ no "tools" (the allowlist ships with the code)
95
+ --owner <org> GitHub owner (user or org) to serve; reads use cred:<org>-read,
96
+ writes cred:<org>-write (repeatable). A first install with no
97
+ owner asks for one, or fails under --non-interactive
98
+ --installation-id <org>=<id>
99
+ GitHub App installation id for an owner (repeatable)
100
+ --default-owner <org> owner whose read token serves calls that name no owner
101
+ (a first install with a single owner defaults to it)
102
+ --listen <unix:path> socket the broker listens on
103
+ --upstream <url> GitHub hosted MCP base URL
104
+ --app-id <n> GitHub App id (App-token-only verbs)
105
+ --app-private-key <cred-name>
106
+ credential holding the App private key (default gh-app-key)
107
+ --secrets-path <path> secrets file location for --secrets-provider file
108
+ --npm-registry <url> npm registry for npm_scaffold (https; with --npm)
109
+ --ghcr-owner <org> enable GHCR pushes for <org>; its classic PAT is cred:<org>-packages
110
+ --ghcr-username <login> GitHub login that owns that PAT (needs --ghcr-owner)
111
+ --ghcr-image <glob> image name or glob the owner may push, e.g. app or app-* (repeatable)
112
+ --ghcr-drop-dir <path> directory archives are read from (default /srv/gh-broker/ghcr-drop)
113
+ --ghcr-max-archive-bytes <n>
114
+ largest archive accepted (default 16 GiB)
115
+ --protect-release-tags, --no-protect-release-tags
116
+ refuse (or allow) overwriting 1.2.3 / v1.2.3 tags (default: refuse)
117
+ Several GHCR owners, or any other detail, go in a --config file.
76
118
  -h, --help this help
77
119
 
78
- Non-interactive credentials: for each not already stored, the token is taken from
79
- --secrets-file, then env GH_BROKER_SECRET_<NAME> (NAME upper-cased, non-alnum -> _).
120
+ Credentials: each token comes from, in order, --secret, --secrets-file, env
121
+ GH_BROKER_SECRET_<NAME> (NAME upper-cased, non-alnum -> _), and otherwise an
122
+ interactive prompt (input hidden). A token supplied by any of the first three
123
+ replaces a stored one (rotation). Optional tokens (npm-token) can be skipped.
80
124
  Example (unattended):
81
- sudo GH_BROKER_SECRET_CLDMV_READ=… GH_BROKER_SECRET_CLDMV_WRITE=… \\
82
- /usr/local/bin/node deploy/install.mjs --agent-user repo \\
125
+ sudo GH_BROKER_SECRET_ACME_READ=… GH_BROKER_SECRET_ACME_WRITE=… \\
126
+ /usr/local/bin/node deploy/install.mjs --agent-user repo --owner acme \\
83
127
  --secrets-provider systemd-creds --non-interactive`;
84
128
 
85
- function parseArgs(argv) {
86
- const o = { positional: [], nonInteractive: false, createAgentUser: false };
87
- // Consume the value after a value-taking flag, failing if it's missing or is
88
- // itself a flag — so `--agent-user --secrets-provider file` can't silently take
89
- // "--secrets-provider" as the username and proceed with an unintended user.
90
- const value = (i, flag) => {
91
- const v = argv[i];
92
- if (v === undefined || v.startsWith("-")) die(`${flag} requires a value (see --help)`);
93
- return v;
94
- };
95
- for (let i = 0; i < argv.length; i++) {
96
- const a = argv[i];
97
- if (a === "-h" || a === "--help") o.help = true;
98
- else if (a === "-y" || a === "--non-interactive") o.nonInteractive = true;
99
- else if (a === "--create-agent-user") o.createAgentUser = true;
100
- else if (a === "--agent-user") o.agentUser = value(++i, a);
101
- else if (a === "--secrets-provider") o.provider = value(++i, a);
102
- else if (a === "--secrets-file") o.secretsFile = value(++i, a);
103
- else if (a.startsWith("-")) die(`unknown flag: ${a} (see --help)`);
104
- else o.positional.push(a);
129
+ const NPM_TOKEN_REF = "cred:npm-token";
130
+
131
+ /** parseArgs with installer-style failure (the parser itself throws). */
132
+ function parseArgsOrDie(argv) {
133
+ try {
134
+ return parseArgs(argv);
135
+ } catch (err) {
136
+ die(err.message);
105
137
  }
106
- return o;
107
138
  }
108
139
 
109
- const envSecretKey = (name) => "GH_BROKER_SECRET_" + name.toUpperCase().replace(/[^A-Z0-9]/g, "_");
110
-
111
140
  async function main() {
112
- const opts = parseArgs(process.argv.slice(2));
141
+ const opts = parseArgsOrDie(process.argv.slice(2));
113
142
  if (opts.help) {
114
143
  log(HELP);
115
144
  return;
116
145
  }
117
- if (opts.provider && opts.provider !== "systemd-creds" && opts.provider !== "file") {
118
- die(`--secrets-provider must be "systemd-creds" or "file"`);
119
- }
120
146
  const importTokens = opts.secretsFile ? readJson(opts.secretsFile) : {};
121
147
  // Must be a plain JSON object of { "<name>": "<token>" } — an array parses as an
122
148
  // "object" but yields no tokens, so reject it explicitly rather than importing nothing.
@@ -124,6 +150,15 @@ async function main() {
124
150
  die(`--secrets-file: ${opts.secretsFile} must be a JSON object of { "<name>": "<token>" }`);
125
151
  }
126
152
 
153
+ let operatorFile;
154
+ if (opts.configFile) {
155
+ try {
156
+ operatorFile = readOperatorConfig(opts.configFile, (f) => readFileSync(f, "utf8"));
157
+ } catch (err) {
158
+ die(err.message);
159
+ }
160
+ }
161
+
127
162
  if (!isRoot()) die("must run as root: sudo node deploy/install.mjs [agent-user] [flags]");
128
163
  if (process.platform !== "linux") {
129
164
  die(`only Linux is implemented today (platform: ${process.platform}). The path/secret/service seams are ready; the impls are not.`);
@@ -196,15 +231,53 @@ async function main() {
196
231
  log(` node symlink -> ${currentNodeBin()} (${linked.join(", ")})`);
197
232
 
198
233
  // --- config: merge(example, current) ---
199
- step("config (merge example + current)");
234
+ step("config (example + deployed + --config + flags)");
200
235
  const example = readJson(join(REPO, "config.example.json"));
201
236
  if (!example) die("config.example.json missing or invalid");
202
237
  const current = readJson(paths.config); // null on first install
203
- const merged = mergeConfig(example, current);
238
+ let merged;
239
+ try {
240
+ const built = await buildConfig({ example, current, file: operatorFile, opts, nonInteractive: opts.nonInteractive, ask: promptLine });
241
+ merged = built.config;
242
+ for (const n of built.notes) log(` ${n}`);
243
+ } catch (err) {
244
+ die(err.message);
245
+ }
246
+ if (merged.ghcr?.dropDir) {
247
+ ensureDropDir(merged.ghcr.dropDir, agentUser, SVC_GROUP);
248
+ log(` ghcr drop dir: ${merged.ghcr.dropDir} (2750 ${agentUser}:${SVC_GROUP})`);
249
+ }
250
+
251
+ // --- npm integration (npm_scaffold): opt-in ---
252
+ // Offered on a first interactive install; afterwards `--npm` enables it or, when it is
253
+ // already enabled, rotates the token. Either way a new token is created by driving the
254
+ // npm web login + token creation from here (see lib/npm-setup.mjs), unless the operator
255
+ // supplied one through --secrets-file/env.
256
+ step("npm integration");
257
+ let npmMint = false;
258
+ if (opts.npm || (!merged.npm && current === null && !opts.nonInteractive && (await confirm("Enable npm_scaffold (create new npm packages and their trusted publishers)?")))) {
259
+ let scopes = opts.npmScopes.length ? opts.npmScopes : merged.npm?.scopes;
260
+ if (!scopes?.length) {
261
+ const fallback = `@${Object.keys(merged.owners ?? {})[0] ?? ""}`.toLowerCase();
262
+ const answer = opts.nonInteractive ? fallback : (await promptLine(` npm scope(s) to allow, comma-separated [${fallback}]: `)) || fallback;
263
+ scopes = answer.split(",").map((s) => s.trim()).filter(Boolean);
264
+ }
265
+ for (const s of scopes) if (!NPM_SCOPE.test(s)) die(`npm scope must be a lowercase npm scope like "@acme", got "${s}"`);
266
+ const tokenOrgs = opts.npmOrgs.length ? opts.npmOrgs : (merged.npm?.tokenOrgs ?? []);
267
+ merged.npm = { ...(merged.npm ?? {}), token: merged.npm?.token ?? NPM_TOKEN_REF, scopes, ...(opts.npmRegistry ? { registry: opts.npmRegistry } : {}) };
268
+ if (tokenOrgs.length) merged.npm.tokenOrgs = tokenOrgs;
269
+ else delete merged.npm.tokenOrgs;
270
+ npmMint = true;
271
+ log(` ${current?.npm ? "rotating the npm token" : "enabling"} for ${scopes.join(", ")}${tokenOrgs.length ? ` (plus org read-write on ${tokenOrgs.join(", ")})` : ""}`);
272
+ } else {
273
+ log(merged.npm ? ` enabled for ${merged.npm.scopes.join(", ")} (rotate the token with --npm)` : " not enabled (enable with --npm)");
274
+ }
275
+ const npmTokenName = merged.npm?.token?.startsWith("cred:") ? merged.npm.token.slice("cred:".length) : undefined;
204
276
 
205
277
  // --- secrets: operator chooses the backend, then tokens are stored in it ---
206
278
  step("secrets");
207
279
  const needed = referencedCredNames(merged);
280
+ const optional = new Set(optionalCredNames(merged));
208
281
  const existingFile = readSecretsFile(paths.secrets); // {} if none
209
282
  const credsDir = join(configDir, "creds"); // systemd-creds location
210
283
  const priorProvider = merged.secrets?.provider ?? "systemd-creds";
@@ -237,77 +310,69 @@ async function main() {
237
310
  );
238
311
  }
239
312
  log(` backend: ${provider}`);
240
- merged.secrets = { provider, ...(merged.secrets?.path ? { path: merged.secrets.path } : {}) };
313
+ try {
314
+ merged.secrets = secretsBlock(provider, merged.secrets);
315
+ } catch (err) {
316
+ die(err.message);
317
+ }
241
318
 
242
319
  // The App private key (if config.app is ever set) is a multi-line PEM the
243
320
  // single-line prompt can't accept — it must be supplied out-of-band.
244
321
  const appKeyName = merged.app?.privateKey?.startsWith("cred:") ? merged.app.privateKey.slice("cred:".length) : undefined;
245
322
 
246
- // A token the operator EXPLICITLY supplied for this run — --secrets-file, then
247
- // env GH_BROKER_SECRET_<NAME>. Its presence is what makes a re-run ROTATE an
248
- // existing credential rather than keep it. Only non-empty strings count.
249
- const explicitToken = (name) => {
250
- const fromImport = importTokens && importTokens[name];
251
- if (typeof fromImport === "string" && fromImport) return fromImport;
252
- const fromEnv = process.env[envSecretKey(name)];
253
- if (typeof fromEnv === "string" && fromEnv) return fromEnv;
254
- return undefined;
255
- };
256
-
257
- // Resolve a token's plaintext, only when needed. Precedence:
258
- // plaintext secrets file (file → creds migration) → explicit --secrets-file/env
259
- // (a new value = rotation) → decrypt an existing legacy .cred (migration/keep) →
260
- // prompt (interactive only). Explicit MUST beat decrypt, or a rotation would
261
- // just re-encrypt the old decrypted value. Every source yields a non-empty STRING.
262
- const resolveToken = async (name) => {
263
- const fromFile = existingFile[name];
264
- if (typeof fromFile === "string" && fromFile) return fromFile;
265
- const explicit = explicitToken(name);
266
- if (explicit) return explicit;
267
- const dec = trySystemdCredDecrypt(name, join(credsDir, `${name}.cred`));
268
- if (dec) {
269
- log(` ${name}: migrated from systemd-creds`);
270
- return dec;
271
- }
272
- if (opts.nonInteractive) die(`missing credential "${name}" — provide via --secrets-file or env ${envSecretKey(name)}`);
273
- if (name === appKeyName) {
274
- die(`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)}`);
275
- }
276
- const entered = await promptHidden(` ${name} (input hidden): `);
277
- if (!entered) die(`empty value for "${name}"`);
278
- return entered;
279
- };
323
+ let plan;
324
+ try {
325
+ plan = credentialPlan({
326
+ needed,
327
+ optional,
328
+ cliSecrets: opts.cliSecrets,
329
+ imported: importTokens ?? {},
330
+ env: process.env,
331
+ existingFile,
332
+ decrypt: (name) => trySystemdCredDecrypt(name, join(credsDir, `${name}.cred`)),
333
+ mints: (name) => name === npmTokenName && npmMint,
334
+ mint: async (name) => {
335
+ try {
336
+ const out = await createNpmToken({
337
+ ...(merged.npm.registry ? { registry: merged.npm.registry } : {}),
338
+ scopes: merged.npm.scopes,
339
+ ...(merged.npm.tokenOrgs?.length ? { orgs: merged.npm.tokenOrgs } : {}),
340
+ tokenName: `gh-broker ${hostname()} ${new Date().toISOString().slice(0, 10)}`,
341
+ ...(opts.npmTokenDays ? { days: opts.npmTokenDays } : {}),
342
+ promptHidden,
343
+ log
344
+ });
345
+ log(` ${name}: created${out.expires ? ` (expires ${out.expires.slice(0, 10)})` : ""}`);
346
+ return out.token;
347
+ } catch (err) {
348
+ throw new Error(`npm token setup failed: ${err.message}`);
349
+ }
350
+ },
351
+ appKeyName,
352
+ nonInteractive: opts.nonInteractive,
353
+ promptHidden,
354
+ log
355
+ });
280
356
 
281
- if (provider === "systemd-creds") {
282
- ensureCredsDir(configDir, SVC_GROUP);
283
- for (const name of needed) {
284
- const credFile = join(credsDir, `${name}.cred`);
285
- const existed = existsSync(credFile);
286
- // Re-encrypt (rotate) only when the operator supplies a new value — a
287
- // plaintext entry (file → creds migration) or an explicit --secrets-file/env
288
- // token. On a plain re-run with no new value, keep the existing cred as-is.
289
- const rotate = Boolean(existingFile[name]) || explicitToken(name) !== undefined;
290
- if (existed && !rotate) {
291
- log(` ${name}: existing systemd-creds kept`);
292
- continue;
357
+ if (provider === "systemd-creds") {
358
+ ensureCredsDir(configDir, SVC_GROUP);
359
+ const stored = await plan.storeSystemd({
360
+ exists: (name) => existsSync(join(credsDir, `${name}.cred`)),
361
+ encrypt: (name, value) => systemdCredsEncrypt(name, value, join(credsDir, `${name}.cred`))
362
+ });
363
+ writeCredsDropin(stored, credsDir);
364
+ // Now that creds are encrypted, a plaintext secrets file from a previous
365
+ // `file` install must not linger — it would defeat "encrypted at rest".
366
+ if (existsSync(paths.secrets)) {
367
+ removeSecretsFile(paths.secrets);
368
+ log(` removed plaintext secrets file (now using systemd-creds)`);
293
369
  }
294
- systemdCredsEncrypt(name, await resolveToken(name), credFile);
295
- log(` ${name}: ${existed ? "rotated" : "encrypted"} (systemd-creds)`);
296
- }
297
- writeCredsDropin(needed, credsDir);
298
- // Now that creds are encrypted, a plaintext secrets file from a previous
299
- // `file` install must not linger — it would defeat "encrypted at rest".
300
- if (existsSync(paths.secrets)) {
301
- removeSecretsFile(paths.secrets);
302
- log(` removed plaintext secrets file (now using systemd-creds)`);
303
- }
304
- } else {
305
- const tokens = { ...existingFile };
306
- for (const name of needed) {
307
- if (!tokens[name]) tokens[name] = await resolveToken(name);
370
+ } else {
371
+ writeSecretsFile(paths.secrets, await plan.storeFile(), { gid: requireGid(SVC_GROUP) });
372
+ removeCredsDropin(); // no systemd-creds delivery under the file provider
308
373
  }
309
- writeSecretsFile(paths.secrets, tokens, { gid: requireGid(SVC_GROUP) });
310
- removeCredsDropin(); // no systemd-creds delivery under the file provider
374
+ } catch (err) {
375
+ die(err.message);
311
376
  }
312
377
  writeConfigFile(paths.config, merged, SVC_GROUP);
313
378
 
package/deploy/install.sh CHANGED
File without changes
@@ -7,29 +7,43 @@
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-11-01T00:40:44-07:00 (1793518844)
11
11
  * -----
12
12
  * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
13
  *
14
14
  */
15
15
 
16
16
  /**
17
- * Regenerate the deployed config by merging shipped defaults with what's already
18
- * deployed — never a blind overwrite. See docs/deploy-architecture.md.
17
+ * Build the deployed config from four layers, lowest to highest precedence:
19
18
  *
20
- * new_config = mergeConfig(example_defaults, current_config)
19
+ * 1. example defaults — config.example.json: `listen`, `upstream` and the `tools` allowlist.
20
+ * 2. deployed config — what is already on the host (the previous install's result).
21
+ * 3. --config file — an operator config file, same shape as the deployed config.
22
+ * 4. CLI flags — one flag per setting (--owner, --listen, --ghcr-owner, ...).
21
23
  *
22
- * Field-level precedence, because the two halves have different owners:
23
- * - `tools` (the allowlist) is a SECURITY control → taken from the example, so a
24
- * removal/tightening in an update always applies (a "current wins" merge would
25
- * silently keep a tool the repo dropped).
26
- * - `owners` → current entries win and are preserved; example owners not present
27
- * in current are added (a newly-onboarded org in the repo shows up on upgrade).
28
- * - `listen` / `upstream` / `defaultOwner` / `app` / `secrets` → current wins if
29
- * set, else the example's value.
24
+ * Field-level rules, because the layers have different owners:
25
+ * - `tools` (the allowlist) is a SECURITY control and is always taken from the example, so a
26
+ * removal or tightening in an update applies and neither the deployed copy, a --config
27
+ * file nor a flag can widen it. A --config file that contains `tools` is refused.
28
+ * - Everything else is operator data and the deployed copy wins over the example: `listen`,
29
+ * `upstream`, `defaultOwner`, `owners`, `ghcr`, `app`, `secrets`, `npm`. The example ships
30
+ * none of the owner-specific ones, so a fresh install has no owners until the operator
31
+ * supplies one (--owner, --config, or the interactive prompt).
32
+ * - Layers 3 and 4 are merged on top of what is deployed rather than replacing it: scalars are
33
+ * replaced, `owners` and `ghcr.owners` are merged per owner (and per field within an owner),
34
+ * and the other blocks (`app`, `secrets`, `npm`, the `ghcr` scalars) are merged per field.
35
+ * An upgrade run without flags therefore keeps the deployed owners and `ghcr` untouched.
30
36
  *
31
- * On a first install (no current), the example is used as-is.
37
+ * The result is validated by the real loader (`--check`) before anything is deployed, and a
38
+ * --config file may hold only `cred:` references, never token values.
32
39
  */
40
+ export const DEFAULT_GHCR_DROP_DIR = "/srv/gh-broker/ghcr-drop";
41
+
42
+ const OPERATOR_KEYS = ["listen", "upstream", "defaultOwner", "owners", "app", "secrets", "npm", "ghcr"];
43
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
44
+ /** Shapes of live GitHub / npm tokens (classic, fine-grained, app, npm granular). */
45
+ const TOKEN_SHAPE = /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|npm_[A-Za-z0-9]{30,})\b/;
46
+
33
47
  export function mergeConfig(example, current) {
34
48
  if (!example || typeof example !== "object" || Array.isArray(example)) throw new Error("mergeConfig: example config must be an object");
35
49
  if (!current) return structuredClone(example);
@@ -44,13 +58,157 @@ export function mergeConfig(example, current) {
44
58
  if (current[k] !== undefined) out[k] = current[k];
45
59
  }
46
60
 
47
- // owners: current entries win per-owner; example owners not in current are kept.
61
+ // owners: operator data. Current entries win per owner; any the example ships are kept.
48
62
  out.owners = { ...(example.owners ?? {}), ...(current.owners ?? {}) };
49
63
 
50
64
  // optional blocks: current wins if present.
51
- for (const k of ["app", "secrets"]) {
52
- if (current[k] !== undefined) out[k] = current[k];
65
+ for (const k of ["app", "secrets", "npm", "ghcr"]) {
66
+ if (current[k] !== undefined) out[k] = structuredClone(current[k]);
53
67
  }
54
68
 
55
69
  return out;
56
70
  }
71
+
72
+ /**
73
+ * Lay `overlay` (a --config file or the flags' config fragment) on top of `base`. `tools` in the
74
+ * overlay is ignored here (readOperatorConfig refuses it earlier). Returns a new object.
75
+ */
76
+ export function layerConfig(base, overlay) {
77
+ const out = structuredClone(base);
78
+ for (const k of ["listen", "upstream", "defaultOwner"]) {
79
+ if (overlay[k] !== undefined) out[k] = overlay[k];
80
+ }
81
+ for (const k of ["app", "secrets", "npm"]) {
82
+ if (overlay[k] !== undefined) out[k] = { ...(out[k] ?? {}), ...structuredClone(overlay[k]) };
83
+ }
84
+ if (overlay.owners !== undefined) {
85
+ out.owners = { ...(out.owners ?? {}) };
86
+ for (const [name, creds] of Object.entries(overlay.owners)) out.owners[name] = { ...(out.owners[name] ?? {}), ...creds };
87
+ }
88
+ if (overlay.ghcr !== undefined) {
89
+ const { owners: ghcrOwners, ...scalars } = overlay.ghcr;
90
+ const merged = { ...(out.ghcr ?? {}), ...structuredClone(scalars) };
91
+ if (ghcrOwners !== undefined) {
92
+ merged.owners = { ...(merged.owners ?? {}) };
93
+ for (const [name, o] of Object.entries(ghcrOwners)) merged.owners[name] = { ...(merged.owners[name] ?? {}), ...structuredClone(o) };
94
+ }
95
+ out.ghcr = merged;
96
+ }
97
+ return out;
98
+ }
99
+
100
+ /** The config fragment the CLI flags describe (same shape as a config file), or {} when none were given. */
101
+ export function overlayFromFlags(opts) {
102
+ const o = {};
103
+ if (opts.listen !== undefined) o.listen = opts.listen;
104
+ if (opts.upstream !== undefined) o.upstream = opts.upstream;
105
+ if (opts.defaultOwner !== undefined) o.defaultOwner = opts.defaultOwner;
106
+ for (const org of opts.owners ?? []) (o.owners ??= {})[org] = { read: `cred:${org}-read`, write: `cred:${org}-write` };
107
+ for (const [org, id] of Object.entries(opts.installationIds ?? {})) ((o.owners ??= {})[org] ??= {}).installationId = id;
108
+ if (opts.appId !== undefined) o.app = { appId: opts.appId, privateKey: `cred:${opts.appPrivateKey ?? "gh-app-key"}` };
109
+ if (opts.secretsPath !== undefined) o.secrets = { path: opts.secretsPath };
110
+ const g = {};
111
+ if (opts.ghcrDropDir !== undefined) g.dropDir = opts.ghcrDropDir;
112
+ if (opts.ghcrMaxArchiveBytes !== undefined) g.maxArchiveBytes = opts.ghcrMaxArchiveBytes;
113
+ if (opts.protectReleaseTags !== undefined) g.protectReleaseTags = opts.protectReleaseTags;
114
+ if (opts.ghcrOwner !== undefined) {
115
+ g.owners = { [opts.ghcrOwner]: { username: opts.ghcrUsername, credential: `cred:${opts.ghcrOwner}-packages`, images: [...opts.ghcrImages] } };
116
+ }
117
+ if (Object.keys(g).length) o.ghcr = g;
118
+ return o;
119
+ }
120
+
121
+ /**
122
+ * Parse an operator --config file. It must be a JSON object in the deployed config's shape, with
123
+ * no `tools` (the allowlist ships with the code), no unknown top-level keys and no token values.
124
+ * `read` is the file reader (injected so the check is testable). Throws on any problem.
125
+ */
126
+ export function readOperatorConfig(path, read) {
127
+ let raw;
128
+ try {
129
+ raw = JSON.parse(read(path));
130
+ } catch (err) {
131
+ throw new Error(`--config: cannot read/parse ${path}: ${err.message}`);
132
+ }
133
+ if (!isObject(raw)) throw new Error(`--config: ${path} must be a JSON object`);
134
+ if (raw.tools !== undefined) throw new Error("--config: `tools` is the shipped allowlist and cannot be set from a config file");
135
+ const unknown = Object.keys(raw).filter((k) => !OPERATOR_KEYS.includes(k));
136
+ if (unknown.length) throw new Error(`--config: unknown key(s) ${unknown.join(", ")} (allowed: ${OPERATOR_KEYS.join(", ")})`);
137
+ const hit = findTokenShape(raw, "");
138
+ if (hit) throw new Error(`--config: ${hit} looks like a token. The config holds only "cred:<name>" references; pass the token with --secret, --secrets-file or env`);
139
+ return raw;
140
+ }
141
+
142
+ /** Path of the first string in `v` shaped like a token, or undefined. */
143
+ function findTokenShape(v, where) {
144
+ if (typeof v === "string") return TOKEN_SHAPE.test(v) ? where : undefined;
145
+ if (Array.isArray(v)) {
146
+ for (let i = 0; i < v.length; i++) {
147
+ const hit = findTokenShape(v[i], `${where}[${i}]`);
148
+ if (hit) return hit;
149
+ }
150
+ } else if (isObject(v)) {
151
+ for (const [k, x] of Object.entries(v)) {
152
+ const hit = findTokenShape(x, where ? `${where}.${k}` : k);
153
+ if (hit) return hit;
154
+ }
155
+ }
156
+ return undefined;
157
+ }
158
+
159
+ /**
160
+ * The full pipeline: example -> deployed -> --config file -> flags, then the first-install
161
+ * fix-ups. Returns { config, notes }.
162
+ *
163
+ * @param {object} o
164
+ * @param {object} o.example parsed config.example.json
165
+ * @param {object | null} o.current the deployed config, or null on a first install
166
+ * @param {object | undefined} o.file a parsed operator --config file (see readOperatorConfig)
167
+ * @param {object} o.opts parsed installer flags
168
+ * @param {boolean} o.nonInteractive
169
+ * @param {(q: string) => Promise<string>} o.ask visible prompt, used only when no owner exists
170
+ */
171
+ export async function buildConfig({ example, current, file, opts, nonInteractive, ask }) {
172
+ const notes = [];
173
+ let config = mergeConfig(example, current);
174
+ if (file) config = layerConfig(config, file);
175
+ config = layerConfig(config, overlayFromFlags(opts));
176
+ config.owners ??= {};
177
+
178
+ if (Object.keys(config.owners).length === 0) {
179
+ if (nonInteractive) {
180
+ throw new Error("no GitHub owner configured — pass --owner <org> (repeatable) or --config <file> with an owners block");
181
+ }
182
+ const name = (await ask("GitHub owner (user or org) the broker serves, e.g. your-org: ")).trim().toLowerCase();
183
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,38})$/.test(name)) throw new Error(`"${name}" is not a valid GitHub owner name — or pass --owner <org>`);
184
+ config.owners[name] = { read: `cred:${name}-read`, write: `cred:${name}-write` };
185
+ }
186
+ for (const [name, creds] of Object.entries(config.owners)) {
187
+ if (typeof creds.read !== "string" || typeof creds.write !== "string") {
188
+ throw new Error(`owner "${name}" has no credentials — add it with --owner ${name}, or give "read" and "write" cred refs in --config`);
189
+ }
190
+ }
191
+
192
+ const names = Object.keys(config.owners);
193
+ if (current === null && config.defaultOwner === undefined && names.length === 1) {
194
+ config.defaultOwner = names[0];
195
+ notes.push(`defaultOwner set to "${names[0]}" (the only owner); change it with --default-owner`);
196
+ }
197
+
198
+ if (config.ghcr && config.ghcr.dropDir === undefined) {
199
+ config.ghcr.dropDir = DEFAULT_GHCR_DROP_DIR;
200
+ notes.push(`ghcr.dropDir defaulted to ${DEFAULT_GHCR_DROP_DIR} (change it with --ghcr-drop-dir)`);
201
+ }
202
+ return { config, notes };
203
+ }
204
+
205
+ /**
206
+ * The `secrets` block for the chosen provider. A `path` only belongs to the "file" provider, and
207
+ * the loader refuses the combination, so refuse it here before any credential is written.
208
+ */
209
+ export function secretsBlock(provider, existing) {
210
+ if (existing?.path && provider !== "file") {
211
+ throw new Error(`secrets.path (${existing.path}) is only used by the "file" provider — use --secrets-provider file, or drop the path`);
212
+ }
213
+ return { provider, ...(existing?.path ? { path: existing.path } : {}) };
214
+ }