@bongos/core 1.18.0 → 1.18.3

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 (113) hide show
  1. package/.bongos-core.json +132 -322
  2. package/README.md +1 -1
  3. package/config/branding.neutral.json +5 -1
  4. package/docs/adr/0142-project-context-is-prose-only.md +41 -0
  5. package/docs/adr/0143-onboarding-repo-picker-oauth-scope.md +35 -0
  6. package/docs/adr/README.md +5 -2
  7. package/docs/api/openapi.json +1789 -450
  8. package/docs/api-reference.md +52 -5
  9. package/docs/file-map.md +7 -7
  10. package/docs/recipes/packaging-the-core.md +13 -0
  11. package/docs/recipes/upgrading-the-core.md +5 -2
  12. package/migrations/039_newcomer_friendly_column.sql +5 -0
  13. package/migrations/102_remove_box_first_provision_approval.sql +4 -0
  14. package/migrations/core_192_github_oauth_tokens.sql +33 -0
  15. package/modules/hall-ui/public/my-projects.html +78 -0
  16. package/modules/hall-ui/public/my-projects.js +142 -0
  17. package/modules/hall-ui/public/scouting.html +45 -0
  18. package/modules/hall-ui/public/scouting.js +73 -0
  19. package/modules/status-ui/public/explore.html +54 -0
  20. package/modules/status-ui/public/explore.js +67 -0
  21. package/package-lock.json +2 -2
  22. package/package.json +1 -1
  23. package/public-landing/create.html +211 -14
  24. package/scripts/gds/cli-lib.js +11 -5
  25. package/scripts/gds/lint-migrations.js +102 -0
  26. package/scripts/gds/login.js +42 -10
  27. package/scripts/gds/mirror-redact.js +5 -0
  28. package/scripts/gds/package-core.js +263 -4
  29. package/scripts/gds/provision.js +145 -7
  30. package/scripts/gds/publish-manifest.js +15 -0
  31. package/scripts/gds/run-unit-tests.js +38 -14
  32. package/scripts/gds/setup.js +41 -0
  33. package/scripts/gds/ship.js +115 -31
  34. package/scripts/gds/upgrade.js +147 -5
  35. package/scripts/migrate.sh +10 -0
  36. package/src/bongos/auth.js +362 -5
  37. package/src/bongos/db.js +66 -0
  38. package/src/bongos/github-repos.js +93 -0
  39. package/src/bongos/routes/auth.js +93 -11
  40. package/src/bongos/routes/github.js +90 -0
  41. package/src/bongos/routes/instance.js +15 -3
  42. package/src/bongos/routes.js +2 -0
  43. package/src/branding.js +17 -2
  44. package/src/instance-config.js +16 -0
  45. package/src/module-api.js +29 -1
  46. package/tests/branding.mjs +29 -23
  47. package/tests/github_repos_helpers.mjs +91 -0
  48. package/tests/github_token_store.mjs +121 -0
  49. package/tests/idp_device_exchange.mjs +105 -0
  50. package/tests/idp_federation.mjs +100 -0
  51. package/tests/migrations_forward_safe.mjs +86 -0
  52. package/tests/module_api.mjs +1 -1
  53. package/tests/package_core.mjs +246 -1
  54. package/tests/provision.mjs +157 -0
  55. package/tests/pruned_lockfile_integrity.mjs +79 -0
  56. package/tests/ship_ci_deploy.mjs +60 -0
  57. package/tests/upgrade.mjs +254 -5
  58. package/.gitignore +0 -91
  59. package/docs/adr/0139-platform-history-migration-to-cloudbongos.md +0 -94
  60. package/docs/adr/0140-cloudbongos-standalone-reprovision.md +0 -43
  61. package/scripts/hall-preview/fixtures/access-requests.json +0 -20
  62. package/scripts/hall-preview/fixtures/achievements.json +0 -76
  63. package/scripts/hall-preview/fixtures/analytics__builder.json +0 -251
  64. package/scripts/hall-preview/fixtures/audit-log.json +0 -68
  65. package/scripts/hall-preview/fixtures/blockers__detail.json +0 -12
  66. package/scripts/hall-preview/fixtures/box__me.json +0 -5
  67. package/scripts/hall-preview/fixtures/boxes__cost-ledger.json +0 -69
  68. package/scripts/hall-preview/fixtures/builders__active-claims.json +0 -1
  69. package/scripts/hall-preview/fixtures/builders__directory.json +0 -81
  70. package/scripts/hall-preview/fixtures/builders__roster.json +0 -48
  71. package/scripts/hall-preview/fixtures/goals.json +0 -502
  72. package/scripts/hall-preview/fixtures/goals__25.json +0 -96
  73. package/scripts/hall-preview/fixtures/goals__26.json +0 -67
  74. package/scripts/hall-preview/fixtures/goals__3.json +0 -103
  75. package/scripts/hall-preview/fixtures/goals__35.json +0 -49
  76. package/scripts/hall-preview/fixtures/goals__conflicts.json +0 -154
  77. package/scripts/hall-preview/fixtures/goals__detail.json +0 -96
  78. package/scripts/hall-preview/fixtures/goals__inbox.json +0 -5
  79. package/scripts/hall-preview/fixtures/goals__requests.json +0 -3
  80. package/scripts/hall-preview/fixtures/grades__by-builder.json +0 -32
  81. package/scripts/hall-preview/fixtures/inbox.json +0 -330
  82. package/scripts/hall-preview/fixtures/inbox__detail.json +0 -15
  83. package/scripts/hall-preview/fixtures/instance.json +0 -124
  84. package/scripts/hall-preview/fixtures/leaderboard.json +0 -184
  85. package/scripts/hall-preview/fixtures/me.json +0 -476
  86. package/scripts/hall-preview/fixtures/me__art-key__own.json +0 -6
  87. package/scripts/hall-preview/fixtures/me__event-sound-prefs.json +0 -209
  88. package/scripts/hall-preview/fixtures/me__recent-ships.json +0 -304
  89. package/scripts/hall-preview/fixtures/me__render.json +0 -5
  90. package/scripts/hall-preview/fixtures/me__skill-prefs.json +0 -143
  91. package/scripts/hall-preview/fixtures/me__sound-prefs.json +0 -67
  92. package/scripts/hall-preview/fixtures/me__wandering.json +0 -43
  93. package/scripts/hall-preview/fixtures/override-requests.json +0 -15
  94. package/scripts/hall-preview/fixtures/provisioning__fleet.json +0 -1
  95. package/scripts/hall-preview/fixtures/provisioning__fleet__cost-ledger.json +0 -1
  96. package/scripts/hall-preview/fixtures/provisioning__instances.json +0 -1
  97. package/scripts/hall-preview/fixtures/public__analytics__ecosystem.json +0 -516
  98. package/scripts/hall-preview/fixtures/public__bounty-table.json +0 -37
  99. package/scripts/hall-preview/fixtures/public__cost-summary.json +0 -742
  100. package/scripts/hall-preview/fixtures/public__estimation-drift.json +0 -41
  101. package/scripts/hall-preview/fixtures/public__grades.json +0 -50
  102. package/scripts/hall-preview/fixtures/public__progress.json +0 -124
  103. package/scripts/hall-preview/fixtures/public__recent-shipped.json +0 -426
  104. package/scripts/hall-preview/fixtures/public__repo-health.json +0 -160
  105. package/scripts/hall-preview/fixtures/public__repo-info.json +0 -5
  106. package/scripts/hall-preview/fixtures/security__docs.json +0 -10
  107. package/scripts/hall-preview/fixtures/security__reports.json +0 -31
  108. package/scripts/hall-preview/fixtures/sessions__search.json +0 -9
  109. package/scripts/hall-preview/fixtures/tasks.json +0 -2420
  110. package/scripts/hall-preview/fixtures/tasks__claimable.json +0 -2555
  111. package/scripts/hall-preview/fixtures/tasks__detail.json +0 -111
  112. package/scripts/hall-preview/fixtures/tasks__peer-votes__feed.json +0 -330
  113. package/scripts/hall-preview/fixtures/versions.json +0 -89
