@bongos/core 1.17.3 → 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 (163) hide show
  1. package/.bongos-core.json +212 -332
  2. package/.devcontainer/healthcheck.sh +0 -0
  3. package/.devcontainer/init-firewall.sh +0 -0
  4. package/.devcontainer/post-create.sh +0 -0
  5. package/GOVERNANCE.md +1 -1
  6. package/README.md +26 -22
  7. package/bin/bongos.js +0 -0
  8. package/config/branding.neutral.json +5 -1
  9. package/docker-entrypoint.sh +0 -0
  10. package/docs/adr/0142-project-context-is-prose-only.md +41 -0
  11. package/docs/adr/0143-onboarding-repo-picker-oauth-scope.md +35 -0
  12. package/docs/adr/README.md +6 -0
  13. package/docs/api/openapi.json +1630 -632
  14. package/docs/api-reference.md +50 -15
  15. package/docs/file-map.md +0 -18
  16. package/docs/onboarding/diagrams/05-versioning.mmd +4 -6
  17. package/docs/onboarding/diagrams/assertions.json +5 -9
  18. package/docs/recipes/packaging-the-core.md +13 -0
  19. package/docs/recipes/upgrading-the-core.md +5 -2
  20. package/migrations/039_newcomer_friendly_column.sql +5 -0
  21. package/migrations/102_remove_box_first_provision_approval.sql +4 -0
  22. package/migrations/core_192_github_oauth_tokens.sql +33 -0
  23. package/modules/hall-ui/public/builders.js +37 -0
  24. package/modules/hall-ui/public/goals-page.js +98 -176
  25. package/modules/hall-ui/public/goals.css +183 -0
  26. package/modules/hall-ui/public/goals.html +35 -261
  27. package/modules/hall-ui/public/goals.js +7 -2
  28. package/modules/hall-ui/public/hall-widgets.js +12 -4
  29. package/modules/hall-ui/public/my-projects.html +78 -0
  30. package/modules/hall-ui/public/my-projects.js +142 -0
  31. package/modules/hall-ui/public/scouting.html +45 -0
  32. package/modules/hall-ui/public/scouting.js +73 -0
  33. package/modules/hall-ui/public/settings.css +355 -0
  34. package/modules/hall-ui/public/settings.html +86 -414
  35. package/modules/hall-ui/public/settings.js +96 -35
  36. package/modules/hall-ui/public/style.css +31 -0
  37. package/modules/hall-ui/public/watch.css +184 -0
  38. package/modules/hall-ui/public/watch.html +111 -285
  39. package/modules/hall-ui/public/watch.js +59 -21
  40. package/modules/status-ui/public/explore.html +54 -0
  41. package/modules/status-ui/public/explore.js +67 -0
  42. package/package-lock.json +2 -2
  43. package/package.json +1 -1
  44. package/public-landing/create.html +292 -19
  45. package/public-landing/index.html +11 -76
  46. package/scripts/cron/style_guide_audit.sh +0 -0
  47. package/scripts/gds/api.js +0 -0
  48. package/scripts/gds/architect-audit.js +0 -0
  49. package/scripts/gds/audit-authorship.sh +0 -0
  50. package/scripts/gds/audit-replay.sh +0 -0
  51. package/scripts/gds/box.js +0 -0
  52. package/scripts/gds/capture.js +0 -0
  53. package/scripts/gds/cli-lib.js +11 -5
  54. package/scripts/gds/conductor-dispatch.js +0 -0
  55. package/scripts/gds/coreB-carve-proof.sh +0 -0
  56. package/scripts/gds/db-backup-nightly.sh +0 -0
  57. package/scripts/gds/db-backup-offsite.sh +0 -0
  58. package/scripts/gds/doctor.js +0 -0
  59. package/scripts/gds/grade-smoke.js +0 -0
  60. package/scripts/gds/hooks/pre-push.js +0 -0
  61. package/scripts/gds/install-git-hooks.js +0 -0
  62. package/scripts/gds/lint-migrations.js +102 -0
  63. package/scripts/gds/login.js +42 -10
  64. package/scripts/gds/main-audit.js +0 -0
  65. package/scripts/gds/mirror-redact.js +5 -0
  66. package/scripts/gds/oauth-secret.js +0 -0
  67. package/scripts/gds/onboarding-analyze.js +0 -0
  68. package/scripts/gds/onboarding-ship.js +0 -0
  69. package/scripts/gds/package-core.js +263 -4
  70. package/scripts/gds/paste-token.js +0 -0
  71. package/scripts/gds/portability-smoke-art.sh +0 -0
  72. package/scripts/gds/portability-smoke-memory.sh +0 -0
  73. package/scripts/gds/provision.js +270 -11
  74. package/scripts/gds/publish-manifest.js +15 -0
  75. package/scripts/gds/push-main.js +0 -0
  76. package/scripts/gds/render-ideas.js +0 -0
  77. package/scripts/gds/run-unit-tests.js +38 -14
  78. package/scripts/gds/security-baseline-check.sh +0 -0
  79. package/scripts/gds/session.js +0 -0
  80. package/scripts/gds/setup.js +41 -0
  81. package/scripts/gds/ship.js +126 -32
  82. package/scripts/gds/smoke-batch-claim.sh +0 -0
  83. package/scripts/gds/smoke-batch-release.sh +0 -0
  84. package/scripts/gds/smoke-box.sh +0 -0
  85. package/scripts/gds/smoke-claim-consistency.sh +0 -0
  86. package/scripts/gds/smoke-dependencies.sh +0 -0
  87. package/scripts/gds/smoke-write.sh +0 -0
  88. package/scripts/gds/upgrade.js +147 -5
  89. package/scripts/hall-preview/server.js +18 -1
  90. package/scripts/migrate.sh +10 -0
  91. package/scripts/migrate_cost_ledger.sh +0 -0
  92. package/scripts/render-diagrams.sh +0 -0
  93. package/scripts/security/dep-audit.js +0 -0
  94. package/scripts/setup-builder.js +0 -0
  95. package/scripts/setup-builder.sh +0 -0
  96. package/scripts/snapshots/maybe-render-on-deploy.sh +0 -0
  97. package/src/bongos/auth.js +362 -5
  98. package/src/bongos/db.js +66 -0
  99. package/src/bongos/github-repos.js +93 -0
  100. package/src/bongos/routes/auth.js +93 -11
  101. package/src/bongos/routes/github.js +90 -0
  102. package/src/bongos/routes/instance.js +15 -3
  103. package/src/bongos/routes.js +2 -0
  104. package/src/branding.js +17 -2
  105. package/src/instance-config.js +16 -0
  106. package/src/module-api.js +48 -1
  107. package/tests/branding.mjs +29 -23
  108. package/tests/github_repos_helpers.mjs +91 -0
  109. package/tests/github_token_store.mjs +121 -0
  110. package/tests/idp_device_exchange.mjs +105 -0
  111. package/tests/idp_federation.mjs +100 -0
  112. package/tests/migrations_forward_safe.mjs +86 -0
  113. package/tests/module_api.mjs +1 -1
  114. package/tests/package_core.mjs +246 -1
  115. package/tests/provision.mjs +226 -0
  116. package/tests/pruned_lockfile_integrity.mjs +79 -0
  117. package/tests/ship_ci_deploy.mjs +60 -0
  118. package/tests/upgrade.mjs +254 -5
  119. package/.gitignore +0 -91
  120. package/scripts/hall-preview/fixtures/access-requests.json +0 -1
  121. package/scripts/hall-preview/fixtures/achievements.json +0 -76
  122. package/scripts/hall-preview/fixtures/analytics__builder.json +0 -251
  123. package/scripts/hall-preview/fixtures/audit-log.json +0 -1
  124. package/scripts/hall-preview/fixtures/boxes__cost-ledger.json +0 -1
  125. package/scripts/hall-preview/fixtures/builders__active-claims.json +0 -1
  126. package/scripts/hall-preview/fixtures/builders__directory.json +0 -81
  127. package/scripts/hall-preview/fixtures/builders__roster.json +0 -1
  128. package/scripts/hall-preview/fixtures/goals.json +0 -502
  129. package/scripts/hall-preview/fixtures/goals__25.json +0 -96
  130. package/scripts/hall-preview/fixtures/goals__26.json +0 -67
  131. package/scripts/hall-preview/fixtures/goals__3.json +0 -103
  132. package/scripts/hall-preview/fixtures/goals__35.json +0 -49
  133. package/scripts/hall-preview/fixtures/goals__conflicts.json +0 -154
  134. package/scripts/hall-preview/fixtures/goals__detail.json +0 -96
  135. package/scripts/hall-preview/fixtures/goals__inbox.json +0 -5
  136. package/scripts/hall-preview/fixtures/goals__requests.json +0 -3
  137. package/scripts/hall-preview/fixtures/grades__by-builder.json +0 -1
  138. package/scripts/hall-preview/fixtures/inbox.json +0 -330
  139. package/scripts/hall-preview/fixtures/instance.json +0 -124
  140. package/scripts/hall-preview/fixtures/leaderboard.json +0 -184
  141. package/scripts/hall-preview/fixtures/me.json +0 -476
  142. package/scripts/hall-preview/fixtures/me__recent-ships.json +0 -304
  143. package/scripts/hall-preview/fixtures/override-requests.json +0 -1
  144. package/scripts/hall-preview/fixtures/provisioning__fleet.json +0 -1
  145. package/scripts/hall-preview/fixtures/provisioning__fleet__cost-ledger.json +0 -1
  146. package/scripts/hall-preview/fixtures/provisioning__instances.json +0 -1
  147. package/scripts/hall-preview/fixtures/public__analytics__ecosystem.json +0 -516
  148. package/scripts/hall-preview/fixtures/public__bounty-table.json +0 -37
  149. package/scripts/hall-preview/fixtures/public__cost-summary.json +0 -742
  150. package/scripts/hall-preview/fixtures/public__estimation-drift.json +0 -41
  151. package/scripts/hall-preview/fixtures/public__grades.json +0 -50
  152. package/scripts/hall-preview/fixtures/public__progress.json +0 -124
  153. package/scripts/hall-preview/fixtures/public__recent-shipped.json +0 -426
  154. package/scripts/hall-preview/fixtures/public__repo-health.json +0 -160
  155. package/scripts/hall-preview/fixtures/public__repo-info.json +0 -5
  156. package/scripts/hall-preview/fixtures/security__docs.json +0 -1
  157. package/scripts/hall-preview/fixtures/security__reports.json +0 -1
  158. package/scripts/hall-preview/fixtures/sessions__search.json +0 -1
  159. package/scripts/hall-preview/fixtures/tasks.json +0 -2420
  160. package/scripts/hall-preview/fixtures/tasks__claimable.json +0 -2555
  161. package/scripts/hall-preview/fixtures/tasks__detail.json +0 -111
  162. package/scripts/hall-preview/fixtures/tasks__peer-votes__feed.json +0 -330
  163. 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
 
