@bongos/core 1.19.331 → 1.19.333

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 (61) hide show
  1. package/.bongos-core.json +163 -43
  2. package/docs/api/openapi.json +2 -2
  3. package/docs/api-reference.md +1 -1
  4. package/docs/copy-inventory.md +202 -202
  5. package/docs/copy-registry.json +384 -384
  6. package/modules/grading/grader-config.js +98 -0
  7. package/modules/grading/grader-model.js +150 -0
  8. package/modules/grading/grader-prompt.js +139 -0
  9. package/modules/grading/grader-score.js +706 -0
  10. package/modules/grading/grader-subagent.js +328 -0
  11. package/modules/grading/grader.js +5 -1287
  12. package/modules/hall-ui/public/builders.js +19 -1182
  13. package/modules/hall-ui/public/goals-page.js +11 -997
  14. package/modules/hall-ui/public/goals-render.js +1032 -0
  15. package/modules/hall-ui/public/goals.html +4 -0
  16. package/modules/hall-ui/public/hall-render.js +1211 -0
  17. package/modules/hall-ui/public/index.html +6 -0
  18. package/modules/lifecycle/routes/goal-route-authz.js +400 -0
  19. package/modules/lifecycle/routes/goals.js +1 -367
  20. package/modules/lifecycle/routes/task-route-helpers.js +219 -0
  21. package/modules/lifecycle/routes/task-write-routes.js +542 -0
  22. package/modules/lifecycle/routes/tasks.js +3 -696
  23. package/package-lock.json +2 -2
  24. package/package.json +1 -1
  25. package/public-landing/assets/cosmos.css +4 -0
  26. package/public-landing/projects.html +103 -23
  27. package/scripts/gds/box-infra.js +280 -0
  28. package/scripts/gds/box.js +1 -245
  29. package/scripts/gds/fitness-checks-identity.js +320 -0
  30. package/scripts/gds/fitness-checks-vocabulary.js +230 -0
  31. package/scripts/gds/fitness-checks-write-validation.js +144 -0
  32. package/scripts/gds/fitness-lib.js +82 -0
  33. package/scripts/gds/fitness.js +55 -668
  34. package/scripts/gds/provision-config.js +204 -0
  35. package/scripts/gds/provision-net.js +221 -0
  36. package/scripts/gds/provision-repo.js +849 -0
  37. package/scripts/gds/provision-units.js +262 -0
  38. package/scripts/gds/provision.js +4 -1395
  39. package/src/bongos/auth-admission.js +264 -0
  40. package/src/bongos/auth-config.js +151 -0
  41. package/src/bongos/auth-discord.js +198 -0
  42. package/src/bongos/auth-github.js +336 -0
  43. package/src/bongos/auth-idp.js +330 -0
  44. package/src/bongos/auth.js +5 -1100
  45. package/src/module-api.js +5 -1
  46. package/tests/auth_admission_gate.mjs +3 -1
  47. package/tests/est_advisory.mjs +3 -1
  48. package/tests/goal_advisory.mjs +3 -2
  49. package/tests/goal_criterion_confirm_ui.mjs +6 -0
  50. package/tests/goal_subtitle.mjs +5 -2
  51. package/tests/goals_card_view.mjs +10 -3
  52. package/tests/grader_generated_provenance.mjs +3 -1
  53. package/tests/hall_palette.mjs +3 -1
  54. package/tests/helpers.mjs +58 -25
  55. package/tests/lib_sh_resolution.mjs +3 -1
  56. package/tests/membership_kind_lockstep.mjs +3 -1
  57. package/tests/migration_allocation.mjs +5 -2
  58. package/tests/patch_gate_ordering.mjs +3 -1
  59. package/tests/projects_hub_pre_uat.mjs +53 -0
  60. package/tests/rank_tier_single_source.mjs +16 -3
  61. package/tests/reward_gate.mjs +3 -2
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.331",
3
+ "version": "1.19.333",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.331",
9
+ "version": "1.19.333",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.331",
3
+ "version": "1.19.333",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -414,6 +414,10 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
414
414
  /* ── page-level bits ── */
415
415
  .alert{display:none;background:var(--err-soft);border:2px solid color-mix(in srgb,var(--err) 25%,transparent);color:var(--err);font-weight:600;font-size:14px;border-radius:14px;padding:12px 16px;margin-bottom:18px;}
416
416
  .alert.on{display:block;}
417
+ /* the raw server text folds under the alert's plain sentence (task 1003244) */
418
+ .alert details{margin-top:8px;}
419
+ .alert summary{cursor:pointer;font-size:12.5px;font-weight:700;}
420
+ .alert details p{margin-top:6px;font-family:var(--font-mono);font-size:12px;font-weight:500;overflow-wrap:anywhere;}
417
421
  .loading{font-size:14.5px;font-weight:600;color:var(--ink-faint);padding:14px 0;}
418
422
  .empty{border:2px dashed var(--line);border-radius:18px;padding:22px;text-align:center;}
419
423
  .empty p{font-size:14.5px;font-weight:600;color:var(--ink-soft);margin-bottom:14px;line-height:1.55;}
@@ -614,7 +614,7 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
614
614
  stripFor/errMarkup/canRetry/canRevive the list card reads -->
615
615
  <div id="mStrip"></div>
616
616
  <div class="projMsg err" id="mErr" hidden></div>
