@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
@@ -47,6 +47,7 @@ const fs = require('node:fs');
47
47
  const os = require('node:os');
48
48
  const path = require('node:path');
49
49
  const cp = require('node:child_process');
50
+ const crypto = require('node:crypto');
50
51
 
51
52
  const REPO_ROOT = path.resolve(__dirname, '..', '..');
52
53
 
@@ -180,7 +181,23 @@ function loadDeps() {
180
181
  const provisioning = require(path.join(REPO_ROOT, 'modules/provisioning/provisioning'));
181
182
  // eslint-disable-next-line global-require
182
183
  const { DoApi } = require(path.join(REPO_ROOT, 'scripts/gds/do-api'));
183
- _deps = { pool, provisioning, DoApi };
184
+ // Cross-site identity (ADR 0141 §4, task #1002109). Resolve the hub-side module by a
185
+ // VARIABLE key, gated on isModuleEnabled — the module-loader's own dynamic-dispatch
186
+ // pattern (the run-unit-tests.js precedent), NOT a static 'modules/platform-identity/'
187
+ // literal that would couple this publishable core file to an instance-EXCLUDED module
188
+ // (fitness Check 16b / task 2135). A vanilla core that neither ships nor enables the
189
+ // module never resolves it → no MODULE_NOT_FOUND on a fresh boot. Null here → federation
190
+ // dormant (shouldFederate skips) → the instance owns its auth, byte-identical to today.
191
+ let platformIdentity = null, hubKeys = null;
192
+ const PI_KEY = 'platform-identity';
193
+ let piEnabled = false;
194
+ try { piEnabled = require(path.join(REPO_ROOT, 'src/modules')).isModuleEnabled(PI_KEY); } catch { piEnabled = false; }
195
+ if (piEnabled) {
196
+ const piBase = path.join(REPO_ROOT, 'modules', PI_KEY);
197
+ try { platformIdentity = require(path.join(piBase, 'platform-identity')); hubKeys = require(path.join(piBase, 'hub-keys')); }
198
+ catch { platformIdentity = null; hubKeys = null; }
199
+ }
200
+ _deps = { pool, provisioning, DoApi, platformIdentity, hubKeys };
184
201
  return _deps;
185
202
  }
186
203
 