@@ -98,6 +99,11 @@ const CONFIG = {
98
99
  // <slug>.caddy file (idempotent overwrite, no fragile append to a shared file);
99
100
  // the main Caddyfile carries a one-time `import <dir>/*.caddy` (operator setup).
100
101
  caddySnippetDir: process.env.PROVISION_CADDY_SNIPPET_DIR || '/etc/caddy/sites',
102
+ // Where nightly pg_dump backups land (db-backup-nightly.sh's GDS_BACKUP_DIR
103
+ // default). Shared across co-tenant instances on one box — dumps are named
104
+ // `<db>-<ts>.sql.gz`, so each instance's files + retention prune stay scoped to
105
+ // its own DB. Owned by the app user, mode 0750. (task 2213, productizes 2210.)
106
+ backupDir: process.env.PROVISION_BACKUP_DIR || '/var/backups/gds',
101
107
  // Dedicated-droplet defaults (only used for hosting_shape='dedicated').
102
108
  region: process.env.PROVISION_REGION || 'nyc3',
103
109
  baseImage: process.env.PROVISION_BASE_IMAGE || 'ubuntu-24-04-x64',
@@ -175,7 +181,23 @@ function loadDeps() {
175
181
  const provisioning = require(path.join(REPO_ROOT, 'modules/provisioning/provisioning'));
176
182
  // eslint-disable-next-line global-require
177
183
  const { DoApi } = require(path.join(REPO_ROOT, 'scripts/gds/do-api'));
178
- _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 };
179
201
  return _deps;
180
202
  }