617
- <div class="projMsg" id="mMsg"></div>
617
+ <div class="projMsg" id="mMsg" role="status" aria-live="polite"></div>
618
618
  <div class="actions" id="mActs" hidden></div>
619
619
  </div>
620
620
 
@@ -683,7 +683,7 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
683
683
  <div class="wrap viewWrap">
684
684
  <div class="card">
685
685
  <ol class="steps" aria-label="Creation steps">
686
- <li class="now" data-step="1"><span class="snum">1</span><span class="slbl">Name</span></li>
686
+ <li class="now" data-step="1" aria-current="step"><span class="snum">1</span><span class="slbl">Name</span></li>
687
687
  <li data-step="2"><span class="snum">2</span><span class="slbl">Type</span></li>
688
688
  <li data-step="3"><span class="snum">3</span><span class="slbl">Home</span></li>
689
689
  <li data-step="4"><span class="snum">4</span><span class="slbl">Address</span></li>
@@ -2400,16 +2400,27 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
2400
2400
  Six steps since task 1002772: gate 0 · name 1 · type 2 · home 3 ·
2401
2401
  address 4 · review 5 · done 6 (the rail shows 1-5; done marks it all). */
2402
2402
  var panels = { 0: 'panel-gate', 1: 'panel-name', 2: 'panel-type', 3: 'panel-home', 4: 'panel-address', 5: 'panel-review', 6: 'panel-done' };
2403
+ /* the rail at the done panel (task 1003244): "done" is the Create step
2404
+ still running — the marker stays on 5 until the project actually
2405
+ answers, and only then does the whole rail read finished. aria-current
2406
+ tells a screen reader the same thing. */
2407
+ function paintRail(step) {
2408
+ var landed = step === 6 && !!(live && live.instStatus === 'active');
2409
+ var cur = step === 6 ? 5 : step;
2410
+ root.querySelectorAll('.steps li').forEach(function (li) {
2411
+ var n = Number(li.getAttribute('data-step'));
2412
+ var now = !landed && n === cur;
2413
+ li.classList.toggle('now', now);
2414
+ li.classList.toggle('done', landed || n < cur);
2415
+ if (now) li.setAttribute('aria-current', 'step'); else li.removeAttribute('aria-current');
2416
+ });
2417
+ }
2403
2418
  function show(step, moveFocus) {
2404
2419
  state.step = step;
2405
2420
  Object.keys(panels).forEach(function (k) {
2406
2421
  $(panels[k]).classList.toggle('on', Number(k) === step);
2407
2422
  });
2408
- root.querySelectorAll('.steps li').forEach(function (li) {
2409
- var n = Number(li.getAttribute('data-step'));
2410
- li.classList.toggle('now', n === step);
2411
- li.classList.toggle('done', n < step || step === 6);
2412
- });
2423
+ paintRail(step);
2413
2424
  saveDraft();
2414
2425
  /* the done step takes the whole card — the bongo pair steps aside */
2415
2426
  root.querySelector('.createSplit').classList.toggle('wizDone', step === 6);
@@ -2782,14 +2793,24 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
2782
2793
  }
2783
2794
 
2784
2795
  /* ── create ── */
2796
+ /* the mapped codes are headline-only; anything else gets a fixed owner
2797
+ sentence with the raw text folded under Technical details (direction
2798
+ §5.1, task 1003244) — operator text is never the headline */
2785
2799
  function friendlyCreateError(err) {
2786
2800
  var code = err && err.data && err.data.error && (err.data.error.code || '');
2787
2801
  if (code === 'slug_taken') return 'That handle is already taken by another project. Pick a different one.';
2788
2802
  if (code === 'bad_slug') return 'The handle didn’t pass the server’s checks — 2–40 lowercase letters, numbers, or dashes.';
2789
- if (code === 'domain_reserved') return 'That address is reserved for the platform itself. Pick a different handle, or bring a domain you own.';
2803
+ /* both server branches: a reserved hostname, and any name under the platform's own apex */
2804
+ if (code === 'domain_reserved') return 'That address belongs to the platform’s own domain — your project already gets its own address there from its handle. Use a domain you own instead.';
2790
2805
  if (code === 'domain_taken') return 'That address is already held by another project. Pick a different handle or domain.';
2791
2806
  if (code === 'bad_domain') return 'The domain didn’t parse as a real hostname — use something like myproject.example.com.';
2792
- return 'Couldn’t file the request: ' + (err && err.message ? err.message : 'something went sideways. Try again.');
2807
+ if (code === 'repo_scope_required') return 'GitHub hasn’t granted repository access for this session. Sign in again — it asks for repository access — and try once more.';
2808
+ if (code === 'request_failed') return 'The platform couldn’t file the request. Nothing was created — try again in a moment.';
2809
+ return '';
2810
+ }
2811
+ function createFailureMarkup(err) {
2812
+ return 'We couldn’t file the request just now. Nothing was created — you can try again.' +
2813
+ '<details><summary>Technical details</summary><p>' + esc(err && err.message ? err.message : 'no detail came back') + '</p></details>';
2793
2814
  }
2794
2815
 