@@ -562,7 +579,7 @@ function provisionedEnvPrefix() {
562
579
  // the hall on this host + OAuth uses the real callback origin — without them the pack
563
580
  // stays at neutral localhost and the hall never loads (task 1972). No domain yet
564
581
  // (bring-your-own deferred, ADR 0111 §4) ⇒ origins stay neutral until one is set.
565
- function webEnvBody(inst, { clientId = '', clientSecret = '' } = {}) {
582
+ function webEnvBody(inst, { clientId = '', clientSecret = '', federation = null } = {}) {
566
583
  const prefix = provisionedEnvPrefix();
567
584
  const origin = inst.domain ? `https://${inst.domain}` : '';
568
585
  const originLines = origin
@@ -583,7 +600,11 @@ ${prefix}_OAUTH_ORIGIN=${origin}
583
600
  // Manifest flow (placeManifestCreds fetches them control-plane-side + re-writes this
584
601
  // file with clientId/clientSecret set), or the manual oauth-secret.js placement. On
585
602
  // the first provision write they are blank placeholders the flow fills later.
586
- const credComment = clientId || clientSecret
603
+ const credComment = federation
604
+ ? `# Provisioned by scripts/gds/provision.js (ADR 0111). This instance FEDERATES sign-in
605
+ # through the Cloud Bongos hub (ADR 0141 §4) — it needs no GitHub app of its own, so the
606
+ # GitHub client id/secret below stay blank; the ${prefix}_HUB_* block drives sign-in.`
607
+ : clientId || clientSecret
587
608
  ? `# Provisioned by scripts/gds/provision.js (ADR 0111). OAuth app creds placed via the
588
609
  # GitHub App Manifest flow (ADR 0133) — the owner clicked one link; no secret was pasted.`
589
610
  : `# Provisioned by scripts/gds/provision.js (ADR 0111). Fill the OAuth app creds via the
@@ -602,6 +623,20 @@ ${prefix}_OAUTH_ORIGIN=${origin}
602
623
  const firstAdminLine = ownerRepo && ownerRepo.owner
603
624
  ? `${prefix}_FIRST_ADMIN=${ownerRepo.owner}\n`
604
625
  : '';
626
+ // Cross-site identity federation block (ADR 0141 §4, task #1002109). Present only for
627
+ // a HOSTED (federated) instance: flips auth.idp ON via the env override branding.js
628
+ // reads (idpEnabled), and carries the hub origin, this instance's per-client secret
629
+ // (authenticates it TO the hub, server-to-server), and the hub's PUBLIC key (verifies
630
+ // the CLI's hub-signed assertion offline). All written straight to this chmod-600
631
+ // file — never through a browser or chat (the ADR 0133 secret-placement posture).
632
+ const fedLines = federation
633
+ ? `${prefix}_AUTH_IDP=true
634
+ ${prefix}_HUB_ORIGIN=${federation.hubOrigin}
635
+ ${prefix}_HUB_CLIENT_ID=${federation.clientId}
636
+ ${prefix}_HUB_CLIENT_SECRET=${federation.clientSecret}
637
+ ${prefix}_HUB_PUBLIC_KEY=${federation.hubPublicKey}
638
+ `
639
+ : '';
605
640
  return `# /etc/${inst.slug}/web.env — per-instance secrets (chmod 600, OUTSIDE the repo).
606
641
  ${credComment}
607
642
  ${prefix}_GITHUB_CLIENT_ID=${clientId || ''}
@@ -609,12 +644,88 @@ ${prefix}_GITHUB_CLIENT_SECRET=${clientSecret || ''}
609
644
  ${firstAdminLine}GDS_BRANDING_FILE=${PROVISIONED_BRANDING_FILE}
610
645
  ${originLines}PORT=${inst.port}
611
646
  PGDATABASE=${dbName(inst)}
612
- `;
647
+ ${fedLines}`;
613
648
  }
614
649
  function webEnvPath(inst, { etcBase = CONFIG.etcBase } = {}) {
615
650
  return path.posix.join(etcBase, inst.slug, 'web.env');
616
651
  }
617
652
 
653
+ // ── Cross-site identity: federate a hosted instance's sign-in through the hub ───
654
+ // (ADR 0141 §4, task #1002109). The control plane IS the hub (cloudbongos.com), so
655
+ // registration is a direct in-process call against the hub pool — no HTTP hop.
656
+
657
+ // The hub's own public origin, written into each federated instance as
658
+ // <PREFIX>_HUB_ORIGIN (what auth.js loadIdpConfig reads). Explicit PROVISION_HUB_ORIGIN
659
+ // wins; otherwise the control plane's OWN public origin (<PREFIX>_PUBLIC_ORIGIN, the
660
+ // env the hub already runs under). Null → not hub-configured → shouldFederate is false.
661
+ function federationHubOrigin() {
662
+ const explicit = process.env.PROVISION_HUB_ORIGIN;
663
+ if (explicit) return String(explicit).replace(/\/+$/, '');
664
+ const own = process.env[`${provisionedEnvPrefix()}_PUBLIC_ORIGIN`];
665
+ return own ? String(own).replace(/\/+$/, '') : null;
666
+ }
667
+
668
+ // Whether provisioning should federate this instance's sign-in through the hub.
669
+ // Dormant-by-default: true ONLY when the control plane is hub-configured (the
670
+ // platform-identity module is present, a signing key is loadable, AND a hub origin
671
+ // resolves), the instance is a CO-TENANT (the mothership-hosted shape whose web.env is
672
+ // written locally — a `standalone` is owner-hosted and keeps its own GitHub app, the
673
+ // self-hosted path; a `dedicated` droplet has no local web.env write yet, same gap the
674
+ // ADR 0133 manifest flow leaves — see placeManifestCreds), and it has a public domain
675
+ // (the origin the hub binds the CLI mint audience to — the #1002108 account-takeover
676
+ // fix). Returns { federate, reason } so the caller can log WHY it skipped. PURE-ish
677
+ // (reads env + the injected module handles; no DB, no I/O).
678
+ function shouldFederate(inst, deps) {
679
+ const { platformIdentity, hubKeys } = deps;
680
+ if (!platformIdentity || !hubKeys) return { federate: false, reason: 'platform-identity module not present (control plane is not the hub)' };
681
+ if (!hubKeys.hubSigningConfigured()) return { federate: false, reason: 'hub signing key not configured (<PREFIX>_HUB_SIGNING_KEY unset)' };
682
+ if (!federationHubOrigin()) return { federate: false, reason: 'hub origin not resolvable (set PROVISION_HUB_ORIGIN or <PREFIX>_PUBLIC_ORIGIN)' };
683
+ if (inst.hosting_shape !== 'co-tenant') return { federate: false, reason: `${inst.hosting_shape} keeps its own GitHub app (not the co-tenant hosted path)` };
684
+ if (!inst.domain) return { federate: false, reason: 'no domain yet — federation needs the instance public origin' };
685
+ return { federate: true, reason: null };
686
+ }
687
+
688
+ // Register this hosted instance as an sso_clients row on the HUB (fresh per-client
689
+ // secret) and return the federation block to bake into its web.env (ADR 0141 §4).
690
+ // SECURITY: `origin` MUST be the instance's EXACT public origin — the hub binds the
691
+ // CLI-federation mint audience to it (the #1002108 fix), so a mismatch would break
692
+ // device sign-in (and a wrong origin would be a takeover vector). client_id is the
693
+ // (unique, DNS-safe) slug, so a re-provision rotates the SAME row's secret via
694
+ // registerClient's ON CONFLICT rather than orphaning a client. redirect_uri lists both
695
+ // callback prefixes the core may emit (/api/gds is what auth.js builds today) so the
696
+ // hub's exact-match allow-list accepts the browser leg. --apply gates the real hub
697
+ // write; dry-run returns a placeholder block (so webEnvBody can still be composed) + logs.
698
+ async function federateInstance(inst, deps) {
699
+ const { db, platformIdentity, hubKeys, log, apply } = deps;
700
+ const origin = `https://${inst.domain}`;
701
+ const clientId = inst.slug;
702
+ const hubOrigin = federationHubOrigin();
703
+ const hubPublicKey = hubKeys.hubPublicKeyB64();
704
+ if (!hubPublicKey) throw new Error('hub signing key is set but unreadable — cannot derive HUB_PUBLIC_KEY');
705
+ const redirectUris = [
706
+ `${origin}/api/gds/auth/web/callback`,
707
+ `${origin}/api/bongos/auth/web/callback`,
708
+ ];
709
+ if (!apply) {
710
+ log(` [federate] would register client '${clientId}' on the hub (origin ${origin}) + write ${provisionedEnvPrefix()}_HUB_* + AUTH_IDP into web.env`);
711
+ return { hubOrigin, clientId, clientSecret: '0'.repeat(43), hubPublicKey };
712
+ }
713
+ const clientSecret = crypto.randomBytes(32).toString('base64url');
714
+ // Best-effort owner github_id — nice for the hub catalog's ownership link, but
715
+ // nullable in registerClient, so a lookup miss must never fail the provision.
716
+ let ownerGithubId = null;
717
+ try {
718
+ const { rows } = await db.query('SELECT github_id FROM builders WHERE id = $1', [inst.owner_builder_id]);
719
+ ownerGithubId = rows[0] ? rows[0].github_id : null;
720
+ } catch { /* non-fatal */ }
721
+ await platformIdentity.registerClient(
722
+ { clientId, secret: clientSecret, name: inst.slug, redirectUris, origin, ownerGithubId },
723
+ { pool: db },
724
+ );
725
+ log(` [federate] registered client '${clientId}' on the hub (origin ${origin})`);
726
+ return { hubOrigin, clientId, clientSecret, hubPublicKey };
727
+ }
728
+
618
729
  // The Cloudflare zone a hostname's A record lives in. Cloudflare zones are
619
730
  // registrable domains, which for the common case is the last two DNS labels
620
731
  // (mercury.example.com → example.com; apex example.com → example.com). Multi-part
@@ -925,6 +1036,28 @@ async function provisionInstance(inst, deps) {
925
1036
  if (apply) await provisioning.setInstanceStatus(db, inst.id, 'provisioning');
926
1037
  const patch = {};
927
1038
 
1039
+ // ── cross-site identity: register hub federation (ADR 0141 §4, task #1002109) ──
1040
+ // Computed BEFORE the shape steps so the co-tenant web.env write (step 3) bakes it
1041
+ // in — the instance comes up already federated, no post-hoc restart. Dormant unless
1042
+ // the control plane is hub-configured AND the shape/domain qualify (shouldFederate);
1043
+ // otherwise the instance owns its auth (direct GitHub), byte-identically to today.
1044
+ let federation = null;
1045
+ {
1046
+ const fed = shouldFederate(inst, deps);
1047
+ if (fed.federate) {
1048
+ federation = await federateInstance(inst, deps);
1049
+ log(` [federate] ${inst.slug} → hub sign-in (auth.idp on) — no GitHub app of its own`);
1050
+ if (apply) {
1051
+ await provisioning.recordEvent(db, {
1052
+ instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'federated',
1053
+ detail: `sign-in federated to the hub (client ${inst.slug}, origin https://${inst.domain})`, actor: 'runner',
1054
+ }).catch(() => {});
1055
+ }
1056
+ } else {
1057
+ log(` [federate] skipped (${fed.reason}) — instance owns its auth (direct GitHub)`);
1058
+ }
1059
+ }
1060
+
928
1061
  // ── step 1: server ───────────────────────────────────────────────────────
929
1062
  // The A-record target for step 5's DNS UPSERT:
930
1063
  // • co-tenant → PROVISION_PUBLIC_IP (the box it runs on — under remote-exec that
@@ -1022,7 +1155,7 @@ async function provisionInstance(inst, deps) {
1022
1155
 
1023
1156
  // ── step 3: config + per-instance secrets ─────────────────────────────────
1024
1157
  log(' [config] per-instance web.env');
1025
- if (local) boxWriteFile(webEnvPath(inst), webEnvBody(inst), { mode: 0o600, sudo: true });
1158
+ if (local) boxWriteFile(webEnvPath(inst), webEnvBody(inst, { federation }), { mode: 0o600, sudo: true });
1026
1159
  else log(' (baked into cloud-init)');
1027
1160
 
1028
1161
  // ── step 4: systemd unit + enable ─────────────────────────────────────────
@@ -1476,8 +1609,8 @@ async function main() {
1476
1609
  const slug = args[1] && !args[1].startsWith('--') ? args[1] : null;
1477
1610
  if ((cmd === 'provision' || cmd === 'teardown') && !slug) { console.error(`${cmd} needs a <slug>`); process.exit(2); }
1478
1611
 
1479
- let pool, provisioning, DoApi;
1480
- try { ({ pool, provisioning, DoApi } = loadDeps()); }
1612
+ let pool, provisioning, DoApi, platformIdentity, hubKeys;
1613
+ try { ({ pool, provisioning, DoApi, platformIdentity, hubKeys } = loadDeps()); }
1481
1614
  catch (e) { console.error(`cannot load DB layer (run this on the control plane): ${e.message}`); process.exit(1); }
1482
1615
 
1483
1616
  // Box-target executor (remote over SSH when PROVISION_REMOTE_HOST is set, else
@@ -1492,6 +1625,9 @@ async function main() {
1492
1625
  writeFile: makeWriteFile({ apply, privileged: CONFIG.privileged, exec, remoteHost: CONFIG.remoteHost }),
1493
1626
  controlWriteFile: makeWriteFile({ apply, privileged: CONFIG.privileged, exec: controlExec }),
1494
1627
  doApi: DoApi ? new DoApi({ dryRun: !apply, token: CONFIG.doToken, log: (m) => console.log(' ' + m) }) : null,
1628
+ // Cross-site identity (ADR 0141 §4). Null on a non-hub control plane → federation
1629
+ // dormant; shouldFederate() then skips and the instance owns its auth (as today).
1630
+ platformIdentity, hubKeys,
1495
1631
  };
1496
1632
 
1497
1633
  let result;
@@ -1527,4 +1663,6 @@ module.exports = {
1527
1663
  cmdRunIntents, cmdReconcile, cmdStatus,
1528
1664
  // GitHub App Manifest flow (task 2080, ADR 0133)
1529
1665
  convertManifestCode, placeManifestCreds, drainOAuthManifests, MANIFEST_UA,
1666
+ // cross-site identity federation (ADR 0141 §4, task #1002109)
1667
+ federationHubOrigin, shouldFederate, federateInstance,
1530
1668
  };
@@ -250,6 +250,21 @@ const INSTANCE_EXCLUDE = [
250
250
  'docs/design/container-exterior-tiles.md',
251
251
  'docs/design/container-interior.md',
252
252
  'docs/design/world-map-scroll.md',
253
+ // instance-HISTORY ADRs (task 1002219 / audit H5). These record THIS platform-family's own
254
+ // migration + identity history (the amazonprimea→cloudbongos lift, the standalone reprovision,
255
+ // the cloudbongos identity hub) — they are inherently full of the prior instance's name/owner and
256
+ // genericize to nothing (unlike a portable methodology ADR). docs/adr/ is allowlisted wholesale for
257
+ // the portable decision records; these three stay in this repo and never ship in the core, mirroring
258
+ // the instance-recipe / game-design carve-outs above. (The portable ADRs + the README index still publish.)
259
+ 'docs/adr/0139-platform-history-migration-to-cloudbongos.md',
260
+ 'docs/adr/0140-cloudbongos-standalone-reprovision.md',
261
+ 'docs/adr/0141-cross-site-identity-federated-hub-idp.md',
262
+ // hall-preview FIXTURES (task 1002219 / audit H5) — the local hall-UI preview harness's sample
263
+ // data is a snapshot of THIS instance's builders/goals/tasks (owner login, world name, prior
264
+ // instance name), pure instance identity. The harness code stays portable; only the OTB data is
265
+ // carved out, so a consumer core never ships customer-zero's directory. (Fixtures remain in-repo
266
+ // for local dev — this only affects what package-core publishes.)
267
+ 'scripts/hall-preview/fixtures/',
253
268
  'blockers/',
254
269
  'limitations/',
255
270
  'generative-ideas.md',
@@ -31,6 +31,10 @@ const { isModuleEnabled } = require('../../src/modules');
31
31
  const TESTS_DIR = path.resolve(__dirname, '..', '..', 'tests');
32
32
  const MODULES_DIR = path.resolve(__dirname, '..', '..', 'modules');
33
33
  const PER_TEST_TIMEOUT_MS = 90_000;
34
+ // Integration tests (package build + `npm install` + a spawned server) are far heavier than a
35
+ // DB-free unit file, so the `--integration` gate (task 1002221 / audit H1) gives each one a much
36
+ // longer budget than the 90s unit cap.
37
+ const INTEGRATION_TIMEOUT_MS = 300_000;
34
38
 
35
39
  // Feature-module test sets (R59 / task 1198). A test that exercises a feature
36
40
  // MODULE runs only when this instance enables that module — a vanilla Cloud
@@ -128,31 +132,50 @@ function discoverModuleTests({ modulesDir = MODULES_DIR, isEnabled = isModuleEna
128
132
  }
129
133
 
130
134
  function main() {
135
+ // Two gates from one runner (task 1002221 / audit H1):
136
+ // default — the DB-free UNIT gate: every test file MINUS the integration set (+ known-
137
+ // failing / support / disabled-module). Fast, runs in a clean checkout / per PR.
138
+ // --integration — the standing INTEGRATION-SMOKE gate: ONLY the integration set (package build +
139
+ // `npm install` + live boot etc.). Heavier, longer per-test budget; run per
140
+ // release / on a schedule in an env that provides the services (DB/network). This
141
+ // is what promotes tests/consumer_layout_boot.mjs from "excluded + ad-hoc" to a
142
+ // first-class gate a release step can invoke with one command.
143
+ const integrationMode = process.argv.includes('--integration');
144
+ const perTestTimeout = integrationMode ? INTEGRATION_TIMEOUT_MS : PER_TEST_TIMEOUT_MS;
145
+
131
146
  const files = fs
132
147
  .readdirSync(TESTS_DIR)
133
148
  .filter((f) => f.endsWith('.mjs'))
134
149
  .map((f) => f.replace(/\.mjs$/, ''))
135
- .filter((name) => !INTEGRATION.has(name) && !KNOWN_FAILING.has(name)
136
- && !SUPPORT_MODULES.has(name) && !MODULE_DISABLED.has(name))
150
+ .filter((name) => integrationMode
151
+ ? (INTEGRATION.has(name) && !KNOWN_FAILING.has(name) && !SUPPORT_MODULES.has(name))
152
+ : (!INTEGRATION.has(name) && !KNOWN_FAILING.has(name) && !SUPPORT_MODULES.has(name) && !MODULE_DISABLED.has(name)))
137
153
  .sort();
138
154
 
139
- const { included: moduleTests, skippedDisabled: moduleTestsSkipped } = discoverModuleTests();
140
-
141
- const moduleNote = MODULE_DISABLED.size
142
- ? ` + ${MODULE_DISABLED.size} disabled-module (${[...MODULE_DISABLED].sort().join(', ')})`
143
- : '';
144
- const modOwnedSkipNote = moduleTestsSkipped.length
145
- ? ` + ${moduleTestsSkipped.length} module-owned test(s) skipped (module disabled: ${[...new Set(moduleTestsSkipped.map((t) => t.key))].sort().join(', ')})`
146
- : '';
155
+ // Module-owned tests are DB-free unit tests — only the default gate runs them.
156
+ const { included: moduleTests, skippedDisabled: moduleTestsSkipped } = integrationMode
157
+ ? { included: [], skippedDisabled: [] }
158
+ : discoverModuleTests();
159
+
160
+ if (integrationMode) {
161
+ console.log(`run-unit-tests --integration: ${files.length} integration-smoke test file(s) (${files.join(', ') || 'none found'}); per-test budget ${INTEGRATION_TIMEOUT_MS / 1000}s`);
162
+ } else {
163
+ const moduleNote = MODULE_DISABLED.size
164
+ ? ` + ${MODULE_DISABLED.size} disabled-module (${[...MODULE_DISABLED].sort().join(', ')})`
165
+ : '';
166
+ const modOwnedSkipNote = moduleTestsSkipped.length
167
+ ? ` + ${moduleTestsSkipped.length} module-owned test(s) skipped (module disabled: ${[...new Set(moduleTestsSkipped.map((t) => t.key))].sort().join(', ')})`
168
+ : '';
169
+ console.log(`run-unit-tests: ${files.length + moduleTests.length} DB-free unit test file(s) (${files.length} core + ${moduleTests.length} module-owned; excluded ${INTEGRATION.size} integration + ${KNOWN_FAILING.size} known-failing${moduleNote}${modOwnedSkipNote})`);
170
+ }
147
171
  const total = files.length + moduleTests.length;
148
- console.log(`run-unit-tests: ${total} DB-free unit test file(s) (${files.length} core + ${moduleTests.length} module-owned; excluded ${INTEGRATION.size} integration + ${KNOWN_FAILING.size} known-failing${moduleNote}${modOwnedSkipNote})`);
149
172
 
150
173
  const failures = [];
151
174
  const runOne = (displayName, filePath) => {
152
175
  try {
153
176
  execFileSync(process.execPath, [filePath], {
154
177
  stdio: ['ignore', 'ignore', 'pipe'],
155
- timeout: PER_TEST_TIMEOUT_MS,
178
+ timeout: perTestTimeout,
156
179
  env: { ...process.env, NODE_ENV: 'test' },
157
180
  });
158
181
  console.log(` ok ${displayName}`);
@@ -168,12 +191,13 @@ function main() {
168
191
  for (const name of files) runOne(name, path.join(TESTS_DIR, `${name}.mjs`));
169
192
  for (const { key, name, file } of moduleTests) runOne(`${key}/${name}`, file);
170
193
 
194
+ const label = integrationMode ? 'run-unit-tests --integration' : 'run-unit-tests';
171
195
  console.log('');
172
196
  if (failures.length === 0) {
173
- console.log(`run-unit-tests: all ${total} passed.`);
197
+ console.log(`${label}: all ${total} passed.`);
174
198
  process.exit(0);
175
199
  }
176
- console.log(`run-unit-tests: ${failures.length} FAILED — ${failures.join(', ')}`);
200
+ console.log(`${label}: ${failures.length} FAILED — ${failures.join(', ')}`);
177
201
  process.exit(1);
178
202
  }
179
203
 
@@ -378,6 +378,47 @@ async function promptOnboardingConsent() {
378
378
  }
379
379
  }
380
380
 
381
+ // Federation (ADR 0141 §1, task #1002108): a HOSTED instance has no GitHub app of
382
+ // its own — it federates sign-in through the Cloud Bongos hub. If the instance
383
+ // manifest advertises auth.idp, run the hub device flow + assertion exchange via
384
+ // the shared login.js runDeviceFlow, then the SAME onboarding prompts. The direct
385
+ // GitHub device flow below (auth.idp unset) is untouched — the self-hosted default.
386
+ let idpCfg = null;
387
+ try {
388
+ const m = await fetch(`${API_BASE}/api/gds/instance`);
389
+ const mj = m.ok ? await m.json() : null;
390
+ idpCfg = (mj && mj.auth && mj.auth.idp) || null;
391
+ } catch (_) { /* manifest unreachable → fall through to the direct flow */ }
392
+ if (idpCfg) {
393
+ console.log('Signing in via the Cloud Bongos platform (hub)...');
394
+ const { runDeviceFlow } = require('./login');
395
+ const flow = await runDeviceFlow(API_BASE, { log: console.log, idp: idpCfg });
396
+ if (flow.state === 'not_approved') {
397
+ console.error('');
398
+ console.error('This GitHub account has not been approved yet.');
399
+ console.error('An Archon must approve your access request before you can enroll.');
400
+ console.error(' → Ask an Archon to invite you, then re-run /builder-setup.');
401
+ process.exit(1);
402
+ }
403
+ if (flow.state !== 'ok') {
404
+ console.error(`\nSign-in did not complete (${flow.state}). Re-run /builder-setup.`);
405
+ process.exit(1);
406
+ }
407
+ const { token, builder } = flow;
408
+ await saveSession({ token, builder, api_base: API_BASE });
409
+ console.log('');
410
+ console.log(`Authenticated as ${builder.github_login} (#${builder.id}). Session saved to ${SESSION_PATH}.`);
411
+ try {
412
+ const { activate } = require('./install-git-hooks');
413
+ activate();
414
+ } catch (_) { /* hook activation is best-effort */ }
415
+ await promptGeminiKey();
416
+ await promptAnthropicLink();
417
+ await promptDisciplines();
418
+ await promptOnboardingConsent();
419
+ process.exit(0);
420
+ }
421
+
381
422
  console.log('Starting GitHub Device Flow...');
382
423
  const start = await (await cliClient()).auth.postAuthDeviceStart();
383
424
  if (!start.ok) {
@@ -95,7 +95,11 @@ const { playSound } = (() => {
95
95
  return { playSound: () => {} };
96
96
  }
97
97
  })();
98
- const DROPLET = 'lars@104.236.254.243';
98
+ // The laptop-mode SSH deploy target is resolved at land time from config/deploy.json
99
+ // ("sshTarget") or <PREFIX>_DEPLOY_SSH_TARGET — see resolveDeployTarget(). There is NO
100
+ // hardcoded default: the core carries no deploy target of its own. The founder
101
+ // instance's old game-box SSH target used to be baked in here, which post-migration
102
+ // meant a laptop-mode ship deployed to the WRONG server (task 1002202).
99
103
  const BEHIND_MAIN_LIMIT = 5;
100
104
 
101
105
  // arg() — the =-aware flag-value parser now lives in cli-lib (the canonical
@@ -762,9 +766,13 @@ function buildMergeArgv(branch, valueSummary) {
762
766
  // Two ways the confirmed→shipped land can run, chosen by a SINGLE committed
763
767
  // switch (config/deploy.json `mode`, env OTB_DEPLOY_MODE overrides):
764
768
  //
765
- // 'laptop' (default) — the operator's machine merges to main locally and
766
- // SSHes `~/deploy.sh` on the droplet. Today's behavior, byte-for-byte.
767
- // Requires the droplet SSH key, so only the operator can land.
769
+ // 'laptop' (default) — the operator's machine merges to main locally and, IFF a
770
+ // deploy target is configured (config/deploy.json "sshTarget" or
771
+ // <PREFIX>_DEPLOY_SSH_TARGET, resolveDeployTarget()), SSHes `~/deploy.sh` on it.
772
+ // The core carries NO target of its own, so by default this is a merge-to-main
773
+ // with no SSH deploy — a change reaches a live instance via package-core +
774
+ // `bongos upgrade` (pin-bump), NOT an SSH deploy (ADR 0108). When a target IS
775
+ // set it requires that host's SSH key, so only the operator can land.
768
776
  //
769
777
  // 'ci' — NO builder box holds the prod key. ship pushes the branch, opens a
770
778
  // PR, and enables GitHub auto-merge; once required checks pass, GitHub
@@ -815,12 +823,7 @@ function effectiveDeployMode(mode, { canPushLocally, envForced } = {}) {
815
823
  }
816
824
 
817
825
  function resolveDeployMode() {
818
- let configRaw = null;
819
- try {
820
- configRaw = fs.readFileSync(path.join(REPO_ROOT, 'config', 'deploy.json'), 'utf8');
821
- } catch (_) {
822
- /* no config file → safe default */
823
- }
826
+ const configRaw = readDeployConfigRaw();
824
827
  const mode = parseDeployMode(process.env.OTB_DEPLOY_MODE, configRaw);
825
828
  const envForced = ['ci', 'laptop'].includes(
826
829
  String(process.env.OTB_DEPLOY_MODE || '').trim().toLowerCase()
@@ -834,6 +837,63 @@ function resolveDeployMode() {
834
837
  return mode;
835
838
  }
836
839
 
840
+ // readDeployConfigRaw — raw contents of config/deploy.json (or null if absent /
841
+ // unreadable). One reader shared by resolveDeployMode (the mode switch) and
842
+ // resolveDeployTarget (the SSH target) so they never diverge on where the file is.
843
+ function readDeployConfigRaw() {
844
+ try {
845
+ return fs.readFileSync(path.join(REPO_ROOT, 'config', 'deploy.json'), 'utf8');
846
+ } catch (_) {
847
+ return null; // no config file → callers apply their safe default
848
+ }
849
+ }
850
+
851
+ // safeBranding — the instance brand pack, or null if it can't be read. Guarded the
852
+ // same way ship.js guards the sound player (line ~91): a deploy must never crash
853
+ // because the brand file is missing on a fresh / re-seeded checkout.
854
+ function safeBranding() {
855
+ try {
856
+ return require('../../src/branding').branding();
857
+ } catch (_) {
858
+ return null;
859
+ }
860
+ }
861
+
862
+ // resolveDeployTarget — WHERE a laptop-mode ship SSHes `~/deploy.sh`, and which
863
+ // /healthz it re-probes when deploy.sh's own health check races the restart window.
864
+ // Pure (env + raw config + branding → target) and exported so the precedence and the
865
+ // safe "no target" default are unit-tested without touching fs/env/network.
866
+ //
867
+ // There is deliberately NO hardcoded default. The founder instance's old game-box
868
+ // SSH target was baked in here, so a laptop-mode ship from a migrated
869
+ // checkout SSHed the WRONG server (task 1002202). The core carries no deploy target
870
+ // of its own — under the standalone model a core change reaches a live instance via
871
+ // package-core + `bongos upgrade` (pin-bump), not an SSH deploy (ADR 0108). An
872
+ // instance whose OWN checkout is its live server opts back into laptop-SSH deploy by
873
+ // setting the target:
874
+ // config/deploy.json → { "sshTarget": "user@host", "healthzUrl": "https://…/healthz" }
875
+ // env → <PREFIX>_DEPLOY_SSH_TARGET, <PREFIX>_DEPLOY_HEALTHZ_URL
876
+ // healthzUrl, when unset, falls back to the instance's own publicOrigin + /healthz
877
+ // (never a hardcoded host); null when even that is unknown (re-probe then skipped).
878
+ function resolveDeployTarget({ env = process.env, configRaw = null, branding = null } = {}) {
879
+ const ic = require('../../src/instance-config');
880
+ let cfg = {};
881
+ if (configRaw) {
882
+ try { cfg = JSON.parse(configRaw) || {}; } catch (_) { cfg = {}; }
883
+ }
884
+ const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
885
+
886
+ const sshTarget = ic.resolveEnv('DEPLOY_SSH_TARGET', { env }) || str(cfg.sshTarget) || null;
887
+
888
+ let healthzUrl = ic.resolveEnv('DEPLOY_HEALTHZ_URL', { env }) || str(cfg.healthzUrl) || null;
889
+ const publicOrigin = branding && branding.domains && branding.domains.publicOrigin;
890
+ if (!healthzUrl && str(publicOrigin)) {
891
+ try { healthzUrl = new URL('/healthz', publicOrigin).toString(); }
892
+ catch (_) { /* malformed origin → leave healthzUrl null */ }
893
+ }
894
+ return { sshTarget, healthzUrl };
895
+ }
896
+
837
897
  // gh runners — mirror gitArgvOk/gitOk but for the GitHub CLI. No shell, ever:
838
898
  // branch names + DB-sourced value_summary reach `gh` as literal argv elements
839
899
  // (same injection discipline as #681). ghArgvOk = boolean side-effect; ghJson =
@@ -2129,26 +2189,48 @@ async function autoMerge(taskId, valueSummary, { dbOnly = false } = {}) {
2129
2189
  return false;
2130
2190
  }
2131
2191
 
2132
- console.log(' triggering deploy on droplet...');
2133
- // Quote ~/deploy.sh so the LOCAL shell doesn't tilde-expand it to
2134
- // /Users/<mac-user>/deploy.sh before ssh transmits the command. With
2135
- // the quotes, ssh sends the literal `~/deploy.sh` and the REMOTE shell
2136
- // expands it to /home/lars/deploy.sh. (Matches /merge-mode SKILL.md:81.)
2137
- if (!shellOk(`ssh -o ConnectTimeout=10 ${DROPLET} "~/deploy.sh"`)) {
2138
- // task 984 / idea 180: ~/deploy.sh's internal /healthz curl can race the
2139
- // service-restart window (Node not yet bound) and exit non-zero even though
2140
- // the deploy succeeded — which used to false-fail the ship and force a
2141
- // needless /merge-mode. Before concluding failure, re-probe the public
2142
- // /healthz with backoff (curl's own --retry; cross-platform, no sleep).
2143
- // --retry-all-errors retries connection-refused during the bind window;
2144
- // -f makes a non-200 an error so a 502/503 also retries. If it comes up
2145
- // within the window the deploy really succeeded; only then-still-down fails.
2146
- console.log(' auto-merge: ~/deploy.sh returned non-zero — re-probing /healthz with backoff before concluding...');
2147
- if (shellOk('curl -fsS --max-time 10 --retry 5 --retry-delay 3 --retry-all-errors https://amazonprimea.com/healthz -o /dev/null')) {
2148
- console.log(' auto-merge: /healthz 200 on re-probe — deploy actually succeeded (deploy.sh raced the restart window). Proceeding.');
2149
- } else {
2150
- console.log(' auto-merge: deploy failed (/healthz never returned 200 after retries); task stays at confirmed');
2151
- return false;
2192
+ // Deploy step. The core carries NO deploy target of its own — under the
2193
+ // standalone model a core change reaches a live instance via package-core +
2194
+ // `bongos upgrade` (pin-bump), NOT an SSH deploy (ADR 0108). So unless an
2195
+ // instance whose own checkout IS its live server has configured a target
2196
+ // (config/deploy.json "sshTarget" or <PREFIX>_DEPLOY_SSH_TARGET), the land is
2197
+ // complete at "merged + pushed to main" and the SSH deploy is skipped. (This
2198
+ // used to SSH a hardcoded target — the founder instance's old game box, i.e. the
2199
+ // WRONG server post-migration. task 1002202.)
2200
+ const { sshTarget, healthzUrl } = resolveDeployTarget({
2201
+ configRaw: readDeployConfigRaw(),
2202
+ branding: safeBranding(),
2203
+ });
2204
+ if (!sshTarget) {
2205
+ console.log(' merged + pushed to main. No deploy target configured, so no SSH deploy —');
2206
+ console.log(' expected for the core. To publish to a live instance run the standalone release');
2207
+ console.log(' flow: package-core → bongos upgrade (pin-bump), per ADR 0108. (Not an SSH deploy.)');
2208
+ } else {
2209
+ console.log(` triggering deploy on ${sshTarget}...`);
2210
+ // Quote ~/deploy.sh so the LOCAL shell doesn't tilde-expand it before ssh
2211
+ // transmits the command; with the quotes ssh sends the literal `~/deploy.sh`
2212
+ // and the REMOTE shell expands it. (Matches /merge-mode SKILL.md:81.)
2213
+ if (!shellOk(`ssh -o ConnectTimeout=10 ${sshTarget} "~/deploy.sh"`)) {
2214
+ // task 984 / idea 180: ~/deploy.sh's internal /healthz curl can race the
2215
+ // service-restart window (Node not yet bound) and exit non-zero even though
2216
+ // the deploy succeeded — which used to false-fail the ship and force a
2217
+ // needless /merge-mode. Before concluding failure, re-probe the CONFIGURED
2218
+ // /healthz with backoff (curl's own --retry; cross-platform, no sleep) —
2219
+ // never a hardcoded host. --retry-all-errors retries connection-refused
2220
+ // during the bind window; -f makes a non-200 an error so a 502/503 also
2221
+ // retries. If it comes up within the window the deploy really succeeded.
2222
+ if (healthzUrl) {
2223
+ console.log(' auto-merge: ~/deploy.sh returned non-zero — re-probing /healthz with backoff before concluding...');
2224
+ if (shellOk(`curl -fsS --max-time 10 --retry 5 --retry-delay 3 --retry-all-errors ${JSON.stringify(healthzUrl)} -o /dev/null`)) {
2225
+ console.log(' auto-merge: /healthz 200 on re-probe — deploy actually succeeded (deploy.sh raced the restart window). Proceeding.');
2226
+ } else {
2227
+ console.log(' auto-merge: deploy failed (/healthz never returned 200 after retries); task stays at confirmed');
2228
+ return false;
2229
+ }
2230
+ } else {
2231
+ console.log(' auto-merge: ~/deploy.sh returned non-zero and no healthzUrl configured to re-probe; task stays at confirmed');
2232
+ return false;
2233
+ }
2152
2234
  }
2153
2235
  }
2154
2236
 
@@ -3548,7 +3630,9 @@ module.exports = {
3548
3630
  gitArgvOk, buildMergeArgv, sanitizeGitHubRefs,
3549
3631
  // #679 / ADR 0042: CI deploy mode — pure helpers exported for the regression
3550
3632
  // test (mode precedence/safe-default, no-shell PR argv, deploy-run match).
3551
- parseDeployMode, buildPrCreateArgv, buildPrMergeArgv, deployRunSucceeded,
3633
+ // task 1002202: resolveDeployTarget — SSH target / healthz precedence + the safe
3634
+ // "no target" default (the core has none), exported for the same reason.
3635
+ parseDeployMode, resolveDeployTarget, buildPrCreateArgv, buildPrMergeArgv, deployRunSucceeded,
3552
3636
  // task 1049 (B): pure CI-land outcome classifier — a timed-out-but-armed PR is
3553
3637
  // 'pending' (success), not failure. Exported so the semantics are unit-tested.
3554
3638
  classifyLandOutcome,