181
203
 
@@ -252,6 +274,10 @@ function seedFirstVersionCmd(inst, { privileged = false } = {}) {
252
274
  // It is the ADR 0108 DESTINATION model; these generators make provision.js drive the
253
275
  // scripts/deploy/deploy-instance.sh runbook API-driven instead of by hand.
254
276
  const CORE_PKG_DIR = 'node_modules/@bongos/core';
277
+ // The unix account the instance's Node process + its backup timer run as. Mirrors
278
+ // serviceUnit's hardcoded `User=lars`/`Group=lars` (the droplet's app user); kept as
279
+ // one constant so the backup dir owner + the backup unit's User= can't drift from it.
280
+ const APP_USER = 'lars';
255
281
  function standaloneRoot(inst, { base = CONFIG.standaloneBase } = {}) { return path.posix.join(base, inst.slug); }
256
282
  // Sync the instance's own repo (host content + the core pin). Mirrors deploy-instance.sh
257
283
  // step 1 — assumes the repo is already cloned at standaloneRoot (initial clone is a
@@ -447,6 +473,80 @@ WantedBy=multi-user.target
447
473
  }
448
474
  function serviceUnitPath(inst) { return `/etc/systemd/system/${inst.slug}.service`; }
449
475
 
476
+ // ── nightly DB backup: per-instance timer + service (task 2213) ─────────────
477
+ // PRODUCTIZES the manual task-2210 fix: a newly-provisioned instance was born with
478
+ // NO backups (dir absent, GDS_BACKUP_DB unset → the script fell back to the
479
+ // nonexistent `amazonprimea` default, and the repo's global gds-db-backup units
480
+ // hardcode /home/lars/amazonprimea — wrong for the consumer node_modules/@bongos/
481
+ // core layout). These generators emit a PER-INSTANCE unit pair that fixes all three:
482
+ // • name → `<slug>-db-backup.{service,timer}` so co-tenant instances on one box
483
+ // never collide (each dumps its OWN db; the script's retention prune is
484
+ // already `<db>-*.sql.gz`-scoped, so a shared backupDir is safe).
485
+ // • DB → Environment=GDS_BACKUP_DB=<instance db>, set on the unit (not read from
486
+ // the web.env) so the dump targets the right database with no fallback.
487
+ // • path → ExecStart resolves the canonical script INSIDE the instance's pinned
488
+ // @bongos/core (standalone) or this control-plane checkout (co-tenant),
489
+ // the SAME workDir/CORE_PKG_DIR split serviceUnit uses — never the old
490
+ // monorepo path.
491
+ function backupUnitName(inst) { return `${inst.slug}-db-backup`; }
492
+ function backupServicePath(inst) { return `/etc/systemd/system/${backupUnitName(inst)}.service`; }
493
+ function backupTimerPath(inst) { return `/etc/systemd/system/${backupUnitName(inst)}.timer`; }
494
+ // The consumer-layout-correct absolute path to db-backup-nightly.sh. Standalone runs
495
+ // it from the instance's own pinned core; co-tenant/dedicated from this checkout's
496
+ // scripts/gds/ — mirrors serviceUnit's execStart resolution so they can't diverge.
497
+ function backupScriptPath(inst) {
498
+ const rel = 'scripts/gds/db-backup-nightly.sh';
499
+ return inst.hosting_shape === 'standalone'
500
+ ? path.posix.join(standaloneRoot(inst), CORE_PKG_DIR, rel)
501
+ : path.posix.join(REPO_ROOT, rel);
502
+ }
503
+ function backupService(inst, { backupDir = CONFIG.backupDir } = {}) {
504
+ return `[Unit]
505
+ Description=Nightly pg_dump of the ${inst.slug} GDS DB (provisioned by provision.js, task 2213)
506
+ After=postgresql.service
507
+ Requires=postgresql.service
508
+
509
+ [Service]
510
+ Type=oneshot
511
+ User=${APP_USER}
512
+ Group=${APP_USER}
513
+ # The DB to dump + where dumps land — set on the unit (not via the instance web.env)
514
+ # so the dump targets the right database with no fallback to a stale default (root
515
+ # cause of the task-2210 breakage). db-backup-nightly.sh reads both as env.
516
+ Environment=GDS_BACKUP_DB=${dbName(inst)}
517
+ Environment=GDS_BACKUP_DIR=${backupDir}
518
+ # Consumer-layout path: the canonical script inside this instance's pinned core.
519
+ ExecStart=/bin/bash ${backupScriptPath(inst)}
520
+ # Modest hardening — the script only needs read on the pg socket + write on the
521
+ # backup dir. Mirrors the repo's gds-db-backup.service.
522
+ NoNewPrivileges=true
523
+ ProtectSystem=strict
524
+ ReadWritePaths=${backupDir}
525
+ ProtectHome=read-only
526
+ PrivateTmp=true
527
+
528
+ [Install]
529
+ # Not socket-activated; the timer drives this. The timer's WantedBy is what enables.
530
+ `;
531
+ }
532
+ function backupTimer(inst) {
533
+ return `[Unit]
534
+ Description=Daily timer for the ${inst.slug} GDS DB pg_dump (provisioned by provision.js, task 2213)
535
+
536
+ [Timer]
537
+ # 04:00 UTC daily, mirroring the repo's gds-db-backup.timer (low-traffic window).
538
+ OnCalendar=*-*-* 04:00:00 UTC
539
+ # Catch up on next boot if the box was off when the timer was due.
540
+ Persistent=true
541
+ # Jitter so simultaneous timers don't dogpile the disk at exactly 04:00.
542
+ RandomizedDelaySec=300
543
+ Unit=${backupUnitName(inst)}.service
544
+
545
+ [Install]
546
+ WantedBy=timers.target
547
+ `;
548
+ }
549
+
450
550
  // The branding pack a provisioned CO-TENANT instance loads: the shared, committed