2795
2816
  /* Fresh + repo access: auto-create the empty GitHub repo NOW (the commit moment),
@@ -2901,7 +2922,9 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
2901
2922
  $('h-home').className = 'hint err';
2902
2923
  return;
2903
2924
  }
2904
- alertBox.textContent = friendlyCreateError(err);
2925
+ var friendly = friendlyCreateError(err);
2926
+ if (friendly) alertBox.textContent = friendly;
2927
+ else alertBox.innerHTML = createFailureMarkup(err);
2905
2928
  alertBox.classList.add('on');
2906
2929
  });
2907
2930
  });
@@ -2918,7 +2941,7 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
2918
2941
  var GH_ICON = '<svg class="gh" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>';
2919
2942
 
2920
2943
  var instanceId = null; /* set when a create lands / a resumed watch starts */
2921
- var live = { instStatus: null, manifest: null, manifestMsg: '', appSlug: null };
2944
+ var live = { instStatus: null, manifest: null, manifestMsg: '', manifestCode: '', appSlug: null };
2922
2945
  var planPromises = {}; /* mode|domain → Promise<steps[]> */
2923
2946
  var lastStepsHtml = '';
2924
2947
  var FALLBACK_HUMAN_KEYS = ['github-repo', 'oauth-app', 'device-flow', 'app-install', 'first-sign-in', 'enter-hall'];
@@ -3000,8 +3023,15 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
3000
3023
  return '<li class="sdone" data-onboard-step="oauth-app"><b>GitHub sign-in is wired</b><p>Credentials placed and verified — sign-in on <code style="font-family:var(--font-mono)">' + esc(domain) + '</code> is live.</p></li>';
3001
3024
  }
3002
3025
  if (live.manifest === 'error' || live.manifest === 'expired') {
3003
- return '<li data-onboard-step="oauth-app"><b>Wire GitHub sign-in</b><p>' +
3004
- (live.manifest === 'expired' ? 'That setup link expired (they last 30 minutes) — mint a fresh one.' : 'GitHub setup hit a snag' + (live.manifestMsg ? ': ' + esc(live.manifestMsg) : '') + '. Try again.') + '</p>' +
3026
+ /* a plain sentence first; the raw route text folds under Technical
3027
+ details (task 1003244). The one refusal a founder can act on — no
3028
+ address yet — says where to go instead of what the server said. */
3029
+ var snag, raw = '';
3030
+ if (live.manifest === 'expired') snag = 'That setup link expired (they last 30 minutes) — mint a fresh one.';
3031
+ else if (live.manifestCode === 'domain_required') snag = 'This project needs an address first — attach one from <a href="?view=mine">your projects page</a>, then come back to this step.';
3032
+ else { snag = 'GitHub setup didn’t start. Nothing was changed on GitHub — try again.'; raw = live.manifestMsg; }
3033
+ return '<li data-onboard-step="oauth-app"><b>Wire GitHub sign-in</b><p>' + snag + '</p>' +
3034
+ (raw ? '<details><summary>Technical details</summary><p>' + esc(raw) + '</p></details>' : '') +
3005
3035
  '<button type="button" class="ghbtn" data-action="manifest-start">' + GH_ICON + 'Set up GitHub sign-in</button>' + manualOauthDetails() + '</li>';
3006
3036
  }
3007
3037
  if (live.manifest === 'code_received') {
@@ -3186,6 +3216,7 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
3186
3216
  return res.json().catch(function () { return {}; }).then(function (d) {
3187
3217
  if (!res.ok) {
3188
3218
  var e = new Error((d.error && (d.error.message || d.error.code)) || ('HTTP ' + res.status));
3219
+ e.code = (d.error && d.error.code) || '';
3189
3220
  throw e;
3190
3221
  }
3191
3222
  return d;
@@ -3206,6 +3237,7 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
3206
3237
  if (String(err && err.message) === 'signing in') return;
3207
3238
  if (tab) { try { tab.close(); } catch (e) {} }
3208
3239
  live.manifest = 'error'; live.manifestMsg = String((err && err.message) || 'could not start');
3240
+ live.manifestCode = String((err && err.code) || '');
3209
3241
  renderSteps();
3210
3242
  });
3211
3243
  }
@@ -3380,7 +3412,10 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
3380
3412
  /* the checklist reflects live state (task 1002694): a status transition —
3381
3413
  e.g. the scaffold landing — re-renders the steps (change-only, so the
3382
3414
  5s tick never clobbers an open dropdown or mid-flight button) */
3383
- if (inst && inst.status !== live.instStatus) { live.instStatus = inst.status; renderSteps(); }
3415
+ if (inst && inst.status !== live.instStatus) {
3416
+ live.instStatus = inst.status; renderSteps();
3417
+ if (state.step === 6) paintRail(6); /* the rail finishes only when the project answers */
3418
+ }
3384
3419
  var terminal = renderProgress(inst);
3385
3420
  if (!terminal) pollTimer = setTimeout(tick, 5000);
3386
3421
  else stopPolling();
@@ -3412,7 +3447,7 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
3412
3447
  instanceId = null;
3413
3448
  watchPending = false; manifestPending = false; manifestState = null;
3414
3449
  manifestStartedAt = 0; manifestStale = false;
3415
- live = { instStatus: null, manifest: null, manifestMsg: '', appSlug: null };
3450
+ live = { instStatus: null, manifest: null, manifestMsg: '', manifestCode: '', appSlug: null };
3416
3451
  lastStepsHtml = '';
3417
3452
  try { sessionStorage.removeItem(CREATED_KEY); sessionStorage.removeItem(DRAFT_KEY); } catch (e) {}
