@bongos/core 1.17.1 → 1.17.2

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 (164) hide show
  1. package/.bongos-core.json +244 -264
  2. package/.claude/skills/blocker-review/SKILL.md +1 -1
  3. package/.claude/skills/builder-claim/SKILL.md +2 -0
  4. package/.claude/skills/builder-exit/SKILL.md +2 -2
  5. package/.claude/skills/builder-redteam/SKILL.md +3 -3
  6. package/.claude/skills/builder-setup/SKILL.md +2 -2
  7. package/.claude/skills/builder-start/SKILL.md +1 -1
  8. package/.claude/skills/dev/SKILL.md +1 -1
  9. package/.claude/skills/idea-triage/SKILL.md +2 -2
  10. package/.claude/skills/ideate/SKILL.md +1 -1
  11. package/.claude/skills/merge-mode/SKILL.md +1 -1
  12. package/.claude/skills/new-project/SKILL.md +3 -0
  13. package/.claude/skills/paint/SKILL.md +1 -1
  14. package/GOVERNANCE.md +1 -1
  15. package/README.md +1 -1
  16. package/docs/adr/0001-stack-choice-single-vps.md +1 -1
  17. package/docs/adr/0008-google-chat-oauth-user-auth.md +1 -1
  18. package/docs/adr/0019-red-team-bounty-payout-table.md +3 -3
  19. package/docs/adr/0020-security-model-public-stub.md +1 -1
  20. package/docs/adr/0023-kind-multipliers-and-idea-bonus.md +1 -1
  21. package/docs/adr/0024-cloneable-repo-local-first-memory.md +1 -1
  22. package/docs/adr/0026-bfg-memory-hygiene-and-cross-builder-learning.md +4 -4
  23. package/docs/adr/0027-bfg-session-inefficiency-evaluator.md +3 -3
  24. package/docs/adr/0030-builders-hall-live-updates.md +1 -1
  25. package/docs/adr/0031-cloud-dev-environments-for-builders.md +4 -4
  26. package/docs/adr/0032-discord-mirror-of-gds.md +2 -2
  27. package/docs/adr/0034-thetes-graduated-newcomer-rank.md +1 -1
  28. package/docs/adr/0044-mediterranean-palette-replacement.md +1 -1
  29. package/docs/adr/0044-per-box-live-game-preview.md +1 -1
  30. package/docs/adr/0054-session-token-reward-cost-plus.md +10 -10
  31. package/docs/adr/0057-container-cost-ledger.md +3 -3
  32. package/docs/adr/0062-medusa-instance-model-decoupling.md +4 -4
  33. package/docs/adr/0065-agpl-nonprofit-ai-first-governance.md +2 -2
  34. package/docs/adr/0085-remove-default-per-builder-spend-cap.md +1 -1
  35. package/docs/adr/0086-goal-scoped-work-hierarchy.md +1 -1
  36. package/docs/adr/0096-require-reward-before-workable.md +9 -9
  37. package/docs/adr/0097-retroactive-reward-backfill.md +3 -3
  38. package/docs/adr/0100-two-repo-instance-model.md +1 -1
  39. package/docs/adr/0101-goal-hierarchy-phase-5-judge-membership-reward.md +3 -3
  40. package/docs/adr/0103-core-first-extraction-cloud-bongos-trunk.md +3 -3
  41. package/docs/adr/0107-module-upstreaming-contribution-to-core-catalog.md +1 -1
  42. package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +6 -6
  43. package/docs/adr/0136-update-channel-subscription-policy.md +51 -0
  44. package/docs/adr/0137-upstream-publish-policy-manual-owner-gated.md +40 -0
  45. package/docs/adr/0138-bundled-module-deps-declared-per-module.md +45 -0
  46. package/docs/adr/README.md +9 -6
  47. package/docs/architecture.md +2 -2
  48. package/docs/branding-contract.md +5 -4
  49. package/docs/canonical-permissions.md +1 -1
  50. package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
  51. package/docs/file-map.md +18 -17
  52. package/docs/handoff-template.md +2 -2
  53. package/docs/modules-contract.md +6 -1
  54. package/docs/onboarding/diagrams/README.md +7 -7
  55. package/docs/onboarding/drachmae-and-karma.md +18 -18
  56. package/docs/onboarding/primer.md +9 -9
  57. package/docs/recipes/audit-replay.md +2 -2
  58. package/docs/recipes/builder-box-lifecycle.md +2 -2
  59. package/docs/recipes/claude-design-loop.md +1 -1
  60. package/docs/recipes/ops-gotchas.md +1 -1
  61. package/docs/recipes/private-npm-distribution.md +17 -0
  62. package/docs/recipes/staging-environment.md +1 -1
  63. package/docs/recipes/upgrading-the-core.md +55 -0
  64. package/docs/research/cursor-competitive-analysis.md +1 -1
  65. package/docs/research/llm-model-agnostic-and-cheaper-grader.md +1 -1
  66. package/modules/discord/module.json +2 -1
  67. package/modules/economy/achievements.js +9 -1
  68. package/modules/economy/credits.js +10 -1
  69. package/modules/hall-ui/public/builders.js +71 -4
  70. package/modules/hall-ui/public/goal-inbox.js +191 -0
  71. package/modules/hall-ui/public/hall-widgets.js +14 -1
  72. package/modules/hall-ui/public/index.html +3 -0
  73. package/modules/hall-ui/public/new-project-wizard.js +1 -0
  74. package/modules/hall-ui/public/style.css +43 -0
  75. package/modules/provisioning/CLAUDE.md +15 -0
  76. package/modules/provisioning/migrations/provisioning_001_tables.sql +160 -0
  77. package/modules/provisioning/migrations/provisioning_002_standalone_shape.sql +11 -0
  78. package/modules/provisioning/migrations/provisioning_003_github_app_manifest.sql +95 -0
  79. package/modules/provisioning/module.json +17 -0
  80. package/modules/provisioning/onboard-plan.js +37 -0
  81. package/modules/provisioning/provisioning.js +539 -0
  82. package/modules/provisioning/rate-limit.js +35 -0
  83. package/modules/provisioning/routes/provisioning.js +607 -0
  84. package/modules/provisioning/tests/provisioning.mjs +232 -0
  85. package/modules/provisioning/ui/.gitkeep +0 -0
  86. package/modules/security/secret-scrub.js +10 -0
  87. package/package-lock.json +2 -2
  88. package/package.json +7 -2
  89. package/scripts/discord/README.md +1 -1
  90. package/scripts/gds/claim.js +98 -48
  91. package/scripts/gds/claude-materialize.js +30 -9
  92. package/scripts/gds/cutover-codemod.js +241 -0
  93. package/scripts/gds/fitness.js +54 -0
  94. package/scripts/gds/init.js +59 -3
  95. package/scripts/gds/lint-infra-exec.js +70 -0
  96. package/scripts/gds/lint-workflow-exec.js +63 -0
  97. package/scripts/gds/mirror-lineage-check.js +14 -5
  98. package/scripts/gds/mirror-redact.js +8 -0
  99. package/scripts/gds/onboard.js +4 -0
  100. package/scripts/gds/package-core.js +18 -3
  101. package/scripts/gds/publish-manifest.js +60 -2
  102. package/scripts/gds/run-routine.js +51 -13
  103. package/scripts/gds/run-unit-tests.js +4 -0
  104. package/scripts/gds/update-channel.js +186 -0
  105. package/scripts/gds/upgrade.js +116 -9
  106. package/src/bongos/auth.js +14 -1
  107. package/src/bongos/serve-internal.js +10 -1
  108. package/src/branding.js +1 -0
  109. package/src/module-api.js +12 -1
  110. package/src/module-loader/loader.js +36 -4
  111. package/src/module-loader/manifest-schema.js +16 -1
  112. package/tests/branding.mjs +11 -0
  113. package/tests/claim_error_surface.mjs +107 -0
  114. package/tests/claude_materialize.mjs +20 -0
  115. package/tests/consumer_layout_boot.mjs +302 -0
  116. package/tests/cutover_codemod.mjs +184 -0
  117. package/tests/device_flow_start.mjs +25 -2
  118. package/tests/hook_core_resolve.mjs +125 -0
  119. package/tests/infra_exec_paths.mjs +104 -0
  120. package/tests/init.mjs +47 -0
  121. package/tests/mirror_lineage_proof.mjs +14 -0
  122. package/tests/module_loader.mjs +42 -0
  123. package/tests/module_manifest.mjs +19 -0
  124. package/tests/onboard.mjs +13 -0
  125. package/tests/package_core.mjs +8 -0
  126. package/tests/provisioning_github_app_routes.mjs +15 -0
  127. package/tests/publish_manifest.mjs +52 -1
  128. package/tests/run_routine.mjs +26 -1
  129. package/tests/run_routine_two_root.mjs +77 -0
  130. package/tests/scrubber_corpus.mjs +17 -0
  131. package/tests/update_channel.mjs +186 -0
  132. package/tests/upgrade.mjs +131 -0
  133. package/tests/workflow_exec_paths.mjs +98 -0
  134. package/docs/design/container-exterior-tiles.md +0 -49
  135. package/docs/design/container-exterior.md +0 -81
  136. package/docs/design/container-interior.md +0 -77
  137. package/docs/design/world-map-scroll.md +0 -41
  138. package/docs/recipes/claude-gemini-bongos-doors.md +0 -155
  139. package/docs/recipes/cloudbongos-staging-gate.md +0 -139
  140. package/docs/recipes/cloudbongos-standup.md +0 -111
  141. package/docs/recipes/parallel-load-results-2026-05-30.md +0 -63
  142. package/docs/recipes/parallel-load-test.md +0 -32
  143. package/docs/recipes/pixel-art-pipeline.md +0 -411
  144. package/docs/recipes/staging-data.md +0 -50
  145. package/migrations/instance/018_pms_v3_planning_seed.sql +0 -131
  146. package/migrations/instance/080_gds_v4_planning_seed.sql +0 -75
  147. package/migrations/instance/110_medusa_planning_seed.sql +0 -70
  148. package/migrations/instance/120_bongos_v1_planning_seed.sql +0 -69
  149. package/migrations/instance/144_bongos_v1_module_system_criterion.sql +0 -26
  150. package/migrations/instance/159_bongos_v1_goal_hierarchy_criterion.sql +0 -29
  151. package/migrations/instance/172_v2_world_planning_seed.sql +0 -163
  152. package/migrations/instance/173_bongos_v1_c7_core_modularized.sql +0 -33
  153. package/migrations/instance/182_gdsv4_bongos_consolidation.sql +0 -132
  154. package/migrations/instance/183_gdsv4_carryover_themed_goals.sql +0 -116
  155. package/migrations/instance/186_bongos_v1_governance_planning_seed.sql +0 -69
  156. package/scripts/gds/art-stage.js +0 -152
  157. package/server.js +0 -212
  158. package/tests/art_key.mjs +0 -121
  159. package/tests/day_cycle.mjs +0 -197
  160. package/tests/snapshot_picker.mjs +0 -233
  161. package/tests/sprite_facing.mjs +0 -35
  162. package/tests/terrain_picker.mjs +0 -298
  163. package/tests/turn_in_place.mjs +0 -280
  164. package/tests/walkability.mjs +0 -188