451
551
  // neutral starter (the co-tenant model shares ONE checkout, differentiated only by
452
552
  // env — src/branding.js's GDS_BRANDING_FILE knob). Per-instance identity/domains
@@ -479,7 +579,7 @@ function provisionedEnvPrefix() {
479
579
  // the hall on this host + OAuth uses the real callback origin — without them the pack
480
580
  // stays at neutral localhost and the hall never loads (task 1972). No domain yet
481
581
  // (bring-your-own deferred, ADR 0111 §4) ⇒ origins stay neutral until one is set.
482
- function webEnvBody(inst, { clientId = '', clientSecret = '' } = {}) {
582
+ function webEnvBody(inst, { clientId = '', clientSecret = '', federation = null } = {}) {
483
583
  const prefix = provisionedEnvPrefix();
484
584
  const origin = inst.domain ? `https://${inst.domain}` : '';
485
585
  const originLines = origin
@@ -500,7 +600,11 @@ ${prefix}_OAUTH_ORIGIN=${origin}
500
600
  // Manifest flow (placeManifestCreds fetches them control-plane-side + re-writes this
501
601
  // file with clientId/clientSecret set), or the manual oauth-secret.js placement. On
502
602
  // the first provision write they are blank placeholders the flow fills later.
503
- 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
504
608
  ? `# Provisioned by scripts/gds/provision.js (ADR 0111). OAuth app creds placed via the
505
609
  # GitHub App Manifest flow (ADR 0133) — the owner clicked one link; no secret was pasted.`
506
610
  : `# Provisioned by scripts/gds/provision.js (ADR 0111). Fill the OAuth app creds via the
@@ -519,6 +623,20 @@ ${prefix}_OAUTH_ORIGIN=${origin}
519
623
  const firstAdminLine = ownerRepo && ownerRepo.owner
520
624
  ? `${prefix}_FIRST_ADMIN=${ownerRepo.owner}\n`
521
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
+ : '';
522
640
  return `# /etc/${inst.slug}/web.env — per-instance secrets (chmod 600, OUTSIDE the repo).
523
641
  ${credComment}
524
642
  ${prefix}_GITHUB_CLIENT_ID=${clientId || ''}
@@ -526,12 +644,88 @@ ${prefix}_GITHUB_CLIENT_SECRET=${clientSecret || ''}
526
644
  ${firstAdminLine}GDS_BRANDING_FILE=${PROVISIONED_BRANDING_FILE}
527
645
  ${originLines}PORT=${inst.port}
528
646
  PGDATABASE=${dbName(inst)}
529
- `;
647
+ ${fedLines}`;
530
648
  }
531
649
  function webEnvPath(inst, { etcBase = CONFIG.etcBase } = {}) {
532
650
  return path.posix.join(etcBase, inst.slug, 'web.env');
533
651
  }
534
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
+
535
729
  // The Cloudflare zone a hostname's A record lives in. Cloudflare zones are
536
730
  // registrable domains, which for the common case is the last two DNS labels
537
731
  // (mercury.example.com → example.com; apex example.com → example.com). Multi-part
@@ -842,6 +1036,28 @@ async function provisionInstance(inst, deps) {
842
1036
  if (apply) await provisioning.setInstanceStatus(db, inst.id, 'provisioning');
843
1037
  const patch = {};
844
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
+
845
1061
  // ── step 1: server ───────────────────────────────────────────────────────
846
1062
  // The A-record target for step 5's DNS UPSERT:
847
1063
  // • co-tenant → PROVISION_PUBLIC_IP (the box it runs on — under remote-exec that
@@ -939,7 +1155,7 @@ async function provisionInstance(inst, deps) {
939
1155
 
940
1156
  // ── step 3: config + per-instance secrets ─────────────────────────────────
941
1157
  log(' [config] per-instance web.env');
942
- if (local) boxWriteFile(webEnvPath(inst), webEnvBody(inst), { mode: 0o600, sudo: true });
1158
+ if (local) boxWriteFile(webEnvPath(inst), webEnvBody(inst, { federation }), { mode: 0o600, sudo: true });
943
1159
  else log(' (baked into cloud-init)');
944
1160
 
945
1161
  // ── step 4: systemd unit + enable ─────────────────────────────────────────
@@ -950,6 +1166,21 @@ async function provisionInstance(inst, deps) {
950
1166
  boxExec(`${sudoP}systemctl enable --now ${inst.slug}`);
951
1167
  } else { log(' (unit baked into cloud-init)'); }
952
1168
 
1169
+ // ── step 4b: nightly DB backup — dir + consumer-layout timer/service (task 2213) ──
1170
+ // Every instance must be born WITH backups (the task-2210 gap: cloudbongos was
1171
+ // provisioned without any). Create the app-user-owned backup dir, install the
1172
+ // per-instance timer/service (correct db + consumer-layout script path), enable
1173
+ // the timer. Idempotent: `install -d` no-ops on an existing dir, unit writes are
1174
+ // overwrites, `enable --now` is idempotent. Dedicated bakes the same into cloud-init.
1175
+ log(' [backup] create backup dir + install + enable nightly DB backup timer');
1176
+ if (local) {
1177
+ boxExec(`${sudoP}install -d -o ${APP_USER} -g ${APP_USER} -m 0750 ${CONFIG.backupDir}`);
1178
+ boxWriteFile(backupServicePath(inst), backupService(inst), { mode: 0o644, sudo: true });
1179
+ boxWriteFile(backupTimerPath(inst), backupTimer(inst), { mode: 0o644, sudo: true });
1180
+ boxExec(`${sudoP}systemctl daemon-reload`);
1181
+ boxExec(`${sudoP}systemctl enable --now ${backupUnitName(inst)}.timer`);
1182
+ } else { log(' (backup dir + units baked into cloud-init)'); }
1183
+
953
1184
  // ── step 5: DNS — Cloudflare A-record UPSERT for the instance's domain (P4) ──
954
1185
  // Formalized in P4 (#1945): the shared box-dns-cloudflare.sh hook UPSERTs an A
955
1186
  // record pointing the domain at its target IP (recordIp), in the domain's own
@@ -1009,12 +1240,28 @@ async function provisionInstance(inst, deps) {
1009
1240
  // SAME pure command generators so local + dedicated never diverge.
1010
1241
  function dedicatedUserData(inst) {
1011
1242
  const i = { ...inst, db_name: inst.db_name || inst.slug };
1243
+ // task 2213: a dedicated droplet gets the same nightly backup as local shapes —
1244
+ // write the per-instance unit pair (base64-encoded to sidestep YAML block-scalar
1245
+ // escaping of the multi-line bodies), create the app-user-owned backup dir, and
1246
+ // enable the timer alongside the web unit. Mirrors provisionInstance's step 4b.
1247
+ const svcB64 = Buffer.from(backupService(i), 'utf8').toString('base64');
1248
+ const timerB64 = Buffer.from(backupTimer(i), 'utf8').toString('base64');
1012
1249
  return `#cloud-config
1250
+ write_files:
1251
+ - path: ${backupServicePath(i)}
1252
+ encoding: b64
1253
+ permissions: '0644'
1254
+ content: ${svcB64}
1255
+ - path: ${backupTimerPath(i)}
1256
+ encoding: b64
1257
+ permissions: '0644'
1258
+ content: ${timerB64}
1013
1259
  runcmd:
1014
1260
  - [ bash, -lc, "${dbCreateCmd(i)} || true" ]
1015
1261
  - [ bash, -lc, "cd ${REPO_ROOT} && ${migrateCmd(i)}" ]
1016
1262
  - [ bash, -lc, "${seedFirstVersionCmd(i)}" ]
1017
- - [ bash, -lc, "systemctl daemon-reload && systemctl enable --now ${i.slug}" ]
1263
+ - [ bash, -lc, "install -d -o ${APP_USER} -g ${APP_USER} -m 0750 ${CONFIG.backupDir}" ]
1264
+ - [ bash, -lc, "systemctl daemon-reload && systemctl enable --now ${i.slug} ${backupUnitName(i)}.timer" ]
1018
1265
  `;
1019
1266
  }
1020
1267
 
@@ -1034,10 +1281,16 @@ async function teardownInstance(inst, deps) {
1034
1281
  if (inst.hosting_shape !== 'dedicated') {
1035
1282
  boxExec(`${sudoP}systemctl disable --now ${inst.slug}`, { allowFail: true });
1036
1283
  boxExec(`${sudoP}rm -f ${serviceUnitPath(inst)}`, { allowFail: true });
1284
+ // Stop + remove the nightly backup units too (task 2213). The backup DIR + its
1285
+ // dumps are left in place — like the DB below, they are precious data a teardown
1286
+ // must not silently destroy.
1287
+ boxExec(`${sudoP}systemctl disable --now ${backupUnitName(inst)}.timer`, { allowFail: true });
1288
+ boxExec(`${sudoP}rm -f ${backupServicePath(inst)} ${backupTimerPath(inst)}`, { allowFail: true });
1037
1289
  boxExec(`${sudoP}systemctl daemon-reload`, { allowFail: true });
1038
- // DB + Caddy block are left for an explicit operator (data is precious; a
1039
- // teardown should not silently dropdb). We log the manual follow-ups.
1040
- log(` (left for the operator: dropdb ${dbName(inst)}, remove the Caddy block for ${inst.slug})`);
1290
+ // DB + Caddy block + backup dumps are left for an explicit operator (data is
1291
+ // precious; a teardown should not silently dropdb or delete backups). Log the
1292
+ // manual follow-ups.
1293
+ log(` (left for the operator: dropdb ${dbName(inst)}, remove the Caddy block for ${inst.slug}, prune ${CONFIG.backupDir}/${dbName(inst)}-*.sql.gz)`);
1041
1294
  } else if (apply && deps.doApi && inst.droplet_id) {
1042
1295
  await deps.doApi.deleteDroplet(inst.droplet_id);
1043
1296
  }
@@ -1356,8 +1609,8 @@ async function main() {
1356
1609
  const slug = args[1] && !args[1].startsWith('--') ? args[1] : null;
1357
1610
  if ((cmd === 'provision' || cmd === 'teardown') && !slug) { console.error(`${cmd} needs a <slug>`); process.exit(2); }
1358
1611
 
1359
- let pool, provisioning, DoApi;
1360
- try { ({ pool, provisioning, DoApi } = loadDeps()); }
1612
+ let pool, provisioning, DoApi, platformIdentity, hubKeys;
1613
+ try { ({ pool, provisioning, DoApi, platformIdentity, hubKeys } = loadDeps()); }
1361
1614
  catch (e) { console.error(`cannot load DB layer (run this on the control plane): ${e.message}`); process.exit(1); }
1362
1615
 
1363
1616
  // Box-target executor (remote over SSH when PROVISION_REMOTE_HOST is set, else
@@ -1372,6 +1625,9 @@ async function main() {
1372
1625
  writeFile: makeWriteFile({ apply, privileged: CONFIG.privileged, exec, remoteHost: CONFIG.remoteHost }),
1373
1626
  controlWriteFile: makeWriteFile({ apply, privileged: CONFIG.privileged, exec: controlExec }),
1374
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,
1375
1631
  };
1376
1632
 
1377
1633
  let result;
@@ -1396,6 +1652,7 @@ module.exports = {
1396
1652
  CONFIG, MAX_INTENT_ATTEMPTS,
1397
1653
  // pure step generators (tested directly)
1398
1654
  dbName, dbCreateCmd, migrateCmd, seedFirstVersionCmd, serviceUnit, serviceUnitPath, webEnvBody, webEnvPath,
1655
+ backupUnitName, backupServicePath, backupTimerPath, backupScriptPath, backupService, backupTimer,
1399
1656
  standaloneRoot, standalonePullCmd, standaloneInstallCmd, standaloneMigrateCmd,
1400
1657
  // standalone scaffold (ADR 0108 / 0125, task 2055)
1401
1658
  parseTargetRef, instanceRepoRemote, instanceInitSpec, scaffoldStandaloneRepo, coreVersionSafe,
@@ -1406,4 +1663,6 @@ module.exports = {
1406
1663
  cmdRunIntents, cmdReconcile, cmdStatus,
1407
1664
  // GitHub App Manifest flow (task 2080, ADR 0133)
1408
1665
  convertManifestCode, placeManifestCreds, drainOAuthManifests, MANIFEST_UA,
1666
+ // cross-site identity federation (ADR 0141 §4, task #1002109)
1667
+ federationHubOrigin, shouldFederate, federateInstance,
1409
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',
File without changes
File without changes
@@ -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
 
File without changes
File without changes
@@ -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) {