3418
3453
  state = { step: 1, name: '', projType: null, mode: 'greenfield', owner: '', ownerTouched: false, repo: '', repoTouched: false, slug: '', slugTouched: false, domain: '', noAddress: false };
@@ -3882,7 +3917,9 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
3882
3917
  : '. The repository is still yours, and its database is kept — bringing it back takes one click. Or <a href="?view=new">start another</a>.</div>');
3883
3918
  }
3884
3919
  var lm = lastMsg[inst.id];
3885
- body += '<div class="projMsg' + (lm && lm.cls ? ' ' + lm.cls : '') + '" id="msg-' + iid + '">' + (lm ? lm.text : '') + '</div>';
3920
+ /* a live region: an action's result is announced where the pointer is,
3921
+ not only painted (task 1003244) */
3922
+ body += '<div class="projMsg' + (lm && lm.cls ? ' ' + lm.cls : '') + '" id="msg-' + iid + '" role="status" aria-live="polite">' + (lm ? lm.text : '') + '</div>';
3886
3923
  /* every owned card opens its manage view (task 1003041) — a real link, so
3887
3924
  middle/ctrl-click works; the delegated handler upgrades a plain click */
3888
3925
  var acts = ['<a class="btn small ghost" data-manage="' + iid + '" href="?view=manage&id=' + iid + '">Manage</a>'];
@@ -3903,12 +3940,51 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
3903
3940
  var keepOpen = !!(oldFold && oldFold.open);
3904
3941
  var typed = oldInput && oldInput.value !== oldInput.defaultValue ? oldInput.value : '';
3905
3942
  var hadFocus = !!oldInput && document.activeElement === oldInput;
3943
+ /* focus anywhere else on the card (a door, a confirm, the fold's summary)
3944
+ comes back to the same control — or the nearest one — after the swap;
3945
+ quietActions() leaves a note when a disabled button already let go */
3946
+ var wasInside = li.contains(document.activeElement) && document.activeElement !== li;
3947
+ var prefer = wasInside ? focusKeyOf(document.activeElement) : null;
3948
+ var focusBack = wasInside || li.hasAttribute('data-focus-back');
3949
+ li.removeAttribute('data-focus-back');
3906
3950
  li.innerHTML = orbFor(inst) + '<div class="projBody">' + body + '</div>';
3907
3951
  var fold = li.querySelector('details.attachFold');
3908
3952
  var input = fold ? fold.querySelector('input') : null;
3909
3953
  if (fold && keepOpen) fold.open = true;
3910
3954
  if (input && typed) input.value = typed;
3911
3955
  if (input && hadFocus) input.focus();
3956
+ else if (focusBack) focusCardControl(inst.id, prefer);
3957
+ }
3958
+
3959
+ /* ── keeping focus on the card through a re-render (task 1003244) ── */
3960
+
3961
+ /* which control had focus, as a selector the rebuilt card can answer;
3962
+ a confirm's Yes/Keep lands back on the door that opened it */
3963
+ function focusKeyOf(el) {
3964
+ if (!el || !el.getAttribute) return null;
3965
+ if (el.tagName === 'SUMMARY') return 'details.attachFold summary';
3966
+ if (el.hasAttribute('data-teardown-yes') || el.hasAttribute('data-teardown-no')) return '[data-teardown]';
3967
+ var keys = ['data-manage', 'data-attach', 'data-retry', 'data-revive', 'data-teardown'];
3968
+ for (var i = 0; i < keys.length; i++) if (el.hasAttribute(keys[i])) return '[' + keys[i] + ']';
3969
+ return null;
3970
+ }
3971
+ function focusCardControl(id, prefer) {
3972
+ var li = liFor(id);
3973
+ if (!li) return;
3974
+ var t = (prefer && li.querySelector(prefer)) ||
3975
+ li.querySelector('.actions a, .actions button') ||
3976
+ li.querySelector('details.attachFold summary');
3977
+ if (t) t.focus();
3978
+ }
3979
+ /* an action in flight: the row stays put and its buttons go quiet, so
3980
+ nothing under the pointer is deleted mid-click; a button that held
3981
+ focus lets go when disabled, so the card remembers to take it back */
3982
+ function quietActions(id) {
3983
+ var act = $('act-' + id);
3984
+ if (!act) return;
3985
+ var li = liFor(id);
3986
+ if (li && act.contains(document.activeElement)) li.setAttribute('data-focus-back', '');
3987
+ act.querySelectorAll('button').forEach(function (b) { b.disabled = true; });
3912
3988
  }
3913
3989
 
3914
3990
  function render(instances) {
@@ -4465,16 +4541,20 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
4465
4541
  var act = $('act-' + id);
4466
4542
  if (!act) return;
4467
4543
  cardAsking = String(id);
4468
- act.innerHTML = '<div class="confirmRow">' +
4544
+ act.innerHTML = '<div class="confirmRow" tabindex="-1">' +
4469
4545
  '<span class="plain">Tear it down? ' + teardownTruth(byId[id]) + '</span>' +
4470
4546
  '<button type="button" class="btn small danger" data-teardown-yes="' + esc(id) + '">Yes, tear it down</button>' +
4471
4547
  '<button type="button" class="linklike" data-teardown-no="' + esc(id) + '">Keep it running</button></div>';
4548
+ /* the pressed button is gone — focus lands on the question itself, so
4549
+ the truth is read before Tab reaches Yes (task 1003244) */
4550
+ var row = act.firstChild;
4551
+ if (row && row.focus) row.focus();
4472
4552
  }