@@ -80,12 +80,26 @@ function prompt(q) {
80
80
 
81
81
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
82
82
 
83
- // Run the GitHub device flow against `base`. Returns one of:
83
+ // Run the device flow and return one of:
84
84
  // { state: 'ok', token, builder } | { state: 'not_approved' } | { state: 'expired' }
85
- async function runDeviceFlow(base, { log }) {
86
- const start = await fetchJson(`${base}/api/gds/auth/device/start`, { method: 'POST' });
87
- if (start.status === 503 || (start.data && start.data.error === 'auth_not_configured')) {
88
- throw new Error('that instance has no GitHub sign-in configured yet — ask its Archon to set it up.');
85
+ //
86
+ // Two modes (ADR 0141 §1). Direct: run GitHub device flow against the instance's
87
+ // own app. Federated (idp = { origin, client_id } from the instance manifest): run
88
+ // the device flow against the HUB, then exchange the hub-signed assertion at the
89
+ // instance's /auth/device/exchange — a hosted instance has no GitHub app of its own.
90
+ // The instance's OWN admission gate runs at poll (direct) or exchange (federated),
91
+ // so the stranger path is identical for both.
92
+ async function runDeviceFlow(base, { log, idp = null }) {
93
+ const startUrl = idp ? `${idp.origin}/api/gds/sso/device/start` : `${base}/api/gds/auth/device/start`;
94
+ const start = await fetchJson(startUrl, {
95
+ method: 'POST',
96
+ // Federated: the hub resolves + binds the client from the USER-TYPED origin
97
+ // (`base`), never a client_id we supply — so this CLI can only ever mint an
98
+ // assertion scoped to the instance the user actually typed (ADR 0141 §1).
99
+ ...(idp ? { body: JSON.stringify({ origin: base }) } : {}),
100
+ });
101
+ if (start.status === 503 || (start.data && (start.data.error === 'auth_not_configured' || (start.data.error && start.data.error.code === 'idp_signing_unconfigured')))) {
102
+ throw new Error('that instance has no sign-in configured yet — ask its Archon to set it up.');
89
103
  }
90
104
  if (!start.ok || !start.data || !start.data.device_code) {
91
105
  throw new Error(`could not start sign-in (HTTP ${start.status}).`);
@@ -103,15 +117,29 @@ async function runDeviceFlow(base, { log }) {
103
117
  openBrowser(verification_uri);
104
118
  log(' Waiting for you to approve in the browser…');
105
119
 
120
+ const pollUrl = idp ? `${idp.origin}/api/gds/sso/device/poll` : `${base}/api/gds/auth/device/poll`;
106
121
  const deadline = Date.now() + expires * 1000;
107
122
  while (Date.now() < deadline) {
108
123
  await sleep(interval * 1000);
109
- const poll = await fetchJson(`${base}/api/gds/auth/device/poll`, {
124
+ const poll = await fetchJson(pollUrl, {
110
125
  method: 'POST',
126
+ // The hub uses the client BOUND to device_code at start; the CLI names no
127
+ // audience at poll either (ADR 0141 §1).
111
128
  body: JSON.stringify({ device_code }),
112
129
  });
113
130
  const st = poll.data && poll.data.state;
114
- if (st === 'ok') return { state: 'ok', token: poll.data.token, builder: poll.data.builder };
131
+ if (st === 'ok') {
132
+ if (!idp) return { state: 'ok', token: poll.data.token, builder: poll.data.builder };
133
+ // Federated: exchange the hub assertion at the instance for a real session.
134
+ const ex = await fetchJson(`${base}/api/gds/auth/device/exchange`, {
135
+ method: 'POST',
136
+ body: JSON.stringify({ assertion: poll.data.assertion }),
137
+ });
138
+ const exState = ex.data && ex.data.state;
139
+ if (exState === 'not_approved' || (ex.status === 403 && exState !== 'pending')) return { state: 'not_approved' };
140
+ if (!ex.ok || !ex.data || !ex.data.token) throw new Error(`sign-in exchange failed (HTTP ${ex.status}).`);
141
+ return { state: 'ok', token: ex.data.token, builder: ex.data.builder };
142
+ }
115
143
  if (st === 'not_approved' || (poll.status === 403 && st !== 'pending')) return { state: 'not_approved' };
116
144
  if (st === 'denied') throw new Error('sign-in was denied in the browser.');
117
145
  if (st === 'expired') return { state: 'expired' };
@@ -146,10 +174,14 @@ async function main() {
146
174
  }
147
175
  const instanceName = man.data.name || base;
148
176
  const admission = (man.data.admission && man.data.admission.policy) || 'invite-only';
149
- log(` Signing in to ${instanceName}${admission === 'invite-only' ? ' (invite-only)' : ''}`);
177
+ // Federation (ADR 0141 §1): a hosted instance advertises auth.idp = { origin,
178
+ // client_id } — sign-in then runs through the Cloud Bongos hub, not a local
179
+ // GitHub app. null → the direct-GitHub path, unchanged.
180
+ const idp = (man.data.auth && man.data.auth.idp) || null;
181
+ log(` Signing in to ${instanceName}${admission === 'invite-only' ? ' (invite-only)' : ''}${idp ? ' — via Cloud Bongos platform sign-in' : ''}`);
150
182
 
151
183
  // 2. Device flow.
152
- let flow = await runDeviceFlow(base, { log });
184
+ let flow = await runDeviceFlow(base, { log, idp });
153
185
 
154
186
  // 3. Stranger path: not admitted → file the request, poll until an Archon 👍s,
155
187
  // then finish sign-in with a fresh device flow (the first code is spent).
@@ -193,7 +225,7 @@ async function main() {
193
225
  }
194
226
  process.stdout.write('.');
195
227
  }
196
- flow = await runDeviceFlow(base, { log });
228
+ flow = await runDeviceFlow(base, { log, idp });
197
229
  }
198
230
 
199
231
  if (flow.state !== 'ok') {
@@ -185,6 +185,11 @@ function deriveInstanceLiterals(branding = {}, extra = {}) {
185
185
  add(tokens, company.name);
186
186
  { const label = registrableLabel(company.site); if (label) add(tokens, label); }
187
187
 
188
+ // `extra` also carries a caller-supplied FIXED FLOOR: the package-core neutral-floor gate
189
+ // (task 1002217 / audit H4) injects prior-instance tokens/owners here so published DOCS de-brand
190
+ // against them and the fail-closed gate fires EVEN WITH NO branding.json. Those floor literals
191
+ // live with the CALLER (package-core), never hardcoded in this file — so this redactor's self-leak
192
+ // invariant (tests/mirror_redact.mjs) keeps holding while the floor is still enforced.
188
193
  for (const t of extra.tokens || []) add(tokens, t);
189
194
  for (const o of extra.owners || []) add(owners, o);
190
195
 
@@ -52,8 +52,15 @@
52
52
  //
53
53
  // USAGE:
54
54
  // node scripts/gds/package-core.js [--ref <git-ref>] [--out-dir dist]
55
- // [--version <v>] [--droplet-ip <ip>] [--publish [--live]]
55
+ // [--version <v>] [--droplet-ip <ip>] [--self-verify [--self-verify-boot]] [--publish [--live]]
56
56
  // Defaults: --ref HEAD, --out-dir dist, --version = platform CORE_VERSION.
57
+ // --self-verify after building, prove the artifact reproduces its pin (unpack the .tgz,
58
+ // recompute tree_sha256 == manifest + verify tarball.sha256). Fatal on mismatch.
59
+ // (task 1002218 / audit H3) — deterministic + network-free.
60
+ // --self-verify-boot ALSO `npm install <tgz>` into a throwaway consumer + boot /healthz
61
+ // (reuses tests/consumer_layout_boot.mjs; needs npm/registry access).
62
+ // --leak-scan-strict make the post-build leak scan FATAL on any prior-instance literal
63
+ // (default: the scan runs + WARNS; --no-leak-scan skips it). task 1002220 / H6.
57
64
  // Outputs (nothing is committed — dist/ is gitignored):
58
65
  // dist/bongos-core-<version>/ the redacted core tree (inspectable)
59
66
  // dist/bongos-core-<version>.tgz the installable unit
@@ -64,6 +71,7 @@
64
71
  // npm org on a paid plan + `npm login`; this script holds no token.
65
72
 
66
73
  const fs = require('node:fs');
74
+ const os = require('node:os');
67
75
  const path = require('node:path');
68
76
  const zlib = require('node:zlib');
69
77
  const crypto = require('node:crypto');
@@ -207,6 +215,33 @@ function processCoreFile({ pathRel, raw, rules, sensitiveLiterals, envSecrets })
207
215
  };
208
216
  }
209
217
 
218
+ // ---------------------------------------------------------------------------
219
+ // npm-STRIPPED special files (idea 1000543) — the file-SELECTION gate that keeps
220
+ // the pin HONEST about what a consumer actually installs.
221
+ //
222
+ // `npm install <tgz>` never materializes certain special files into the installed
223
+ // package — most notably `.gitignore` (confirmed live: installing the 1.18.1 core
224
+ // .tgz drops exactly one file, ./.gitignore), plus `.npmignore` and `.npmrc`. But
225
+ // package-core enumerates the GIT tree, so isPublishable() happily selects a tracked
226
+ // `.gitignore`. If it lands in the manifest files[] + tree_sha256 pin, the tree a
227
+ // consumer recomputes over node_modules/@bongos/core is MISSING it → verifyInstalledPin
228
+ // (upgrade.js) reports `missing installed file: .gitignore` → hash mismatch → the bump
229
+ // auto-rolls-back. That breaks EVERY vendored `bongos upgrade` with pin-verify ON, and
230
+ // the unattended auto-upgrade subscription (task 2150 / ADR 0136) has no way to pass
231
+ // --skip-pin-verify — so it would roll back every patch (the fix reported in idea 1000543).
232
+ //
233
+ // The manifest must describe exactly what npm installs, so these files are dropped BEFORE
234
+ // they enter the tarball, filesMeta, or the pin (main(), below). Matched by BASENAME at any
235
+ // depth — npm strips nested ones too. The asymmetry is deliberately safe: excluding a file
236
+ // npm WOULD have kept only slightly weakens the pin (verifyInstalledPin iterates the manifest
237
+ // files[], so an un-pinned file is simply not checked); FAILING to exclude one npm strips is
238
+ // FATAL. And these files are useless inside a vendored dependency anyway.
239
+ const NPM_STRIPPED_BASENAMES = new Set(['.gitignore', '.npmignore', '.npmrc']);
240
+ function isNpmStripped(pathRel) {
241
+ const base = String(pathRel || '').split('/').pop();
242
+ return NPM_STRIPPED_BASENAMES.has(base);
243
+ }
244
+
210
245
  // ---------------------------------------------------------------------------
211
246
  // The CORE's own package.json + package-lock.json (task 1882, ADR 0108 §2).
212
247
  //
@@ -545,6 +580,164 @@ function publishTarball({ tgzPath, live }) {
545
580
  console.error(`[package-core] npm publish ${live ? 'succeeded — @bongos/core is live (private).' : 'dry-run OK — the tarball is publishable.'}`);
546
581
  }
547
582
 
583
+ // The neutral FLOOR — customer-zero identity tokens a portable core must never carry (task 1002220
584
+ // / H6 + task 1002217 / H4, finding F2). When packaging from the NEUTRAL trunk there is no
585
+ // config/branding.json, so the branding-DERIVED redaction + gate see ZERO literals and report
586
+ // "clean" while stripping nothing. This fixed floor closes that gap two ways, both keyed off ONE
587
+ // definition:
588
+ // • DOC path (H4, fail-closed): injected as `extra` {tokens, owners} into buildRedactionRules +
589
+ // collectSensitiveLiterals, so the existing doc-redaction STRIPS these from published docs
590
+ // (tokens→NEUTRAL_TOKEN, owners→NEUTRAL_OWNER) and the fail-closed no-leak gate ABORTS the build
591
+ // if one SURVIVES — even with no branding.json. The floor lives HERE and is injected at call
592
+ // time, so mirror-redact.js stays literal-free (its self-leak invariant holds); functional files
593
+ // still ship VERBATIM (the R88 fix) and are covered by the H6 advisory below, not this gate.
594
+ // • SCAN path (H6, advisory): scanArtifactForLeaks measures the flat list across the WHOLE artifact.
595
+ // findLeaks' public-IP floor separately catches the old droplet IP.
596
+ const PRIOR_INSTANCE_FLOOR = { tokens: ['amazonprimea', 'off the boats'], owners: ['lars589'] };
597
+ const PRIOR_INSTANCE_LITERALS = [...PRIOR_INSTANCE_FLOOR.tokens, ...PRIOR_INSTANCE_FLOOR.owners];
598
+
599
+ // High-signal committed-credential shapes for the secret WARNING (task 1002220 / audit H6, finding
600
+ // F3). Deliberately NARROW — well-known provider token prefixes + PEM private-key headers + an
601
+ // explicit secret=<literal> assignment — rather than the generic entropy catch-all, which fires on
602
+ // ordinary long identifiers / base64 data and buries a real hit (an early cut warned on 339 files,
603
+ // ~all benign). A match is a WARNING to eyeball; the HARD gate against live env-secret VALUES is
604
+ // functionalSecretLeaks in the build loop. Scans functional files (docs are redacted + gated already).
605
+ const CREDENTIAL_SHAPE_PATTERNS = [
606
+ ['anthropic-key', /\bsk-ant-[A-Za-z0-9_-]{16,}/],
607
+ ['openai-key', /\bsk-[A-Za-z0-9]{24,}/],
608
+ ['github-token', /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}\b|\bgithub_pat_[A-Za-z0-9_]{20,}/],
609
+ ['aws-access-key-id', /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/],
610
+ ['google-api-key', /\bAIza[A-Za-z0-9_-]{30,}/],
611
+ ['slack-token', /\bxox[baprs]-[A-Za-z0-9-]{10,}/],
612
+ ['private-key-pem', /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/],
613
+ ['secret-assignment', /\b(?:api[_-]?key|secret|password|passwd|access[_-]?token|auth[_-]?token)\b\s*[:=]\s*['"][^'"\s]{16,}['"]/i],
614
+ ];
615
+ function credentialShapeHits(text) {
616
+ const names = [];
617
+ for (const [name, re] of CREDENTIAL_SHAPE_PATTERNS) if (re.test(text)) names.push(name);
618
+ return names;
619
+ }
620
+
621
+ // classify a shipped path for the scan — mirrors processCoreFile's doc/stub/functional split.
622
+ function scanKindOf(pathRel) {
623
+ if (isAgentDoc(pathRel)) return 'stub';
624
+ if (isDocPath(pathRel)) return 'doc';
625
+ return 'functional';
626
+ }
627
+
628
+ // Independent post-build leak scan over the built core file set (task 1002220 / audit H6). Two layers:
629
+ // • INSTANCE LITERALS — findLeaks() over PRIOR_INSTANCE_LITERALS (+ the public-IP floor) across
630
+ // EVERY shipped file: brand/owner/history that must not ship in a portable core. This is the
631
+ // "prove no leak" measurement the neutral trunk was missing (F2).
632
+ // • CREDENTIAL SHAPES — credentialShapeHits() over FUNCTIONAL files only (docs are redacted+gated
633
+ // already): a high-signal WARNING that a committed credential shape shipped.
634
+ // `files` = [{ path, content }]. Pure — returns findings; the caller decides warn vs fatal.
635
+ function scanArtifactForLeaks(files, { literals = PRIOR_INSTANCE_LITERALS } = {}) {
636
+ const literalHits = []; // { path, label, sample }
637
+ const secretWarnings = []; // { path, shapes }
638
+ for (const f of files || []) {
639
+ const text = Buffer.isBuffer(f.content) ? f.content.toString('utf8') : String(f.content == null ? '' : f.content);
640
+ for (const lk of findLeaks(text, literals)) literalHits.push({ path: f.path, label: lk.label, sample: lk.sample });
641
+ if (scanKindOf(f.path) === 'functional') {
642
+ const shapes = credentialShapeHits(text);
643
+ if (shapes.length) secretWarnings.push({ path: f.path, shapes });
644
+ }
645
+ }
646
+ return { literalHits, secretWarnings };
647
+ }
648
+
649
+ // Self-verify the built artifact (task 1002218 / audit H3, findings F1+F5). The packager
650
+ // claims a consumer "verifies an install by recomputing [the pin] from the unpacked tree" —
651
+ // this proves the packager's OWN output actually does, at build time, on the produce side:
652
+ // 1. REPRODUCE THE PIN (always; deterministic, network-free): unpack the built .tgz and
653
+ // recompute tree_sha256 over the unpacked tree, asserting it equals the manifest — plus
654
+ // the on-disk .tgz bytes vs manifest.tarball.sha256. Catches a tar-writer bug, a corrupt
655
+ // write, or pin drift BEFORE the artifact is trusted or published (the R88 failure class).
656
+ // 2. BARE-INSTALL BOOT (opt-in --self-verify-boot; needs npm/registry): `npm install <tgz>`
657
+ // into a throwaway consumer and boot /healthz, by reusing the proven consumer-layout
658
+ // harness (tests/consumer_layout_boot.mjs) with CORE_TGZ set — no duplication. The deeper
659
+ // standing CI gate is task 1002221; this is the packager's own quick smoke.
660
+ // Fatal (exit 1) on a reproduce mismatch or a boot failure — a self-verify that finds a
661
+ // problem must stop the build, never warn-and-ship.
662
+ // Pure recompute — read each pinned file from an unpacked tree, rebuild the canonical tree
663
+ // hash (using the manifest's already-canonical modes), and compare to the manifest, plus the
664
+ // on-disk .tgz bytes vs manifest.tarball.sha256. No process.exit / no spawn → unit-testable.
665
+ // Returns { ok, problems, fileCount }.
666
+ function reproducePinFromTree({ treeRoot, sidecar, tgzPath }, fsImpl = fs) {
667
+ const problems = [];
668
+ const recomputed = [];
669
+ for (const f of sidecar.files || []) {
670
+ const abs = path.join(treeRoot, ...String(f.path).split('/'));
671
+ let buf;
672
+ try { buf = fsImpl.readFileSync(abs); } catch { problems.push(`missing in artifact: ${f.path}`); continue; }
673
+ const sha = sha256hex(buf);
674
+ if (sha !== f.sha256) problems.push(`content mismatch: ${f.path}`);
675
+ recomputed.push({ path: f.path, mode: f.mode, sha256: sha });
676
+ }
677
+ const treeHash = canonicalTreeHash(recomputed);
678
+ if (problems.length === 0 && treeHash !== sidecar.tree_sha256) {
679
+ problems.push(`recomputed tree_sha256 ${treeHash.slice(0, 12)}… ≠ manifest ${String(sidecar.tree_sha256).slice(0, 12)}…`);
680
+ }
681
+ if (tgzPath && sidecar.tarball && sidecar.tarball.sha256) {
682
+ const tgzSha = sha256hex(fsImpl.readFileSync(tgzPath));
683
+ if (tgzSha !== sidecar.tarball.sha256) {
684
+ problems.push(`on-disk .tgz sha256 ${tgzSha.slice(0, 12)}… ≠ manifest.tarball.sha256 ${String(sidecar.tarball.sha256).slice(0, 12)}…`);
685
+ }
686
+ }
687
+ return { ok: problems.length === 0, problems, fileCount: (sidecar.files || []).length };
688
+ }
689
+
690
+ function selfVerify({ tgzPath, sidecar, outDir, boot }) {
691
+ console.error('');
692
+ console.error('[self-verify] verifying the built artifact reproduces its pin …');
693
+
694
+ // 1. reproduce the pin from the built .tgz.
695
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bongos-selfverify-'));
696
+ try {
697
+ let treeRoot;
698
+ const x = spawnSync('tar', ['-xzf', tgzPath, '-C', tmp], { encoding: 'utf8' });
699
+ if (!x.error && x.status === 0) {
700
+ treeRoot = path.join(tmp, TAR_ROOT.replace(/\/$/, '')); // the `package/` root inside the tarball
701
+ } else {
702
+ // tar unavailable — fall back to the on-disk stage dir (the SAME staged buffers written
703
+ // to the .tgz), so the pin is still recomputed from a materialized tree. Less faithful
704
+ // (won't catch a tar-writer bug), so it's a loud warning, not silent.
705
+ treeRoot = path.join(outDir, stageDirName(sidecar.core_version));
706
+ console.error(`[self-verify] ! tar unavailable (${x.error ? x.error.message : 'exit ' + x.status}); recomputing over the stage dir ${path.relative(REPO_ROOT, treeRoot)} instead of the unpacked .tgz.`);
707
+ if (!fs.existsSync(treeRoot)) { console.error('[self-verify] FAILED — neither `tar` nor the stage dir is available to recompute the pin.'); process.exit(1); }
708
+ }
709
+ const rep = reproducePinFromTree({ treeRoot, sidecar, tgzPath });
710
+ if (!rep.ok) {
711
+ console.error(`[self-verify] FAILED — the built artifact does NOT reproduce its pin (${rep.problems.length} problem(s)):`);
712
+ for (const p of rep.problems.slice(0, 20)) console.error(' ' + p);
713
+ if (rep.problems.length > 20) console.error(` … +${rep.problems.length - 20} more`);
714
+ process.exit(1);
715
+ }
716
+ console.error(`[self-verify] ✓ pin reproduced — tree_sha256 + tarball.sha256 match the manifest (${rep.fileCount} files).`);
717
+ } finally {
718
+ try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* best-effort */ }
719
+ }
720
+
721
+ // 2. bare-install boot (opt-in) — reuse the proven consumer-layout harness with CORE_TGZ.
722
+ if (boot) {
723
+ const harness = path.join(REPO_ROOT, 'tests', 'consumer_layout_boot.mjs');
724
+ if (!fs.existsSync(harness)) {
725
+ console.error('[self-verify] • bare-install boot skipped — tests/consumer_layout_boot.mjs not present (packaging from an installed core, where tests/ is not shipped).');
726
+ return;
727
+ }
728
+ console.error('[self-verify] bare-install boot: `npm install <tgz>` + boot /healthz (via consumer_layout_boot, CORE_TGZ set) …');
729
+ const r = spawnSync(process.execPath, [harness], {
730
+ cwd: REPO_ROOT, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024,
731
+ env: { ...process.env, CORE_TGZ: tgzPath }, stdio: ['ignore', process.stderr, process.stderr],
732
+ });
733
+ if (r.error || r.status !== 0) {
734
+ console.error(`[self-verify] FAILED — the packaged core did not install + boot (${r.error ? r.error.message : 'exit ' + r.status}).`);
735
+ process.exit(1);
736
+ }
737
+ console.error('[self-verify] ✓ bare-install boot OK — the packaged core installs and serves /healthz.');
738
+ }
739
+ }
740
+
548
741
  function main() {
549
742
  const ref = arg('--ref', 'HEAD');
550
743
  const outDir = path.resolve(REPO_ROOT, arg('--out-dir', 'dist'));
@@ -564,8 +757,17 @@ function main() {
564
757
  // dependency set, not the game's. Pulled out of the generic per-file loop below.
565
758
  const SYNTHESIZED_PATHS = new Set(['package.json', 'package-lock.json']);
566
759
  const entries = parseLsTree(git(['ls-tree', '-r', sha])).filter((e) => e.type === 'blob');
567
- const publishable = entries.filter((e) => isPublishable(e.path) && !SYNTHESIZED_PATHS.has(e.path));
760
+ const selected = entries.filter((e) => isPublishable(e.path) && !SYNTHESIZED_PATHS.has(e.path));
761
+ // Drop npm-never-installed files (.gitignore, .npmignore, .npmrc) so the manifest +
762
+ // tree_sha256 pin describe exactly what `npm install` materializes — otherwise the
763
+ // consumer's verifyInstalledPin fails on the missing file and the vendored upgrade
764
+ // auto-rolls-back (idea 1000543). Done here, so they never reach the tarball or the pin.
765
+ const npmStripped = selected.filter((e) => isNpmStripped(e.path));
766
+ const publishable = selected.filter((e) => !isNpmStripped(e.path));
568
767
  console.error(`[package-core] ${ref} = ${sha.slice(0, 12)} — ${entries.length} files, ${publishable.length} in the core (+2 synthesized), ${entries.length - publishable.length - 2} excluded.`);
768
+ if (npmStripped.length) {
769
+ console.error(`[package-core] dropped ${npmStripped.length} npm-never-installed file(s) from the pin (npm strips these on install; idea 1000543): ${npmStripped.map((e) => e.path).join(', ')}`);
770
+ }
569
771
  if (publishable.length === 0) {
570
772
  console.error('refusing: the core set is EMPTY — almost certainly a manifest/ref mismatch, not intentional. Aborting.');
571
773
  process.exit(1);
@@ -595,11 +797,19 @@ function main() {
595
797
  try { branding = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'config/branding.json'), 'utf8')); }
596
798
  catch { /* vanilla instance: derive from the public-IP floor only */ }
597
799
  const ownerAliases = String(process.env.OWNER_ALIASES || '').split(',').map((s) => s.trim()).filter(Boolean);
598
- const extra = { owners: ownerAliases, literals: dropletIp ? [dropletIp] : [] };
800
+ // The NEUTRAL FLOOR (task 1002217 / audit H4): always inject the fixed prior-instance tokens so
801
+ // docs de-brand + the no-leak gate fires against them EVEN WITH NO branding.json (the F2 gap).
802
+ // Merged on top of this instance's own branding — for a branded instance the floor is harmless
803
+ // extra; for the neutral trunk it is the only thing standing between a doc and customer-zero's name.
804
+ const extra = {
805
+ owners: [...ownerAliases, ...PRIOR_INSTANCE_FLOOR.owners],
806
+ tokens: [...PRIOR_INSTANCE_FLOOR.tokens],
807
+ literals: dropletIp ? [dropletIp] : [],
808
+ };
599
809
  const rules = buildRedactionRules(branding, extra);
600
810
  const sensitiveLiterals = collectSensitiveLiterals(branding, extra);
601
811
  const envSecrets = collectEnvSecrets();
602
- console.error(`[package-core] ${rules.length} redaction rule(s), ${sensitiveLiterals.length} gate literal(s) + public-IP floor + ${envSecrets.length} env-secret literal(s).`);
812
+ console.error(`[package-core] ${rules.length} redaction rule(s), ${sensitiveLiterals.length} gate literal(s) + public-IP floor + ${envSecrets.length} env-secret literal(s) (incl. the neutral floor).`);
603
813
 
604
814
  // 4. Process every file IN MEMORY (processCoreFile: docs redacted, functional verbatim)
605
815
  // + the fail-closed no-leak gate. Nothing touches dist/ until the ENTIRE set is proven
@@ -710,6 +920,47 @@ function main() {
710
920
  console.error(` manifest ${path.relative(REPO_ROOT, manifestPath)} ← the PIN a downstream repo records`);
711
921
  console.error(` tree ${path.relative(REPO_ROOT, stageDir)}/`);
712
922
 
923
+ // Optional self-verify leg (task 1002218 / audit H3): prove the built artifact reproduces
924
+ // its pin (+ optionally installs & boots) BEFORE it can be trusted or published. Runs after
925
+ // the artifact is written; a failure exits non-zero so an unverified/broken artifact never
926
+ // reaches the publish leg below or a downstream consumer.
927
+ if (process.argv.includes('--self-verify')) {
928
+ selfVerify({ tgzPath, sidecar, outDir, boot: process.argv.includes('--self-verify-boot') });
929
+ }
930
+
931
+ // Post-build leak scan (task 1002220 / audit H6): independent verification that the BUILT
932
+ // artifact carries no prior-instance identity, + a warn-level secret-shape scan over functional
933
+ // files. Runs on every build (skip with --no-leak-scan). Prior-instance literals are a WARNING
934
+ // by default and a hard FAILURE under --leak-scan-strict (the trunk still carries residual
935
+ // history until task 1002219 sweeps it + task 1002217 adds the fail-closed floor gate). A
936
+ // secret-shape hit is ALWAYS a non-fatal warning — the R88 functional-verbatim fix is preserved.
937
+ if (!process.argv.includes('--no-leak-scan')) {
938
+ const scanFiles = staged.map((s) => ({ path: s.name.startsWith(TAR_ROOT) ? s.name.slice(TAR_ROOT.length) : s.name, content: s.buf }));
939
+ const scan = scanArtifactForLeaks(scanFiles);
940
+ const strict = process.argv.includes('--leak-scan-strict');
941
+ if (scan.literalHits.length) {
942
+ const byLit = {};
943
+ for (const h of scan.literalHits) byLit[h.sample] = (byLit[h.sample] || 0) + 1;
944
+ const summary = Object.entries(byLit).sort((a, b) => b[1] - a[1]).map(([k, n]) => `${k}×${n}`).join(', ');
945
+ const files = [...new Set(scan.literalHits.map((h) => h.path))];
946
+ console.error('');
947
+ console.error(`[leak-scan] ${strict ? 'FAILED' : 'WARNING'} — ${scan.literalHits.length} prior-instance literal hit(s) across ${files.length} shipped file(s): ${summary}`);
948
+ for (const p of files.slice(0, 30)) console.error(` ${p}`);
949
+ if (files.length > 30) console.error(` … +${files.length - 30} more file(s)`);
950
+ if (strict) { console.error('[leak-scan] --leak-scan-strict: refusing to certify a leaky artifact.'); process.exit(1); }
951
+ console.error('[leak-scan] non-fatal for now — becomes a hard gate once the trunk sweep (task 1002219) + the fail-closed floor gate (task 1002217) land. Run --leak-scan-strict to enforce today.');
952
+ } else {
953
+ console.error('[leak-scan] ✓ no prior-instance literals in the built artifact.');
954
+ }
955
+ if (scan.secretWarnings.length) {
956
+ console.error(`[leak-scan] ! ${scan.secretWarnings.length} functional file(s) carry a credential SHAPE (WARNING, not fatal — confirm none is a real credential; a real one must be removed at SOURCE):`);
957
+ for (const w of scan.secretWarnings.slice(0, 20)) console.error(` ${w.path} [${w.shapes.join(', ')}]`);
958
+ if (scan.secretWarnings.length > 20) console.error(` … +${scan.secretWarnings.length - 20} more`);
959
+ } else {
960
+ console.error('[leak-scan] ✓ no committed-credential shapes in functional files.');
961
+ }
962
+ }
963
+
713
964
  // Optional publish leg (task 2089): --publish runs `npm publish` on the built
714
965
  // tarball. DRY-RUN by default; the real, permanent publish needs --publish --live
715
966
  // (+ npm auth). Kept AFTER the artifact is written so a publish failure never
@@ -730,12 +981,20 @@ module.exports = {
730
981
  octal, tarChecksum, ustarHeader, splitUstarName, buildTar,
731
982
  // task 1824 — the consumer-core file-classification layer (pure; unit-tested)
732
983
  isDocPath, functionalSecretLeaks, processCoreFile,
984
+ // idea 1000543 — npm-never-installed files dropped from the pin so vendored `bongos upgrade` pin-verify matches
985
+ isNpmStripped, NPM_STRIPPED_BASENAMES,
733
986
  // task 2121 — agent-doc (CLAUDE.md/AGENTS.md) stub so a consumer's node_modules can't auto-load core memory
734
987
  isAgentDoc, CORE_AGENT_DOC_STUB,
735
988
  // task 1882 (ADR 0108 §2) — the real installable npm package
736
989
  CORE_DEP_NAMES, buildPackageJson, resolveLockDep, lockfileClosure, prunePackageLock,
737
990
  // task 2089 (ADR 0134) — private-by-default npm publish
738
991
  publishArgs,
992
+ // task 1002218 (audit H3) — build-time self-verify (reproduce pin + optional bare-install boot)
993
+ selfVerify, reproducePinFromTree,
994
+ // task 1002220 (audit H6) — post-build leak scan (prior-instance literals + functional secret shapes)
995
+ scanArtifactForLeaks, PRIOR_INSTANCE_LITERALS,
996
+ // task 1002217 (audit H4) — the fixed neutral floor injected into the doc-redaction + fail-closed gate
997
+ PRIOR_INSTANCE_FLOOR,
739
998
  };
740
999
 
741
1000
  if (require.main === module) main();