@@ -0,0 +1,607 @@
1
+ // modules/provisioning/routes/provisioning.js — the provisioning module's HTTP
2
+ // surface (ADR 0111 §1/§2, task #1942). Instance hosting + domains: the web-tier
3
+ // REQUEST + STATE surface for standing up + operating a Cloud Bongos instance.
4
+ //
5
+ // THE TRUST BOUNDARY (ADR 0111 §2 / ADR 0016 / the dev-box /box precedent). The
6
+ // internet-facing web process NEVER calls a cloud API and holds NO provider tokens.
7
+ // A mutation route only ENQUEUES an intent (provision | teardown) into
8
+ // provisioning_intents and returns; the control-plane runner (scripts/gds/
9
+ // provision.js, task P3) is the sole token-holder that drains the queue and does
10
+ // the real infra work. A stolen builder token can never spin up or tear down
11
+ // infrastructure — it can only ask.
12
+ //
13
+ // own-scoped (requireBuilder — the caller's OWN instances):
14
+ // POST /provisioning/instances — request a new instance
15
+ // GET /provisioning/instances — list the caller's instances
16
+ // GET /provisioning/instances/:id — read one of the caller's instances
17
+ // POST /provisioning/instances/:id/teardown — request teardown of one's own
18
+ // Archon-scoped (requireRank('archon') — the fleet, the /boxes precedent):
19
+ // GET /provisioning/fleet — the full instance roster
20
+ // GET /provisioning/fleet/cost-ledger — per-instance + total cost estimate
21
+ // POST /provisioning/instances/:id/force-teardown — tear down ANY instance
22
+ // public:
23
+ // GET /provisioning/health — liveness + core version
24
+ //
25
+ // Reaches core ONLY through the doorway src/module-api.js (the one-way rule, ADR
26
+ // 0083); it requires this module's own provisioning.js (same-dir), never a sibling.
27
+ 'use strict';
28
+
29
+ const crypto = require('node:crypto');
30
+ const express = require('express');
31
+ const api = require('../../../src/module-api');
32
+ const auth = api;
33
+ const { pool, validateOrRespond, parseId } = api;
34
+ const provisioning = require('../provisioning');
35
+ const { onboardPlan } = require('../onboard-plan');
36
+ const { makeAskRateLimiter } = require('../rate-limit');
37
+
38
+ // How long a GitHub-App-Manifest `state` token is valid — the owner must click the
39
+ // pre-filled link + approve on GitHub within this window (task 2080). Comfortably
40
+ // under GitHub's own ~1h conversion-code TTL; long enough for a human round-trip.
41
+ const MANIFEST_STATE_TTL_MS = 30 * 60 * 1000;
42
+
43
+ // The mothership (control-plane) origin GitHub redirects the browser back to. Prefers a
44
+ // dedicated `provisioningOrigin` (e.g. a `get.cloudbongos.com` front door) so the customer-
45
+ // facing GitHub redirect_url carries the PLATFORM's brand, not the game's — a control plane
46
+ // that co-hosts a game (OTB = customer 0) has a game-branded publicOrigin, which would leak
47
+ // into every new customer's App (task 2101). Falls back to `publicOrigin` when unset, so an
48
+ // instance whose publicOrigin is already the platform (e.g. cloudbongos.com) needs no config.
49
+ // Fail-soft to '' so a mis-branded instance produces an obviously-broken (not silently-wrong) URL.
50
+ function mothershipOrigin() {
51
+ try {
52
+ const d = api.branding().domains || {};
53
+ return d.provisioningOrigin || d.publicOrigin || '';
54
+ }
55
+ catch { return ''; }
56
+ }
57
+
58
+ // The provisioning mothership's product brand (e.g. "Cloud Bongos" on the control plane),
59
+ // used to brand-qualify the auto GitHub App name so a common project slug ("demo") does
60
+ // not collide with an existing GitHub App (task 2093). Portable — reads THIS instance's
61
+ // own branding, never a hardcoded string. Fail-soft to null (→ githubAppName falls back).
62
+ function mothershipBrand() {
63
+ try { return (api.branding().identity || {}).productName || null; }
64
+ catch { return null; }
65
+ }
66
+
67
+ // The org login candidate from an instance's target_ref ('owner/repo' → 'owner').
68
+ // Advisory only: personal vs org changes only WHERE the App is created, never whether
69
+ // sign-in works. Null when unparseable.
70
+ function ownerFromTargetRef(ref) {
71
+ const m = String(ref || '').trim()
72
+ .replace(/^git@[^:]+:/, '').replace(/^https?:\/\/[^/]+\//, '')
73
+ .match(/^([A-Za-z0-9][A-Za-z0-9_.-]*)\//);
74
+ return m ? m[1] : null;
75
+ }
76
+
77
+ // Minimal HTML escaper for the callback landing page (no template engine here).
78
+ function esc(s) {
79
+ return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
80
+ { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
81
+ ));
82
+ }
83
+
84
+ // Cloud Bongos brand styling for the standalone onboarding pages — sunrise palette +
85
+ // Baloo 2 / Quicksand (brand/cloud-bongos/tokens.json). Light + dark aware; no external
86
+ // dependency beyond an optional Google-Fonts link (system fallback if it doesn't load).
87
+ const PAGE_STYLE = `
88
+ :root{color-scheme:light dark;--sun:#f97316;--sun600:#ea580c;--coral:#fb7185;--sky:#38bdf8;--sky600:#0284c7;--ok:#16a34a;--warn:#f59e0b;--ink:#2a1f17;--inkSoft:#6b5d52;--card:#fffdfa;--line:#f0e2cf;--bg1:#fde8c8;--bg2:#fff4e6;--bg3:#fffdfa}
89
+ @media (prefers-color-scheme:dark){:root{--ink:#f5efe6;--inkSoft:#cbbdad;--card:#241b13;--line:#3a2c1e;--bg1:#2a1d10;--bg2:#1b130b;--bg3:#130d07}}
90
+ *{box-sizing:border-box}html,body{height:100%}
91
+ body{margin:0;font-family:"Quicksand",system-ui,-apple-system,"Segoe UI",sans-serif;color:var(--ink);background:linear-gradient(180deg,var(--bg1) 0%,var(--bg2) 45%,var(--bg3) 100%);background-attachment:fixed;display:flex;align-items:center;justify-content:center;padding:1.5rem;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
92
+ .card{width:100%;max-width:30rem;background:var(--card);border:1px solid var(--line);border-radius:20px;padding:2.4rem 2rem 2rem;text-align:center;box-shadow:0 18px 50px -22px rgba(120,60,10,.4),0 2px 8px rgba(120,60,10,.07)}
93
+ .badge-warn{font-size:2.4rem;line-height:1;margin:0 auto 1rem}
94
+ h1{font-family:"Baloo 2",system-ui,sans-serif;font-weight:700;font-size:1.5rem;line-height:1.15;margin:.2rem 0 .55rem}
95
+ .sub{color:var(--inkSoft);font-size:1.02rem;line-height:1.5;margin:0 auto;max-width:24rem}
96
+ .sub p{margin:.6rem 0}
97
+ code{font-family:"Fira Code",ui-monospace,SFMono-Regular,monospace;background:rgba(56,189,248,.14);color:var(--sky600);padding:.12em .45em;border-radius:6px;font-size:.9em;overflow-wrap:anywhere}
98
+ @media (prefers-color-scheme:dark){code{color:var(--sky)}}
99
+ .steps{display:flex;align-items:flex-start;justify-content:center;margin:1.9rem 0 1.1rem}
100
+ .step{flex:1 1 0;max-width:9rem;position:relative}
101
+ .step::before{content:"";position:absolute;top:16px;left:-50%;width:100%;height:3px;background:var(--line);z-index:0;transition:background .4s ease}
102
+ .step:first-child::before{display:none}
103
+ .dot{width:34px;height:34px;border-radius:50%;margin:0 auto;display:grid;place-items:center;background:var(--bg3);border:2px solid var(--line);color:var(--inkSoft);font-weight:700;font-size:.95rem;position:relative;z-index:1;transition:all .4s ease}
104
+ .lbl{margin-top:.55rem;font-size:.78rem;color:var(--inkSoft);font-weight:600;line-height:1.25;padding:0 .2rem}
105
+ .step.done .dot{background:var(--ok);border-color:var(--ok);color:#fff}
106
+ .step.done::before{background:var(--ok)}
107
+ .step.active .dot{border-color:var(--sun);color:var(--sun600);animation:pulse 1.5s ease-in-out infinite}
108
+ .step.active .lbl{color:var(--ink)}
109
+ .step.active .dot::after{content:"";position:absolute;inset:-3px;border-radius:50%;border:2px solid transparent;border-top-color:var(--sun);animation:spin .8s linear infinite}
110
+ @keyframes spin{to{transform:rotate(360deg)}}
111
+ @keyframes pulse{0%,100%{box-shadow:0 0 0 4px rgba(249,115,22,.15)}50%{box-shadow:0 0 0 9px rgba(249,115,22,.04)}}
112
+ @media (prefers-reduced-motion:reduce){.step.active .dot,.step.active .dot::after{animation:none}}
113
+ .status-line{min-height:1.4em;color:var(--inkSoft);font-size:.94rem;margin:.4rem auto 0;max-width:24rem;line-height:1.45}
114
+ .cta{margin-top:1.4rem}
115
+ .btn{display:inline-block;background:var(--sun);color:#fff;text-decoration:none;font-weight:700;font-family:"Baloo 2",system-ui,sans-serif;padding:.72rem 1.5rem;border-radius:999px;font-size:1.02rem;box-shadow:0 10px 22px -8px rgba(249,115,22,.7);transition:transform .15s ease,box-shadow .15s ease}
116
+ .btn:hover{transform:translateY(-1px);box-shadow:0 14px 28px -8px rgba(249,115,22,.8)}
117
+ .muted{color:var(--inkSoft);opacity:.9;font-size:.85rem;margin:1.1rem auto 0;max-width:24rem;line-height:1.45}
118
+ .hidden{display:none!important}
119
+ `;
120
+
121
+ // Client script for the PROGRESS variant only (task 2096): poll the public status
122
+ // endpoint, advance the stepper, and auto-open the instance the moment its credentials
123
+ // land. Deliberately contains NO backticks and NO ${...} so it survives the outer
124
+ // template literal verbatim; the only server data it reads is the JSON config block
125
+ // written just above it. Fails soft — a poll hiccup just retries; a long wait reveals a
126
+ // manual "open" button rather than ever falsely erroring.
127
+ const CALLBACK_SCRIPT = `
128
+ (function(){
129
+ var cfg=window.__CB_SETUP__||{};
130
+ var state=cfg.state||'',domain=cfg.domain||'';
131
+ if(!state||!domain)return;
132
+ var target='https://'+domain;
133
+ var pollUrl=location.pathname.replace('/callback','/status')+'?state='+encodeURIComponent(state);
134
+ var steps=document.getElementById('steps'),statusEl=document.getElementById('status');
135
+ var cta=document.getElementById('cta'),go=document.getElementById('go'),foot=document.getElementById('foot');
136
+ var finished=false,tries=0,manual=false;
137
+ function dot(n){return steps.querySelector('[data-step="'+n+'"]');}
138
+ function setActive(n){for(var i=1;i<=3;i++){var el=dot(i);if(!el)continue;el.classList.remove('active','done');if(i<n){el.classList.add('done');el.querySelector('.dot').textContent='\\u2713';}else if(i===n){el.classList.add('active');}}}
139
+ function allDone(){for(var i=1;i<=3;i++){var el=dot(i);if(!el)continue;el.classList.remove('active');el.classList.add('done');el.querySelector('.dot').textContent='\\u2713';}}
140
+ function ready(){if(finished)return;finished=true;allDone();statusEl.textContent='Your instance is ready \\u2014 sign in with GitHub to claim it as owner.';cta.classList.remove('hidden');var n=4;(function tick(){if(n<=0){location.href=target;return;}go.textContent='Opening '+domain+' in '+n+'\\u2026';n--;setTimeout(tick,1000);})();}
141
+ function failed(){if(finished)return;finished=true;statusEl.textContent='Setup didn\\u2019t finish. Open your instance page and start the GitHub sign-in setup again.';cta.classList.remove('hidden');go.textContent='Open '+domain+' \\u2192';}
142
+ function revealManual(){if(manual||finished)return;manual=true;cta.classList.remove('hidden');go.textContent='Open '+domain+' \\u2192';if(foot)foot.textContent='This is taking a little longer than usual \\u2014 you can open your instance now, or keep waiting.';}
143
+ function schedule(){if(!finished)setTimeout(poll,3000);}
144
+ function poll(){tries++;fetch(pollUrl,{headers:{accept:'application/json'},cache:'no-store'}).then(function(r){return r.ok?r.json():null;}).then(function(j){if(!j){schedule();return;}if(j.ready||j.status==='done'){ready();return;}if(j.status==='error'||j.status==='expired'){failed();return;}setActive(2);if(tries===40)revealManual();schedule();}).catch(function(){schedule();});}
145
+ setActive(2);poll();
146
+ })();
147
+ `;
148
+
149
+ // The standalone landing page for the public GitHub redirect (the mothership serves it;
150
+ // it is NOT the hall shell). Two variants share the Cloud Bongos brand shell (task 2096):
151
+ // • progress — ok + state + domain: an animated stepper that polls the status endpoint
152
+ // and auto-opens the instance when its credentials are placed.
153
+ // • message — everything else (errors + the no-domain success fallback): a calm badge
154
+ // + `body` (server-authored, trusted HTML).
155
+ function callbackPage({ ok, title, body, state, domain }) {
156
+ const progress = !!(ok && state && domain);
157
+ const cfgJson = JSON.stringify({ state: state || '', domain: domain || '' }).replace(/</g, '\\u003c');
158
+ const head = `<!doctype html><html lang="en"><head><meta charset="utf-8">
159
+ <meta name="viewport" content="width=device-width, initial-scale=1">
160
+ <meta name="color-scheme" content="light dark">
161
+ <title>${esc(title)}</title>
162
+ <link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
163
+ <link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@600;700;800&family=Quicksand:wght@400;500;600;700&family=Fira+Code&display=swap" rel="stylesheet">
164
+ <style>${PAGE_STYLE}</style></head><body>`;
165
+ if (progress) {
166
+ return `${head}
167
+ <main class="card">
168
+ <h1>${esc(title)}</h1>
169
+ <p class="sub">Finishing sign-in setup for <code>${esc(domain)}</code>.</p>
170
+ <div class="steps" id="steps" aria-label="Setup progress">
171
+ <div class="step done" data-step="1"><div class="dot">✓</div><div class="lbl">GitHub app created</div></div>
172
+ <div class="step active" data-step="2"><div class="dot">2</div><div class="lbl">Placing credentials</div></div>
173
+ <div class="step" data-step="3"><div class="dot">3</div><div class="lbl">Ready to sign in</div></div>
174
+ </div>
175
+ <p class="status-line" id="status">Hang tight — keep this tab open while we finish.</p>
176
+ <div class="cta hidden" id="cta"><a class="btn" id="go" href="https://${esc(domain)}">Open ${esc(domain)} →</a></div>
177
+ <p class="muted" id="foot">This page opens <code>${esc(domain)}</code> for you the moment it’s ready.</p>
178
+ <noscript><p class="muted">When setup finishes, open <a href="https://${esc(domain)}">${esc(domain)}</a> and sign in with GitHub — that first sign-in makes you the owner.</p></noscript>
179
+ </main>
180
+ <script>window.__CB_SETUP__=${cfgJson};</script>
181
+ <script>${CALLBACK_SCRIPT}</script>
182
+ </body></html>`;
183
+ }
184
+ return `${head}
185
+ <main class="card">
186
+ <div class="badge-warn">${ok ? '✓' : '⚠️'}</div>
187
+ <h1>${esc(title)}</h1>
188
+ <div class="sub">${body || ''}</div>
189
+ </main>
190
+ </body></html>`;
191
+ }
192
+
193
+ // Register this module's kernel seam (ADR 0083 / the registerBoxSeams precedent).
194
+ // Idempotent — the loader calls this factory once at boot, but tests may
195
+ // instantiate the router repeatedly; gating on hasProvider keeps the port
196
+ // single-registered.
197
+ // • PROVIDES port `provisioning.instanceState` (slug) → { status, … } | null.
198
+ // Lets other code read an instance's provisioning status via
199
+ // resolveOptional without importing this module (box.hasEverConnected pattern).
200
+ function registerProvisioningSeams() {
201
+ if (api.hasProvider('provisioning.instanceState')) return;
202
+ api.registerProvider('provisioning.instanceState', (slug) => provisioning.instanceStateForSlug(pool, slug));
203
+ }
204
+
205
+ // Project an instance row for the fleet view — the owner identity + the same
206
+ // allow-listed public projection an owner sees.
207
+ function fleetInstance(row) {
208
+ return {
209
+ owner: { id: row.owner_builder_id, github_login: row.github_login, display_name: row.display_name, rank: row.rank },
210
+ ...provisioning.publicInstance(row),
211
+ };
212
+ }
213
+
214
+ module.exports = function provisioningRoutes() {
215
+ registerProvisioningSeams();
216
+ const router = express.Router();
217
+ // Per-IP limiter for the public tls-check `ask` endpoint (SEC review, task 1945).
218
+ const askRateLimit = makeAskRateLimiter();
219
+ // A separate, more generous per-IP limiter for the public status-poll endpoint (task
220
+ // 2096): the onboarding page polls it every ~3s while it waits, so legitimate volume is
221
+ // higher than tls-check's one-shot ask. 120/min/IP gives comfortable headroom while
222
+ // still shedding a flood; in-memory, own bucket (does not share tls-check's budget).
223
+ const statusRateLimit = makeAskRateLimiter({ max: 120 });
224
+
225
+ // Route ORDER is deliberate (the box.js precedent): the Archon-gated routes are
226
+ // declared FIRST, then the any-builder routes. The route-rank auditor
227
+ // (src/bongos/route-rank-check.js) classifies a route from a 20-line lookahead of
228
+ // its middleware; declaring an archon route BELOW an any-builder one would bleed
229
+ // its requireRank('archon') up into the any-builder route's window and mislabel
230
+ // it. Archon-first keeps every route classifying to its true gate.
231
+
232
+ // GET /provisioning/fleet — the full instance roster across all owners.
233
+ // rank: archon (exposes every owner's instance + cost; the /boxes precedent).
234
+ router.get('/provisioning/fleet', auth.requireBuilder, auth.requireRank('archon'), async (req, res) => {
235
+ try {
236
+ const status = req.query.status || undefined;
237
+ const rows = await provisioning.listAllInstances(pool, { status });
238
+ res.json({ instances: rows.map(fleetInstance) });
239
+ } catch (err) {
240
+ console.error('[provisioning] GET /provisioning/fleet', err);
241
+ res.fail('fleet_failed', { status: 500, message: 'internal error' });
242
+ }
243
+ });
244
+
245
+ // GET /provisioning/fleet/cost-ledger — per-instance monthly estimate + total.
246
+ // rank: archon — cost oversight across the fleet (the /boxes/cost-ledger precedent).
247
+ // Declared among the archon routes (archon-first) for correct auditor classification.
248
+ router.get('/provisioning/fleet/cost-ledger', auth.requireBuilder, auth.requireRank('archon'), async (req, res) => {
249
+ try {
250
+ // R14 (#2001 / ADR 0119): shared helper; per-endpoint cap preserved (default 500, max 2000).
251
+ const { limit } = api.parsePagination(req, { defaultLimit: 500, maxLimit: 2000 });
252
+ const out = await provisioning.costLedger(pool, { limit });
253
+ res.json(out);
254
+ } catch (err) {
255
+ console.error('[provisioning] GET /provisioning/fleet/cost-ledger', err);
256
+ res.fail('cost_ledger_failed', { status: 500, message: 'internal error' });
257
+ }
258
+ });
259
+
260
+ // POST /provisioning/instances/:id/force-teardown — Archon tears down ANY
261
+ // instance. Trust-safe by the same mechanism as the owner's own teardown: it
262
+ // ENQUEUES a 'teardown' intent for the control-plane runner; the web tier never
263
+ // holds cloud tokens. rank: archon (acts on another owner's infrastructure).
264
+ // Declared among the archon routes (archon-first).
265
+ router.post('/provisioning/instances/:id/force-teardown', auth.requireBuilder, auth.requireRank('archon'), async (req, res) => {
266
+ const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
267
+ if (!id) return;
268
+ try {
269
+ const inst = await provisioning.getInstanceById(pool, id);
270
+ if (!inst) return res.fail('instance_not_found', 404);
271
+ if (!provisioning.TEARDOWN_FROM.has(inst.status)) {
272
+ return res.fail('not_teardownable', 409, { status: inst.status });
273
+ }
274
+ const { created } = await provisioning.enqueueIntent(pool, id, 'teardown', `api:archon:${req.builder.id}`);
275
+ if (created) {
276
+ await provisioning.recordEvent(pool, {
277
+ instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'teardown',
278
+ detail: `force-teardown by archon ${req.builder.github_login || req.builder.id}`,
279
+ actor: `api:archon:${req.builder.id}`,
280
+ }).catch(() => {});
281
+ }
282
+ res.json({ ok: true, queued: created, action: 'teardown', status: inst.status,
283
+ message: created ? 'The instance will be torn down shortly.' : 'A teardown is already queued for that instance.' });
284
+ } catch (err) {
285
+ console.error('[provisioning] POST /provisioning/instances/:id/force-teardown', err);
286
+ res.fail('teardown_failed', { status: 500, message: 'internal error' });
287
+ }
288
+ });
289
+
290
+ // POST /provisioning/instances — request a new instance. rank: any authenticated
291
+ // builder (own resource). ENQUEUES a 'provision' intent + records the request;
292
+ // the runner (task P3) does the real standup. Idempotent on slug for the SAME
293
+ // owner (re-request returns the existing row); a slug owned by ANOTHER builder → 409.
294
+ // Body: { slug (required), target_ref?, hosting_shape? (co-tenant|dedicated),
295
+ // domain?, tier? }.
296
+ router.post('/provisioning/instances', auth.requireBuilder, async (req, res) => {
297
+ if (validateOrRespond(req, res, {
298
+ slug: { required: true, type: 'string', maxLength: 40 },
299
+ target_ref: { type: 'string', maxLength: 200 },
300
+ hosting_shape: { type: 'string', maxLength: 20 },
301
+ domain: { type: 'string', maxLength: 253 },
302
+ tier: { type: 'string', maxLength: 40 },
303
+ })) return;
304
+ const body = req.body || {};
305
+ const slug = String(body.slug || '').trim().toLowerCase();
306
+ if (!provisioning.isValidSlug(slug)) {
307
+ return res.fail('bad_slug', 400, { reason: 'lowercase, 2–40 chars of [a-z0-9-], no leading/trailing/double dash' });
308
+ }
309
+ const shape = body.hosting_shape || 'co-tenant';
310
+ if (!provisioning.HOSTING_SHAPES.includes(shape)) {
311
+ return res.fail('bad_hosting_shape', 400, { valid: provisioning.HOSTING_SHAPES });
312
+ }
313
+ try {
314
+ const { instance, created } = await provisioning.createInstance(pool, {
315
+ ownerBuilderId: req.builder.id, slug,
316
+ targetRef: body.target_ref, hostingShape: shape,
317
+ domain: body.domain, tier: body.tier,
318
+ });
319
+ // Slug already taken by ANOTHER builder — refuse (never reassign ownership).
320
+ if (!created && String(instance.owner_builder_id) !== String(req.builder.id)) {
321
+ return res.fail('slug_taken', { status: 409, message: 'That instance slug is already in use.' });
322
+ }
323
+ // Enqueue the provision intent (idempotent: an already-open intent is returned).
324
+ const { intent, created: intentCreated } = await provisioning.enqueueIntent(pool, instance.id, 'provision', 'api:self');
325
+ if (created) {
326
+ await provisioning.recordEvent(pool, {
327
+ instanceId: instance.id, ownerBuilderId: req.builder.id, event: 'request',
328
+ detail: `provision requested (shape=${shape}${body.domain ? `, domain=${body.domain}` : ''})`,
329
+ actor: 'api:self',
330
+ }).catch(() => {});
331
+ }
332
+ res.status(created ? 201 : 200).json({
333
+ instance: provisioning.publicInstance(instance),
334
+ open_intent: intent ? { action: intent.action, state: intent.state } : null,
335
+ queued: intentCreated,
336
+ created,
337
+ });
338
+ } catch (err) {
339
+ console.error('[provisioning] POST /provisioning/instances', err);
340
+ res.fail('request_failed', { status: 500, message: 'internal error' });
341
+ }
342
+ });
343
+
344
+ // GET /provisioning/instances — the caller's own instances + each open intent.
345
+ // rank: any authenticated builder (own resource read).
346
+ router.get('/provisioning/instances', auth.requireBuilder, async (req, res) => {
347
+ try {
348
+ const rows = await provisioning.listInstancesForOwner(pool, req.builder.id);
349
+ const instances = [];
350
+ for (const row of rows) {
351
+ const open = await provisioning.getOpenIntent(pool, row.id);
352
+ instances.push({
353
+ ...provisioning.publicInstance(row),
354
+ open_intent: open ? { action: open.action, state: open.state } : null,
355
+ });
356
+ }
357
+ res.json({ instances });
358
+ } catch (err) {
359
+ console.error('[provisioning] GET /provisioning/instances', err);
360
+ res.fail('list_failed', { status: 500, message: 'internal error' });
361
+ }
362
+ });
363
+
364
+ // GET /provisioning/instances/:id — read one of the caller's own instances.
365
+ // rank: any authenticated builder (own resource). 404 when the id isn't the
366
+ // caller's (own-scoped; does not leak another owner's instance existence).
367
+ router.get('/provisioning/instances/:id', auth.requireBuilder, async (req, res) => {
368
+ const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
369
+ if (!id) return;
370
+ try {
371
+ const inst = await provisioning.getInstanceById(pool, id);
372
+ if (!inst || String(inst.owner_builder_id) !== String(req.builder.id)) {
373
+ return res.fail('instance_not_found', 404);
374
+ }
375
+ const open = await provisioning.getOpenIntent(pool, id);
376
+ res.json({
377
+ instance: provisioning.publicInstance(inst),
378
+ open_intent: open ? { action: open.action, state: open.state } : null,
379
+ });
380
+ } catch (err) {
381
+ console.error('[provisioning] GET /provisioning/instances/:id', err);
382
+ res.fail('read_failed', { status: 500, message: 'internal error' });
383
+ }
384
+ });
385
+
386
+ // POST /provisioning/instances/:id/teardown — request teardown of the caller's
387
+ // OWN instance. rank: any authenticated builder (own resource). Enqueues a
388
+ // 'teardown' intent; the runner executes it (web tier holds no tokens). 404 when
389
+ // the id isn't the caller's; 409 when the instance isn't in a teardownable state.
390
+ router.post('/provisioning/instances/:id/teardown', auth.requireBuilder, async (req, res) => {
391
+ const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
392
+ if (!id) return;
393
+ try {
394
+ const inst = await provisioning.getInstanceById(pool, id);
395
+ if (!inst || String(inst.owner_builder_id) !== String(req.builder.id)) {
396
+ return res.fail('instance_not_found', 404);
397
+ }
398
+ if (!provisioning.TEARDOWN_FROM.has(inst.status)) {
399
+ return res.fail('not_teardownable', { status: 409, message: `Instance is '${inst.status}' — nothing to tear down right now.`, details: { status: inst.status } });
400
+ }
401
+ const { created } = await provisioning.enqueueIntent(pool, id, 'teardown', 'api:self');
402
+ if (created) {
403
+ await provisioning.recordEvent(pool, {
404
+ instanceId: id, ownerBuilderId: req.builder.id, event: 'teardown',
405
+ detail: 'teardown requested by owner', actor: 'api:self',
406
+ }).catch(() => {});
407
+ }
408
+ res.json({ ok: true, queued: created, action: 'teardown', status: inst.status,
409
+ message: created ? 'Your instance will be torn down shortly.' : 'A teardown is already queued for that instance.' });
410
+ } catch (err) {
411
+ console.error('[provisioning] POST /provisioning/instances/:id/teardown', err);
412
+ res.fail('teardown_failed', { status: 500, message: 'internal error' });
413
+ }
414
+ });
415
+
416
+ // POST /provisioning/instances/:id/github-app — mint a GitHub-App-Manifest onboarding
417
+ // link for the caller's OWN instance (task 2080, ADR 0133). rank: any authenticated
418
+ // builder (own resource). Returns the pre-filled manifest + the GitHub form-POST target
419
+ // (personal + org variants) + a one-time `state`; the browser POSTs the manifest to
420
+ // GitHub, the owner approves, and GitHub redirects back to the public callback below.
421
+ // The web tier only RECORDS the attempt (state token) — the control-plane runner does
422
+ // the token exchange + cred placement (trust boundary intact, ADR 0111 §2).
423
+ // Body: { account? ('personal'|'org') }.
424
+ router.post('/provisioning/instances/:id/github-app', auth.requireBuilder, async (req, res) => {
425
+ const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
426
+ if (!id) return;
427
+ if (validateOrRespond(req, res, { account: { type: 'string', maxLength: 12 } })) return;
428
+ try {
429
+ const inst = await provisioning.getInstanceById(pool, id);
430
+ if (!inst || String(inst.owner_builder_id) !== String(req.builder.id)) {
431
+ return res.fail('instance_not_found', 404);
432
+ }
433
+ // A domain is required: it is the App's homepage + the sign-in callback host. No
434
+ // domain ⇒ there is nothing to point "Login with GitHub" at yet.
435
+ if (!inst.domain) {
436
+ return res.fail('domain_required', { status: 409, message: 'Set the instance domain before wiring GitHub sign-in — the App needs the real callback host.' });
437
+ }
438
+ const origin = mothershipOrigin();
439
+ if (!origin) {
440
+ return res.fail('mothership_origin_unset', { status: 500, message: 'This control plane has no publicOrigin configured — cannot build the GitHub redirect URL.' });
441
+ }
442
+ const state = crypto.randomBytes(32).toString('hex');
443
+ const expiresAt = new Date(Date.now() + MANIFEST_STATE_TTL_MS).toISOString();
444
+ await provisioning.createOAuthManifest(pool, { instanceId: id, state, requestedBy: req.builder.id, expiresAt });
445
+ await provisioning.recordEvent(pool, {
446
+ instanceId: id, ownerBuilderId: req.builder.id, event: 'oauth-manifest-start',
447
+ detail: 'GitHub App Manifest link minted', actor: 'api:self',
448
+ }).catch(() => {});
449
+
450
+ const manifest = provisioning.buildGithubAppManifest({
451
+ instanceName: inst.slug, slug: inst.slug, instanceDomain: inst.domain,
452
+ mothershipOrigin: origin, brand: mothershipBrand(),
453
+ });
454
+ const orgLogin = ownerFromTargetRef(inst.target_ref);
455
+ const account = req.body && req.body.account === 'org' && orgLogin ? 'org' : 'personal';
456
+ res.json({
457
+ state,
458
+ account,
459
+ // The browser POSTs `manifest` (as a form field) to this URL; GitHub echoes state.
460
+ post_url: provisioning.manifestPostUrl({ account, orgLogin, state }),
461
+ personal_url: provisioning.manifestPostUrl({ account: 'personal', state }),
462
+ org_url: orgLogin ? provisioning.manifestPostUrl({ account: 'org', orgLogin, state }) : null,
463
+ org_login: orgLogin,
464
+ manifest,
465
+ redirect_url: manifest.redirect_url,
466
+ expires_at: expiresAt,
467
+ });
468
+ } catch (err) {
469
+ console.error('[provisioning] POST /provisioning/instances/:id/github-app', err);
470
+ res.fail('github_app_manifest_failed', { status: 500, message: 'internal error' });
471
+ }
472
+ });
473
+
474
+ // GET /provisioning/health — liveness + the core version this module mounted
475
+ // against. rank: public (a plain liveness read; no write, no sensitive data).
476
+ router.get('/provisioning/health', (req, res) => {
477
+ res.json({ module: 'provisioning', ok: true, coreVersion: api.CORE_VERSION });
478
+ });
479
+
480
+ // GET /provisioning/onboard-plan?mode=greenfield|adopt — the CANONICAL onboarding
481
+ // step sequence (task 2085; goal 35 / criterion 165: "the canonical step sequence is
482
+ // the source of truth; skill/CLI/hall are forms over it"). The branded /create page +
483
+ // the hall wizard fetch it so their human-steps checklist can't drift from the CLI's
484
+ // (scripts/gds/onboard.js reads the same modules/provisioning/onboard-plan.js).
485
+ // rank: public — the signed-out /create landing page reads it; it is the same public
486
+ // runbook the /new-project skill documents (no write, no secret).
487
+ router.get('/provisioning/onboard-plan', (req, res) => {
488
+ const mode = req.query.mode === 'adopt' ? 'adopt' : 'greenfield';
489
+ res.json({ mode, steps: onboardPlan(mode) });
490
+ });
491
+
492
+ // GET /provisioning/tls-check?domain=<host> — the Caddy on-demand-TLS ALLOW-LIST
493
+ // (ADR 0111 §4, task P4 #1945). Caddy's global `on_demand_tls { ask … }` calls
494
+ // this before fetching a Let's Encrypt cert for an SNI it doesn't already have:
495
+ // HTTP 200 authorizes, any non-200 refuses. We authorize ONLY a hostname this
496
+ // module actually provisioned + is still serving (provisioning|active), so
497
+ // on-demand TLS can never be abused to mint certs for arbitrary names (the
498
+ // cert-flood guard). FAILS CLOSED — any error denies (503). rank: public (Caddy
499
+ // calls it locally over 127.0.0.1 with no session; a plain read, no mutation, no
500
+ // secret — it only reveals whether a host is a live provisioned instance).
501
+ //
502
+ // RATE-LIMITED (SEC review of task 1945). The route is reachable from the
503
+ // internet via the apex proxy, and core's global limiter bypasses reads while the
504
+ // 120/min public-read cap is mounted only under /public — this module mounts
505
+ // under /api/bongos, so it inherits neither. Without a limiter the endpoint is an
506
+ // unthrottled enumeration oracle + issuance-load amplifier. Core's limiter isn't
507
+ // reachable from a module (the one-way import rule; and the kernel doorway may not
508
+ // re-export it — it's a non-kernel core file, ADR 0091 §1), so we apply a
509
+ // dedicated, tight in-module limiter (askRateLimit). Caddy's own on_demand_tls
510
+ // interval/burst throttle is NOT an option (removed in Caddy 2.x — the `ask`
511
+ // endpoint IS the throttle and must self-limit).
512
+ router.get('/provisioning/tls-check', askRateLimit, async (req, res) => {
513
+ try {
514
+ const ok = await provisioning.isProvisionedHostname(pool, req.query.domain);
515
+ return res.status(ok ? 200 : 404).json({ ok });
516
+ } catch (err) {
517
+ console.error('[provisioning] GET /provisioning/tls-check', err);
518
+ return res.status(503).json({ ok: false, error: 'check_failed' });
519
+ }
520
+ });
521
+
522
+ // GET /provisioning/github-app/status?state=<> — the PUBLIC progress read the onboarding
523
+ // callback page polls (task 2096) to know when setup is done + where to send the owner.
524
+ // Same public-by-state model as the callback: the unguessable, single-use `state` token
525
+ // is the only key, and the response reveals only the manifest lifecycle status + the
526
+ // instance's own (already-public) domain — never a secret. Per-IP rate-limited because
527
+ // the page polls it every ~3s. `ready` is true iff the manifest reached 'done' (creds
528
+ // placed + /healthz verified) — the page then auto-redirects to https://<domain>.
529
+ // rank: public — no session, no mutation, no secret; an unknown state is a 404 no-op.
530
+ router.get('/provisioning/github-app/status', statusRateLimit, async (req, res) => {
531
+ try {
532
+ const state = String(req.query.state || '');
533
+ if (!state) return res.status(400).json({ ok: false, error: 'missing_state' });
534
+ const row = await provisioning.getOAuthManifestByState(pool, state);
535
+ if (!row) return res.status(404).json({ ok: false, error: 'unknown_state' });
536
+ const inst = await provisioning.getInstanceById(pool, row.instance_id).catch(() => null);
537
+ const status = row.status;
538
+ return res.json({
539
+ ok: true,
540
+ status,
541
+ ready: status === 'done',
542
+ terminal: status === 'done' || status === 'error' || status === 'expired',
543
+ domain: inst && inst.domain ? inst.domain : null,
544
+ instance_status: inst ? inst.status : null,
545
+ });
546
+ } catch (err) {
547
+ console.error('[provisioning] GET /provisioning/github-app/status', err);
548
+ return res.status(500).json({ ok: false, error: 'status_check_failed' });
549
+ }
550
+ });
551
+
552
+ // GET /provisioning/github-app/callback?code=&state= — GitHub redirects the owner's
553
+ // BROWSER here after they approve the App Manifest (task 2080, ADR 0133). It is a
554
+ // browser redirect with no session guaranteed, so it is PUBLIC — the unguessable,
555
+ // single-use, TTL'd `state` token (minted only for the signed-in owner and matched
556
+ // against provisioning_oauth_manifests) is the authorization, exactly as GitHub's own
557
+ // conversion `code` is "the code IS the auth". The web tier ONLY stores the code +
558
+ // flips status → 'code_received'; the control-plane runner (the sole token-holder)
559
+ // exchanges it for the client secret and places it in web.env. The secret NEVER
560
+ // touches the browser or the web tier. rank: public — no session, no mutation of
561
+ // authority, no secret revealed; a bad/expired/replayed state is a no-op error page.
562
+ router.get('/provisioning/github-app/callback', async (req, res) => {
563
+ const code = String(req.query.code || '');
564
+ const state = String(req.query.state || '');
565
+ const sendPage = (status, page) => res.status(status).type('html').send(page);
566
+ try {
567
+ if (!code || !state) {
568
+ return sendPage(400, callbackPage({ ok: false, title: 'Missing GitHub response',
569
+ body: '<p>GitHub didn’t send back a code and state. Start the sign-in setup again from your instance’s page.</p>' }));
570
+ }
571
+ const row = await provisioning.getOAuthManifestByState(pool, state);
572
+ // Unknown state, wrong status, or expired → generic refusal (no oracle about which).
573
+ if (!row || row.status !== 'pending' || new Date(row.expires_at).getTime() <= Date.now()) {
574
+ return sendPage(400, callbackPage({ ok: false, title: 'This setup link has expired',
575
+ body: '<p>The one-time link is no longer valid (it may have already been used, or it timed out). Open your instance’s page and start the GitHub sign-in setup again.</p>' }));
576
+ }
577
+ const attached = await provisioning.attachManifestCode(pool, row.id, code);
578
+ if (!attached) {
579
+ // Lost a race (another callback consumed it) or it just expired — same message.
580
+ return sendPage(400, callbackPage({ ok: false, title: 'This setup link has expired',
581
+ body: '<p>The one-time link is no longer valid. Open your instance’s page and start the GitHub sign-in setup again.</p>' }));
582
+ }
583
+ await provisioning.recordEvent(pool, {
584
+ instanceId: row.instance_id, ownerBuilderId: row.requested_by, event: 'oauth-manifest-code',
585
+ detail: 'GitHub returned the App Manifest code; awaiting control-plane conversion', actor: 'github',
586
+ }).catch(() => {});
587
+ const inst = await provisioning.getInstanceById(pool, row.instance_id).catch(() => null);
588
+ const domain = inst && inst.domain ? inst.domain : null;
589
+ // Progress variant (task 2096): the page polls …/github-app/status?state= and
590
+ // auto-opens the instance the moment its credentials are placed — no "come back
591
+ // later" dead-end. Falls back to a static success if we have no domain to poll.
592
+ return sendPage(200, domain
593
+ ? callbackPage({ ok: true, title: 'Setting up your instance', state, domain })
594
+ : callbackPage({ ok: true, title: 'GitHub app created',
595
+ body: '<p>Nice — GitHub created the app. We’re placing its credentials on your instance now; this finishes on its own within a few minutes.</p><p class="muted">You can close this tab.</p>' }));
596
+ } catch (err) {
597
+ console.error('[provisioning] GET /provisioning/github-app/callback', err);
598
+ return sendPage(500, callbackPage({ ok: false, title: 'Something went wrong',
599
+ body: '<p>We couldn’t record GitHub’s response. Try starting the sign-in setup again from your instance’s page.</p>' }));
600
+ }
601
+ });
602
+
603
+ return router;
604
+ };
605
+
606
+ // Re-export the seam registrar for tests that assert the port is registered.
607
+ module.exports.registerProvisioningSeams = registerProvisioningSeams;