4473
4553
  function unAskTeardown(id) { cardAsking = null; var inst = byId[id]; if (inst) renderCard(inst); }
4474
4554
  function doTeardown(id) {
4475
4555
  cardAsking = null;
4476
- var act = $('act-' + id);
4477
- if (act) act.innerHTML = '<span class="projMsg">Asking…</span>';
4556
+ quietActions(id);
4557
+ msg(id, 'Asking…');
4478
4558
  fetch(API + '/provisioning/instances/' + encodeURIComponent(id) + '/teardown', {
4479
4559
  method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: '{}'
4480
4560
  }).then(function (res) {
@@ -4501,8 +4581,8 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
4501
4581
  }).catch(function () {
4502
4582
  msg(id, 'Couldn’t reach the platform — check your connection and try again.', 'err');
4503
4583
  }).then(function () {
4504
- /* restore the actions row ("Asking…" replaced it) even when the state
4505
- didn't change, then pick up whatever DID change */
4584
+ /* wake the quieted row even when the state didn't change (renderCard
4585
+ also hands focus back), then pick up whatever DID change */
4506
4586
  if (byId[id]) renderCard(byId[id]);
4507
4587
  watchOnce(id);
4508
4588
  });
@@ -4515,8 +4595,8 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
4515
4595
  function reProvision(id, verb) {
4516
4596
  var inst = byId[id];
4517
4597
  if (!inst) return;
4518
- var act = $('act-' + id);
4519
- if (act) act.innerHTML = '<span class="projMsg">Asking…</span>';
4598
+ quietActions(id);
4599
+ msg(id, 'Asking…');
4520
4600
  var body = { slug: inst.slug, hosting_shape: inst.hosting_shape || 'standalone' };
4521
4601
  if (inst.onboard_mode) body.onboard_mode = inst.onboard_mode;
4522
4602
  if (inst.target_ref) body.target_ref = inst.target_ref;
@@ -0,0 +1,280 @@
1
+ // scripts/gds/box-infra.js — the dev-box CONFIG block plus everything that
2
+ // turns it into a bootable droplet: infra/ resolution, naming, the DNS hook,
3
+ // and cloud-init rendering.
4
+ //
5
+ // Carved out of scripts/gds/box.js by task 1003206 (goal 1000079, the R03
6
+ // de-monolith plan) to bring that file under the 1,500-line ratchet. A
7
+ // STRUCTURAL move: the lines below are box.js's originals unchanged, and box.js
8
+ // requires every name straight back, so the operator CLI is untouched.
9
+
10
+ 'use strict';
11
+
12
+ const fs = require('node:fs');
13
+ const path = require('node:path');
14
+ const zlib = require('node:zlib');
15
+ const { execFileSync } = require('node:child_process');
16
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
17
+ const instanceConfig = require(path.join(REPO_ROOT, 'src/instance-config'));
18
+ const BOX_INFRA_TEMPLATE = 'builder-box-cloud-init.yaml';
19
+
20
+ function boxInfraCandidates({ env = process.env, repoRoot = REPO_ROOT } = {}) {
21
+ const out = [];
22
+ const seen = new Set();
23
+ const add = (d) => { if (d && !seen.has(d)) { seen.add(d); out.push(d); } };
24
+ // 1. explicit override: <PREFIX>_BOX_INFRA_DIR (operator escape hatch).
25
+ const override = instanceConfig.resolveEnv('BOX_INFRA_DIR', { env });
26
+ if (override) add(path.resolve(override));
27
+ // 2. the instance root via an EXPLICIT pointer (<PREFIX>_INSTANCE_ROOT env or a
28
+ // bongos.config.json {"instanceRoot":"."}) — where a vendored instance keeps infra/.
29
+ const inst = instanceConfig.resolveInstanceRootExplicit({ env });
30
+ if (inst) add(path.join(inst, 'infra'));
31
+ // 3. REPO_ROOT/infra — the dev single-checkout (and the ADR 0144 node_modules workaround).
32
+ add(path.join(repoRoot, 'infra'));
33
+ // 4. walk up out of node_modules from the core root, so a vendored
34
+ // …/node_modules/@bongos/core still finds its host instance's infra/ with no pointer set.
35
+ let dir = repoRoot;
36
+ for (let i = 0; i < 6; i += 1) {
37
+ const parent = path.dirname(dir);
38
+ if (parent === dir) break;
39
+ dir = parent;
40
+ add(path.join(dir, 'infra'));
41
+ }
42
+ return out;
43
+ }
44
+
45
+ // The resolved infra/ dir (first candidate holding the template), or null when the
46
+ // box cloud-init cannot be located anywhere — the caller MUST fail loud on null
47
+ // rather than provision a box with empty user-data (ADR 0150).
48
+ function resolveBoxInfraDir(opts = {}) {
49
+ for (const c of boxInfraCandidates(opts)) {
50
+ try { if (fs.existsSync(path.join(c, BOX_INFRA_TEMPLATE))) return c; }
51
+ catch { /* unreadable candidate → try the next */ }
52
+ }
53
+ return null;
54
+ }
55
+
56
+ // task 1002757 — the instance's own apex; null when unconfigured (callers refuse).
57
+ const { instanceApex } = require('./instance-apex');
58
+
59
+ const CONFIG = {
60
+ floor: process.env.BOX_PROVISION_MIN_RANK || 'xenos',
61
+ region: process.env.BOX_REGION || 'nyc3',
62
+ size: process.env.BOX_SIZE || 's-2vcpu-4gb',
63
+ baseImage: process.env.BOX_BASE_IMAGE || 'ubuntu-24-04-x64',
64
+ idleMinutes: numEnv('BOX_IDLE_MINUTES', 10),
65
+ dormantDays: numEnv('BOX_DORMANT_DAYS', 14),
66
+ // task 1002727 — how many days BEFORE the reclaim a builder gets warned.
67
+ dormantWarnLeadDays: numEnv('BOX_DORMANT_WARN_LEAD_DAYS', 3),
68
+ sshKeyIds: (process.env.BOX_SSH_KEY_IDS || '').split(',').map((s) => s.trim()).filter(Boolean),
69
+ dnsHook: process.env.BOX_DNS_HOOK || null,
70
+ tag: process.env.BOX_TAG || 'otb-builder-box',
71
+ // task 1002757 settled this file's old carve-out: an operator-plane DNS zone is
72
+ // still a FUNCTIONAL DEFAULT, and a founder literal aimed another operator's
73
+ // fleet at the founder's zone. Derived from the instance apex, null when
74
+ // unconfigured — hostnameFor() then refuses instead of minting a dead FQDN.
75
+ dnsZone: process.env.BOX_DNS_ZONE || (instanceApex() ? `dev.${instanceApex()}` : null),
76
+ };
77
+
78
+ // Named-tunnel automation for the browser terminal (#771, ADR 0038 Part B).
79
+ // OPT-IN, default OFF — an unset BOX_NAMED_TUNNEL leaves the box on the tokenless
80
+ // quick tunnel (trycloudflare.com), the zero-secret #760 default. When the operator
81
+ // turns it on AND supplies a Cloudflare token with Tunnel + DNS scope (the same
82
+ // CLOUDFLARE_API_TOKEN box-dns uses) + the account id, every provision creates a
83
+ // stable term-<login>.<terminalZone> tunnel and bakes its connector token into the
84
+ // box; deprovision tears it down.
85
+ const CF = {
86
+ enabled: /^(1|true|yes)$/i.test(process.env.BOX_NAMED_TUNNEL || ''),
87
+ apiToken: process.env.CLOUDFLARE_API_TOKEN || null,
88
+ accountId: process.env.CLOUDFLARE_ACCOUNT_ID || null,
89
+ // The Cloudflare ZONE the record lives in (the registrable apex) — the terminal
90
+ // host is a record WITHIN it, matching box-dns-cloudflare.sh. Distinct from
91
+ // terminalZone (the host suffix). Both resolve from the instance (task 1002757).
92
+ zone: process.env.CLOUDFLARE_ZONE || instanceApex(),
93
+ // Host suffix for term-<login>.<terminalZone>. DEFAULTS TO THE APEX, NOT dev.*:
94
+ // the terminal rides a PROXIED tunnel, and free Universal SSL covers <apex> +
95
+ // *.<apex> (ONE label) but not a TWO-label host like term-x.dev.<apex>, which
96
+ // fails TLS at the edge (verified live, #771). An operator holding an Advanced
97
+ // Certificate for *.dev.<apex> can override via BOX_TERMINAL_ZONE. (The SSH
98
+ // A-record path stays on dev.* — proxied=false, so it needs no edge cert.)
99
+ terminalZone: process.env.BOX_TERMINAL_ZONE || instanceApex(),
100
+ };
101
+
102
+ // Named-tunnel automation is live only when the operator opted in AND the
103
+ // credentials are present. Missing creds while enabled is a loud config error at
104
+ // provision time (see cmdProvision), not a silent fall-back, so an operator who
105
+ // meant to use named tunnels learns immediately.
106
+ function numEnv(name, dflt) {
107
+ const v = process.env[name];
108
+ if (v == null || v === '') return dflt;
109
+ const n = Number(v);
110
+ return Number.isFinite(n) && n > 0 ? n : dflt;
111
+ }
112
+
113
+ function hasFlag(args, name) { return args.includes(name); }
114
+
115
+ // Lazy + memoized DB + lib load so `--help` and arg errors don't require Postgres
116
+ // (this CLI runs on a non-droplet machine for dry-run planning of pure logic too):
117
+ // nothing is require()d until the first makeDo/makeCf/IIFE call, and repeat calls
118
+ // reuse the first resolution instead of re-running the six requires.
119
+ let _deps;
120
+ function shortStamp() {
121
+ return new Date().toISOString().replace(/[-:T.Z]/g, '').slice(2, 14);
122
+ }
123
+
124
+ function dropletName(login) { return `otb-box-${login}`; }
125
+ // Refuses on an unresolved zone (task 1002757) — `box-x.undefined` would mint a
126
+ // DNS record and a box that can never be reached.
127
+ function hostnameFor(login) {
128
+ if (!CONFIG.dnsZone) throw new Error('box: no DNS zone configured — set BOX_DNS_ZONE, or give this instance a domains.publicOrigin in config/branding.json (task 1002757).');
129
+ return `box-${login}.${CONFIG.dnsZone}`;
130
+ }
131
+
132
+ // Run the optional DNS hook so the per-builder hostname tracks the new IP; with no
133
+ // hook configured, print the reminder — the stable-hostname CUTOVER is #599's, #598
134
+ // only records IP + hostname and offers the hook so an operator can wire DNS without
135
+ // editing this file. → { ok, error?, skipped? }. task 1180: callers MUST act on
136
+ // ok:false (box_events row + flag the box), never continue silently — that swallow
137
+ // left @kingaonline's A record pinned at a dead IP while provision reported success.
138
+ // The hook retries transient Cloudflare blips, so ok:false is a real, exhausted
139
+ // failure. `hook` is injectable so the contract is unit-testable (#1034).
140
+ function runDnsHook(hostname, ip, hook = CONFIG.dnsHook) {
141
+ if (!ip) return { ok: true, skipped: true };
142
+ if (!hook) {
143
+ console.log(` ↳ DNS: point ${hostname} → ${ip} (no BOX_DNS_HOOK set; #599 owns the cutover)`);
144
+ return { ok: true, skipped: true };
145
+ }
146
+ try {
147
+ execFileSync('bash', ['-c', hook], {
148
+ cwd: REPO_ROOT, stdio: 'pipe',
149
+ env: { ...process.env, BOX_HOSTNAME: hostname, BOX_IP: ip },
150
+ });
151
+ console.log(` ↳ DNS hook updated ${hostname} → ${ip}`);
152
+ return { ok: true };
153
+ } catch (e) {
154
+ const msg = (e.stderr && e.stderr.toString().trim()) || e.message;
155
+ console.log(` ⚠ DNS hook failed: ${msg} (point ${hostname} → ${ip} manually)`);
156
+ return { ok: false, error: msg };
157
+ }
158
+ }
159
+
160
+ // Blackhole sentinel for a torn-down box's A record (idea 309). 192.0.2.1 is in
161
+ // TEST-NET-1 (RFC 5737) — reserved for documentation and GUARANTEED never to be a
162
+ // routable host, so no recycled DigitalOcean IP can ever land here. A perfect
163
+ // dead end to park a record at.
164
+ const DNS_BLACKHOLE_IP = '192.0.2.1';
165
+
166
+ // Park the per-builder A record at the blackhole sentinel on deprovision (idea 309,
167
+ // refining task 1180). 1180 DELETED the record so it couldn't dangle at a recycled
168
+ // DO IP, but delete-then-recreate across a reap→rebuild left an NXDOMAIN window that
169
+ // resolvers negative-cache for the zone's SOA neg-TTL (~30 min, not builder-editable)
170
+ // — a client querying then kept getting ENOTFOUND long after the box was back.
171
+ // REPOINTING to a non-routable sentinel keeps the name always-resolving (nothing to
172
+ // negative-cache) while preserving 1180's guarantee that a torn-down box's name can
173
+ // never dial a stranger's recycled IP; the next provision/wake PUTs the live IP
174
+ // in-place at the 60s TTL, no gap. Best-effort like the prior delete. Reuses
175
+ // runDnsHook's UPSERT path; `hook` injectable for tests. → { ok, error?, skipped? }
176
+ function runDnsHookBlackhole(hostname, hook = CONFIG.dnsHook) {
177
+ if (!hook || !hostname) return { ok: true, skipped: true };
178
+ return runDnsHook(hostname, DNS_BLACKHOLE_IP, hook);
179
+ }
180
+
181
+ // Read the cloud-init template and substitute the repo URL from BOX_REPO_URL
182
+ // (kept out of the repo file so no GitHub identity is hardcoded — V3.R12 rule).
183
+ // An unset URL leaves the placeholder; the cloud-init then logs guidance rather
184
+ // than cloning, and #600 wires the scoped credential.
185
+ // Read + render the cloud-init. `authorizedKeysBlock` is the otb-managed block
186
+ // for the builder being provisioned (#685) — base64'd here and baked in so the
187
+ // FIRST SSH login works before the box has a GDS token. An empty/absent block
188
+ // renders an empty managed block (a keyless box; the operator is warned upstream).
189
+ function readCloudInit({ authorizedKeysBlock = '', boxEnv = '', gdsSession = '', infraDir = resolveBoxInfraDir() } = {}) {
190
+ // Fail loud, never provision a BARE box (ADR 0150). No infra/ located → there is
191
+ // no cloud-init to render, so THROW here rather than the old silent `return null`
192
+ // that let cmdProvision ship empty user-data (a box that boots as bare Ubuntu).
193
+ if (!infraDir) {
194
+ throw new Error(
195
+ `box cloud-init not found: no infra/${BOX_INFRA_TEMPLATE} in any candidate location `
196
+ + `(set <PREFIX>_BOX_INFRA_DIR or <PREFIX>_INSTANCE_ROOT, or ship infra/). A box must `
197
+ + `never be provisioned without user-data — it would boot bare (ADR 0150).`,
198
+ );
199
+ }
200
+ const p = path.join(infraDir, BOX_INFRA_TEMPLATE);
201
+ // gzip+base64 a repo file into the cloud-init write_files (`encoding: gz+b64`).
202
+ // EVERY inlined infra file goes through this: cloud-init gunzips it back to the
203
+ // exact bytes on the box, so it is byte-identical to plain b64 but ~2-3x smaller,
204
+ // which keeps the rendered user-data well under DO's 64 KiB cap (#903; the source
205
+ // -fetch growth in task 1314 had pushed the all-b64-but-source-fetch render to
206
+ // 67 KB — gz+b64-ing the rest brings it back to ~51 KB, task 1321). Only the
207
+ // dynamic runtime values (authorized_keys, box.env, gds-session) stay plain b64
208
+ // below — they are minted per-provision, not read from infra/. A missing inlined
209
+ // file is COLLECTED and fails loud after the render (ADR 0150) — a half-wired box
210
+ // (that component silently never boots) is a bug, not something to warn past.
211
+ const missingInfra = [];
212
+ const fileGzB64 = (name) => {
213
+ try {
214
+ return zlib.gzipSync(fs.readFileSync(path.join(infraDir, name))).toString('base64');
215
+ } catch {
216
+ missingInfra.push(name);
217
+ return '';
218
+ }
219
+ };
220
+ const raw = fs.readFileSync(p, 'utf8');
221
+ // Inject the rank-gated source-fetch (#600) + authorized-keys-fetch (#685)
222
+ // scripts as gz+b64 (a single YAML-safe token — no indentation/escaping
223
+ // pitfalls; cloud-init gunzips encoding: gz+b64 back to the exact file), plus
224
+ // the baked authorized_keys block. ONE canonical copy of each lives in infra/.
225
+ const rendered = raw
226
+ .replace(/__REPO_URL__/g, process.env.BOX_REPO_URL || '')
227
+ .replace(/__BOX_SOURCE_FETCH_B64__/g, fileGzB64('box-source-fetch.sh'))
228
+ .replace(/__BOX_AUTHKEYS_FETCH_B64__/g, fileGzB64('box-authorized-keys-fetch.sh'))
229
+ .replace(/__BUILDER_AUTHORIZED_KEYS_B64__/g, Buffer.from(authorizedKeysBlock || '', 'utf8').toString('base64'))
230
+ // /etc/otb/box.env — empty by default (quick tunnel); the named-tunnel
231
+ // connector token + stable hostname when BOX_NAMED_TUNNEL is on (#771).
232
+ .replace(/__BOX_ENV_B64__/g, Buffer.from(boxEnv || '', 'utf8').toString('base64'))
233
+ // /root/.config/otb/gds-session.json — the builder's GDS session, minted at
234
+ // provision so box-source-fetch (#600) can authenticate on FIRST boot and
235
+ // clone the rank-scoped repo. Without it the box never clones (the #818
236
+ // chicken-and-egg: the session normally comes from /builder-setup, which
237
+ // lives in the un-cloned repo).
238
+ .replace(/__GDS_SESSION_B64__/g, Buffer.from(gdsSession || '', 'utf8').toString('base64'))
239
+ // #774: the browser-terminal units, inlined so they start at FIRST BOOT
240
+ // (independent of the repo clone). ONE canonical copy lives in infra/.
241
+ .replace(/__BOX_TERMINAL_SERVICE_B64__/g, fileGzB64('box-terminal.service'))
242
+ // #917: credential-drift watcher pair — restarts ttyd when the credential
243
+ // file lands/changes after the service started (the wake/first-boot race).
244
+ .replace(/__BOX_TERMINAL_CREDSYNC_SERVICE_B64__/g, fileGzB64('box-terminal-credsync.service'))
245
+ .replace(/__BOX_TERMINAL_CREDENTIAL_PATH_B64__/g, fileGzB64('box-terminal-credential.path'))
246
+ .replace(/__CLOUDFLARED_SERVICE_B64__/g, fileGzB64('cloudflared.service'))
247
+ .replace(/__CLOUDFLARED_RUN_B64__/g, fileGzB64('cloudflared-run.sh'))
248
+ // #893: the `close-box` teardown command, present at first boot so a bare
249
+ // browser-only box has it before any repo clone (box-source-fetch refreshes
250
+ // it on existing boxes). ONE canonical copy lives in infra/close-box.sh.
251
+ .replace(/__CLOSE_BOX_B64__/g, fileGzB64('close-box.sh'))
252
+ // task 986: the browser-terminal auto-launcher — runs `claude` on first
253
+ // connect so the builder skips the "type claude" step. box-source-fetch
254
+ // refreshes it on existing boxes. ONE canonical copy in infra/otb-box-launch.sh.
255
+ .replace(/__OTB_BOX_LAUNCH_B64__/g, fileGzB64('otb-box-launch.sh'));
256
+ if (missingInfra.length) {
257
+ throw new Error(
258
+ `box cloud-init is incomplete under ${infraDir}: missing ${missingInfra.join(', ')} `
259
+ + `— a box would boot half-wired (ADR 0150). Fix the instance infra/ before provisioning.`,
260
+ );
261
+ }
262
+ return rendered;
263
+ }
264
+
265
+
266
+ module.exports = {
267
+ CF,
268
+ CONFIG,
269
+ boxInfraCandidates,
270
+ dropletName,
271
+ hasFlag,
272
+ hostnameFor,
273
+ instanceApex,
274
+ numEnv,
275
+ readCloudInit,
276
+ resolveBoxInfraDir,
277
+ runDnsHook,
278
+ runDnsHookBlackhole,
279
+ shortStamp,
280
+ };