@bongos/core 1.21.28 → 1.21.29

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.
@@ -15,7 +15,7 @@
15
15
  //
16
16
  // Shape: no DB, no network, no request object — given a plain options bag it returns
17
17
  // a string. `esc` and `callbackPage` are the whole surface; PAGE_STYLE and
18
- // CALLBACK_SCRIPT stay private because they are only ever this page's own body.
18
+ // HANDBACK_SCRIPT stay private because they are only ever this page's own body.
19
19
  'use strict';
20
20
 
21
21
  const api = require('../../../src/module-api');
@@ -53,73 +53,44 @@ h1{font-family:"Baloo 2",system-ui,sans-serif;font-weight:700;font-size:1.5rem;l
53
53
  .sub p{margin:.6rem 0}
54
54
  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}
55
55
  @media (prefers-color-scheme:dark){code{color:var(--sky)}}
56
- .steps{display:flex;align-items:flex-start;justify-content:center;margin:1.9rem 0 1.1rem}
57
- .step{flex:1 1 0;max-width:9rem;position:relative}
58
- .step::before{content:"";position:absolute;top:16px;left:-50%;width:100%;height:3px;background:var(--line);z-index:0;transition:background .4s ease}
59
- .step:first-child::before{display:none}
60
- .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}
61
- .lbl{margin-top:.55rem;font-size:.78rem;color:var(--inkSoft);font-weight:600;line-height:1.25;padding:0 .2rem}
62
- .step.done .dot{background:var(--ok);border-color:var(--ok);color:#fff}
63
- .step.done::before{background:var(--ok)}
64
- .step.active .dot{border-color:var(--sun);color:var(--sun600);animation:pulse 1.5s ease-in-out infinite}
65
- .step.active .lbl{color:var(--ink)}
66
- .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}
67
- @keyframes spin{to{transform:rotate(360deg)}}
68
- @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)}}
69
- @media (prefers-reduced-motion:reduce){.step.active .dot,.step.active .dot::after{animation:none}}
70
- .status-line{min-height:1.4em;color:var(--inkSoft);font-size:.94rem;margin:.4rem auto 0;max-width:24rem;line-height:1.45}
71
56
  .cta{margin-top:1.4rem}
72
57
  .btn{display:inline-block;background:var(--sun);color:var(--sunInk);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}
73
58
  .btn:hover{transform:translateY(-1px);box-shadow:0 14px 28px -8px rgba(249,115,22,.8)}
74
59
  .muted{color:var(--inkSoft);opacity:.9;font-size:.85rem;margin:1.1rem auto 0;max-width:24rem;line-height:1.45}
75
- .hidden{display:none!important}
76
60
  `;
77
61
 
78
- // Client script for the PROGRESS variant only (task 2096): poll the public status
79
- // endpoint, advance the stepper, and auto-open the instance the moment its credentials
80
- // land. Deliberately contains NO backticks and NO ${...} so it survives the outer
81
- // template literal verbatim; the only server data it reads is the JSON config block
82
- // written just above it. Fails soft — a poll hiccup just retries; a long wait reveals a
83
- // manual "open" button rather than ever falsely erroring. The auto-open countdown runs
84
- // ONLY when this page is the wizard's pop-up; a same-tab return (pop-up blocked) keeps
85
- // the founder here with the door and a way back to the checklist (task 1003236).
86
- const CALLBACK_SCRIPT = `
62
+ // Client script for the HAND-BACK variant (task 1004548; replaces the task 2096 progress
63
+ // stepper). Placing the credentials takes minutes, and the founder used to wait for it
64
+ // HERE, in a second tab, away from the checklist that still had steps for them. Now the
65
+ // page hands them straight back: as the wizard's pop-up it closes itself so the checklist
66
+ // tab is in front again (that tab is already polling the same status and shows the wait);
67
+ // if the browser refuses the close, or this was a same-tab return (pop-up blocked), this
68
+ // tab becomes the checklist. The one-time state rides the URL FRAGMENT — never sent to a
69
+ // server — so a checklist opened here can keep watching the same attempt.
70
+ // Deliberately contains NO backticks and NO ${...} so it survives the outer template
71
+ // literal verbatim; the only server data it reads is the JSON config block above it.
72
+ const HANDBACK_SCRIPT = `
87
73
  (function(){
88
74
  var cfg=window.__CB_SETUP__||{};
89
- var state=cfg.state||'',domain=cfg.domain||'';
90
- if(!state||!domain)return;
91
- var target='https://'+domain;
92
- var pollUrl=location.pathname.replace('/callback','/status')+'?state='+encodeURIComponent(state);
93
- var steps=document.getElementById('steps'),statusEl=document.getElementById('status');
94
- var cta=document.getElementById('cta'),go=document.getElementById('go'),foot=document.getElementById('foot'),back=document.getElementById('back');
95
- /* Opened from the wizard as a pop-up (window.opener alive, or the tab still carries the name the wizard gave it):
96
- the checklist is in the other tab, so this one may auto-open the instance. Same-tab fallback (pop-up blocked,
97
- form target=_self): this IS the founder's checklist tab — never navigate it away; offer both doors (task 1003236). */
98
- var popup=!!window.opener||window.name==='ghAppSetup';
99
- var finished=false,tries=0,manual=false;
100
- function dot(n){return steps.querySelector('[data-step="'+n+'"]');}
101
- 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');}}}
102
- 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';}}
103
- 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');
104
- if(!popup){go.textContent='Open '+domain+' \\u2192';if(back)back.classList.remove('hidden');if(foot)foot.textContent='Your setup checklist is waiting where you left it \\u2014 finish it first, or open your instance now.';return;}
105
- var n=4;(function tick(){if(n<=0){location.href=target;return;}go.textContent='Opening '+domain+' in '+n+'\\u2026';n--;setTimeout(tick,1000);})();}
106
- 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';}
107
- 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.';}
108
- function schedule(){if(!finished)setTimeout(poll,3000);}
109
- 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();});}
110
- setActive(2);poll();
75
+ var back=cfg.back||'/projects?view=new';
76
+ if(cfg.state)back+='#ghstate='+encodeURIComponent(cfg.state);
77
+ var link=document.getElementById('back');if(link)link.href=back;
78
+ var popup=false;
79
+ try{popup=!!window.opener&&!window.opener.closed;}catch(e){}
80
+ if(popup){try{window.opener.focus();}catch(e){}try{window.close();}catch(e){}}
81
+ setTimeout(function(){location.replace(back);},popup?700:300);
111
82
  })();
112
83
  `;
113
84
 
114
85
  // The standalone landing page for the public GitHub redirect (the mothership serves it;
115
86
  // it is NOT the hall shell). Two variants share the Cloud Bongos brand shell (task 2096):
116
- // • progress — ok + state + domain: an animated stepper that polls the status endpoint
117
- // and auto-opens the instance when its credentials are placed.
87
+ // • hand-back — ok + state + domain: a brief "GitHub app created" that returns the
88
+ // founder to their setup checklist, where the wait is shown (task 1004548).
118
89
  // • message — everything else (errors + the no-domain success fallback): a calm badge
119
90
  // + `body` (server-authored, trusted HTML).
120
91
  function callbackPage({ ok, title, body, state, domain }) {
121
- const progress = !!(ok && state && domain);
122
- const cfgJson = JSON.stringify({ state: state || '', domain: domain || '' }).replace(/</g, '\\u003c');
92
+ const handback = !!(ok && state && domain);
93
+ const cfgJson = JSON.stringify({ state: state || '', domain: domain || '', back: '/projects?view=new' }).replace(/</g, '\\u003c');
123
94
  const head = `<!doctype html><html lang="en"><head><meta charset="utf-8">
124
95
  <meta name="viewport" content="width=device-width, initial-scale=1">
125
96
  <meta name="color-scheme" content="light dark">
@@ -127,24 +98,16 @@ function callbackPage({ ok, title, body, state, domain }) {
127
98
  <link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
128
99
  <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">
129
100
  <style>${PAGE_STYLE}</style></head><body>`;
130
- if (progress) {
101
+ if (handback) {
131
102
  return `${head}
132
103
  <main class="card">
104
+ <div class="badge-warn">✓</div>
133
105
  <h1>${esc(title)}</h1>
134
- <p class="sub">Finishing sign-in setup for <code>${esc(domain)}</code>.</p>
135
- <div class="steps" id="steps" aria-label="Setup progress">
136
- <div class="step done" data-step="1"><div class="dot">✓</div><div class="lbl">GitHub app created</div></div>
137
- <div class="step active" data-step="2"><div class="dot">2</div><div class="lbl">Placing credentials</div></div>
138
- <div class="step" data-step="3"><div class="dot">3</div><div class="lbl">Ready to sign in</div></div>
139
- </div>
140
- <p class="status-line" id="status">Hang tight — keep this tab open while we finish.</p>
141
- <div class="cta hidden" id="cta"><a class="btn" id="go" href="https://${esc(domain)}">Open ${esc(domain)} →</a></div>
142
- <p class="muted hidden" id="back"><a href="/projects?view=new">Finish your remaining setup steps →</a></p>
143
- <p class="muted" id="foot">This page opens <code>${esc(domain)}</code> for you the moment it’s ready.</p>
144
- <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>
106
+ <p class="sub">Taking you back to your setup checklist — it finishes setting up <code>${esc(domain)}</code> there.</p>
107
+ <div class="cta"><a class="btn" id="back" href="/projects?view=new">Back to my checklist →</a></div>
145
108
  </main>
146
109
  <script>window.__CB_SETUP__=${cfgJson};</script>
147
- <script>${CALLBACK_SCRIPT}</script>
110
+ <script>${HANDBACK_SCRIPT}</script>
148
111
  </body></html>`;
149
112
  }
150
113
  return `${head}
@@ -1391,12 +1391,12 @@ module.exports = function provisioningRoutes() {
1391
1391
  });
1392
1392
 
1393
1393
  // GET /provisioning/github-app/status?state=<> — the PUBLIC progress read the onboarding
1394
- // callback page polls (task 2096) to know when setup is done + where to send the owner.
1394
+ // setup checklist polls (task 1004548) to know when setup is done + where to send the owner.
1395
1395
  // Same public-by-state model as the callback: the unguessable, single-use `state` token
1396
1396
  // is the only key, and the response reveals only the manifest lifecycle status + the
1397
1397
  // instance's own (already-public) domain — never a secret. Per-IP rate-limited because
1398
1398
  // the page polls it every ~3s. `ready` is true iff the manifest reached 'done' (creds
1399
- // placed + /healthz verified) — the page then auto-redirects to https://<domain>.
1399
+ // placed + /healthz verified) — the setup checklist then opens https://<domain>.
1400
1400
  // rank: public — no session, no mutation, no secret; an unknown state is a 404 no-op.
1401
1401
  router.get('/provisioning/github-app/status', statusRateLimit, async (req, res) => {
1402
1402
  try {
@@ -1461,11 +1461,11 @@ module.exports = function provisioningRoutes() {
1461
1461
  }).catch(() => {});
1462
1462
  const inst = await provisioning.getInstanceById(pool, row.instance_id).catch(() => null);
1463
1463
  const domain = inst && inst.domain ? inst.domain : null;
1464
- // Progress variant (task 2096): the page polls …/github-app/status?state= and
1465
- // auto-opens the instance the moment its credentials are placed — no "come back
1466
- // later" dead-end. Falls back to a static success if we have no domain to poll.
1464
+ // Hand-back variant (task 1004548): the page returns the founder to their setup
1465
+ // checklist, which shows the wait for the credentials and opens the instance when
1466
+ // it's ready. Falls back to a static success if we have no domain to point at.
1467
1467
  return sendPage(200, domain
1468
- ? callbackPage({ ok: true, title: 'Setting up your instance', state, domain })
1468
+ ? callbackPage({ ok: true, title: 'GitHub app created', state, domain })
1469
1469
  : callbackPage({ ok: true, title: 'GitHub app created',
1470
1470
  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>' }));
1471
1471
  } catch (err) {
@@ -423,6 +423,10 @@ h1[tabindex="-1"]:focus:not(:focus-visible){outline:none;}
423
423
  .samePath{font-size:13px;font-weight:400;color:var(--ink-faint);margin-top:18px;}
424
424
  .samePath a{display:inline-block;padding:3px 0;min-height:24px;line-height:18px;}
425
425
  #liveProgress{margin-top:4px;}
426
+ /* the sign-in wait inside its step + the "open it when ready" tick (task 1004548) */
427
+ .humanSteps .prog{margin-top:14px;}
428
+ .autoOpen{display:flex;align-items:flex-start;gap:10px;margin-top:14px;font-size:14px;line-height:1.45;color:var(--ink-soft);cursor:pointer;min-height:24px;}
429
+ .autoOpen input{width:18px;height:18px;margin:1px 0 0;flex:0 0 auto;accent-color:var(--accent);cursor:pointer;}
426
430
 
427
431
  /* ── the standup strip (task 2083), drawn as the lifecycle rail (DESIGN.md):
428
432
  three hairlines, a 7px dot, one lit step — no plate, no connector, no
@@ -6557,6 +6561,35 @@ summary{min-height:24px;padding:3px 0;}
6557
6561
  '<p>The platform team places the generated credentials; this step ticks itself once they land.</p></details>';
6558
6562
  }
6559
6563
 
6564
+ /* "Open it when it's ready" (task 1004548). The founder now waits for sign-in on
6565
+ this checklist instead of on the callback page, so the auto-open lives here: a
6566
+ tick, on unless they untick it (remembered per browser), that opens the project
6567
+ the moment it answers AND sign-in is wired. Armed only by a wait this page has
6568
+ actually watched, so revisiting a long-finished checklist never jumps away. */
6569
+ var AUTO_OPEN_KEY = 'cb-open-when-ready';
6570
+ var autoOpenArmed = false;
6571
+ var autoOpened = false;
6572
+ function autoOpenPref() {
6573
+ try { return localStorage.getItem(AUTO_OPEN_KEY) !== '0'; } catch (e) { return true; }
6574
+ }
6575
+ function autoOpenTick() {
6576
+ return '<label class="autoOpen"><input type="checkbox" data-action="auto-open"' + (autoOpenPref() ? ' checked' : '') + '>' +
6577
+ '<span>Open my project automatically when it’s ready</span></label>';
6578
+ }
6579
+ function maybeAutoOpen() {
6580
+ var domain = resolvedDomain();
6581
+ if (autoOpened || !autoOpenArmed || !domain || !autoOpenPref()) return;
6582
+ if (live.manifest !== 'done' || live.instStatus !== 'active' || !live.addrReady) return;
6583
+ autoOpened = true;
6584
+ var url = 'https://' + domain;
6585
+ /* a new tab keeps this checklist (two steps follow sign-in); a browser that
6586
+ blocks an unprompted tab gets this one sent instead — the founder asked */
6587
+ var w = null;
6588
+ try { w = window.open(url, '_blank'); } catch (e) {}
6589
+ if (w) { try { w.opener = null; } catch (e) {} }
6590
+ else window.location.assign(url);
6591
+ }
6592
+
6560
6593
  /* oauth-app — the ADR 0133 one-click GitHub-App manifest flow is the primary path
6561
6594
  (gap 3 / idea 1000676); locked until a domain exists (the App needs the real
6562
6595
  callback host — the server refuses domainless with domain_required). */
@@ -6572,7 +6605,12 @@ summary{min-height:24px;padding:3px 0;}
6572
6605
  return '<li class="slocked" data-onboard-step="oauth-app"><b>Wire GitHub sign-in</b><p>This platform is running on a local address, and GitHub refuses app setups it can’t reach over the public internet — so the one-click flow can’t complete here. On a publicly-addressed platform this is one click.</p>' + manualOauthDetails() + '</li>';
6573
6606
  }
6574
6607
  if (live.manifest === 'done') {
6575
- 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>';
6608
+ /* the wait ends HERE now, not on the callback page (task 1004548): once the
6609
+ project answers too, the step carries the door itself */
6610
+ var open = live.instStatus === 'active' && live.addrReady;
6611
+ 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.' +
6612
+ (open ? '' : ' The door below opens once your project is answering.') + '</p>' +
6613
+ (open ? '<a class="ghbtn" href="https://' + esc(domain) + '" target="_blank" rel="noopener">Open ' + esc(domain) + ' →</a>' : '') + '</li>';
6576
6614
  }
6577
6615
  if (live.manifest === 'error' || live.manifest === 'expired') {
6578
6616
  /* a plain sentence first; the raw route text folds under Technical
@@ -6589,10 +6627,14 @@ summary{min-height:24px;padding:3px 0;}
6589
6627
  if (live.manifest === 'code_received') {
6590
6628
  /* GitHub's half is done — say so, rather than keep telling the founder
6591
6629
  to approve an app they just approved */
6592
- return '<li data-onboard-step="oauth-app"><b>Wire GitHub sign-in</b><p>GitHub’s part is done. The platform is placing the credentials on your project now — this step ticks itself.</p></li>';
6630
+ autoOpenArmed = true;
6631
+ return '<li data-onboard-step="oauth-app"><b>Wire GitHub sign-in</b><p>GitHub’s part is done. The platform is placing the credentials on your project now — this usually takes a few minutes, and this step ticks itself.</p>' +
6632
+ progressMarkup(1, 1, 'Placing credentials and checking sign-in works…', '', 'signin') + autoOpenTick() + '</li>';
6593
6633
  }
6594
6634
  if (live.manifest) { /* pending — GitHub holds the manifest, nobody has approved it yet */
6635
+ autoOpenArmed = true;
6595
6636
  return '<li data-onboard-step="oauth-app"><b>Wire GitHub sign-in</b><p>Waiting on GitHub — approve the app in the tab that opened. Once approved, the platform places the credentials and verifies sign-in on its own; this step ticks itself.</p>' +
6637
+ progressMarkup(0, 0, 'Waiting for your approval on GitHub…', '', 'signin') + autoOpenTick() +
6596
6638
  (manifestStale ? '<button type="button" class="linklike" data-action="manifest-start">Didn’t get to GitHub? Start the setup again</button>' : '') + '</li>';
6597
6639
  }
6598
6640
  return '<li data-onboard-step="oauth-app"><b>Wire GitHub sign-in — one click</b><p>Approve a pre-filled GitHub App and the platform wires sign-in by itself: no keys to copy, and the secret never leaves the server side.</p>' +
@@ -6678,6 +6720,7 @@ summary{min-height:24px;padding:3px 0;}
6678
6720
  if (html === lastStepsHtml) return; /* change-only re-render — polls never clobber open UI */
6679
6721
  lastStepsHtml = html;
6680
6722
  $('humanSteps').innerHTML = html;
6723
+ maybeAutoOpen();
6681
6724
  });
6682
6725
  }
6683
6726
 
@@ -6730,6 +6773,12 @@ summary{min-height:24px;padding:3px 0;}
6730
6773
  });
6731
6774
  });
6732
6775
 
6776
+ $('humanSteps').addEventListener('change', function (ev) {
6777
+ var box = ev.target.closest && ev.target.closest('[data-action="auto-open"]');
6778
+ if (!box) return;
6779
+ try { localStorage.setItem(AUTO_OPEN_KEY, box.checked ? '1' : '0'); } catch (e) {}
6780
+ });
6781
+
6733
6782
  /* ── C8: the wizard's polls run only on stage, exactly like the sky.
6734
6783
  Off-view or hidden-tab, a due tick parks itself instead of fetching;
6735
6784
  enter() and the visibility flip resume whatever was parked. ── */
@@ -6754,6 +6803,13 @@ summary{min-height:24px;padding:3px 0;}
6754
6803
  var manifestPending = false;
6755
6804
  var manifestStartedAt = 0; /* when the founder was sent to GitHub */
6756
6805
  var manifestStale = false; /* ~90s with no approval → offer to start again */
6806
+ /* the GitHub hand-back (task 1004548): the callback page returns the founder here
6807
+ with the attempt's one-time state in the fragment (#ghstate=…), so a checklist
6808
+ that page opened — not only the tab that started the setup — keeps watching the
6809
+ same attempt. Read once at load, before the router's replaceState drops it. */
6810
+ var handbackState = (function () {
6811
+ try { var m = /[#&]ghstate=([0-9a-f]{16,128})/i.exec(location.hash || ''); return m ? m[1] : null; } catch (e) { return null; }
6812
+ })();
6757
6813
  /* the manifest leg survives a reload: token + last status ride the CREATED
6758
6814
  record, so enter() re-seeds the step instead of re-offering "Set up
6759
6815
  GitHub sign-in" for an app the founder already approved (task 1003232) */
@@ -6846,7 +6902,9 @@ summary{min-height:24px;padding:3px 0;}
6846
6902
  standup: { names: ['Request filed', 'Standing it up', 'Live'], cls: '' },
6847
6903
  revive: { names: ['Revive queued', 'Standing it up', 'Back live'], cls: '' },
6848
6904
  teardown: { names: ['Teardown queued', 'Winding down', 'Torn down'], cls: 'flowDown' },
6849
- settings: { names: ['Change saved', 'Restarting to apply it', 'Applied'], cls: '' }
6905
+ settings: { names: ['Change saved', 'Restarting to apply it', 'Applied'], cls: '' },
6906
+ /* the GitHub sign-in wait, drawn inside its checklist step (task 1004548) */
6907
+ signin: { names: ['Approve on GitHub', 'Placing credentials', 'Ready to sign in'], cls: '' }
6850
6908
  };
6851
6909
  /* No live-region attributes here: this strip is destroyed and rebuilt on
6852
6910
  every 5s tick, and a live region that is itself replaced never announces.
@@ -7171,6 +7229,13 @@ summary{min-height:24px;padding:3px 0;}
7171
7229
  /* restore the opt-out too — without it resolvedDomain() re-derives the
7172
7230
  assigned address and the done panel promises one that doesn't exist */
7173
7231
  state.noAddress = !!created.noaddr;
7232
+ /* a hand-back from the callback page names the attempt GitHub just approved */
7233
+ if (handbackState) {
7234
+ created.manifest_state = handbackState;
7235
+ if (created.manifest !== 'done') created.manifest = 'code_received';
7236
+ rememberManifest(handbackState, created.manifest);
7237
+ handbackState = null;
7238
+ }
7174
7239
  /* the manifest leg's last known truth, kept across a reload */
7175
7240
  if (created.manifest) { live.manifest = created.manifest; live.appSlug = created.app_slug || null; }
7176
7241
  if (created.manifest_state && created.manifest !== 'done' && created.manifest !== 'error' && created.manifest !== 'expired') {
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.28",
3
+ "version": "1.21.29",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.28",
9
+ "version": "1.21.29",
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.21.28",
3
+ "version": "1.21.29",
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",
@@ -8924,5 +8924,11 @@
8924
8924
  "id": "1004492",
8925
8925
  "text": "The Builders Hall now asks what you are doing when you arrive: Building, Artistry, Ideating or Governing, or Legacy mode for the hall exactly as it was. It asks once per session (or never again, if you untick the box), artists"
8926
8926
  }
8927
+ ],
8928
+ "1.21.29": [
8929
+ {
8930
+ "id": "1004548",
8931
+ "text": "After approving GitHub sign-in, founders go straight back to their setup checklist to wait, and can tick a box to have their project open automatically when it's ready."
8932
+ }
8927
8933
  ]
8928
8934
  }
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.28'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.29'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -38,7 +38,7 @@ test('esc renders an absent value as the empty string, never "null"/"undefined"'
38
38
  assert.equal(esc(false), 'false');
39
39
  });
40
40
 
41
- test('the progress variant escapes the title + domain it interpolates', () => {
41
+ test('the hand-back variant escapes the title + domain it interpolates', () => {
42
42
  const html = callbackPage({
43
43
  ok: true,
44
44
  title: 'Setting up <your> instance',
@@ -49,9 +49,9 @@ test('the progress variant escapes the title + domain it interpolates', () => {
49
49
  assert.match(html, /evil&quot;\.example\.com/, 'the domain rides through esc');
50
50
  assert.doesNotMatch(html, /Setting up <your> instance/, 'no raw title anywhere on the page');
51
51
  assert.doesNotMatch(html, /https:\/\/evil"\.example\.com/, 'no unescaped domain in an href');
52
- // it IS the progress variant: the stepper + the config block the client script reads
53
- assert.match(html, /id="steps"/);
54
- assert.match(html, /__CB_SETUP__=\{"state":"GOODSTATE","domain":"evil\\"\.example\.com"\}/);
52
+ // it IS the hand-back variant (task 1004548): the way back + the config block the client script reads
53
+ assert.match(html, /id="back" href="\/projects\?view=new"/);
54
+ assert.match(html, /__CB_SETUP__=\{"state":"GOODSTATE","domain":"evil\\"\.example\.com","back":"\/projects\?view=new"\}/);
55
55
  });
56
56
 
57
57
  test('the message variant escapes the title but passes server-authored body HTML through', () => {
@@ -63,7 +63,7 @@ test('the message variant escapes the title but passes server-authored body HTML
63
63
  assert.match(html, /<h1>This setup link has &lt;expired&gt;<\/h1>/);
64
64
  assert.match(html, /<div class="sub"><p>Ask the owner for a fresh link\.<\/p><\/div>/,
65
65
  'body is trusted server-authored HTML — the route composes it, a request never does');
66
- assert.doesNotMatch(html, /id="steps"/, 'no stepper without ok + state + domain');
66
+ assert.doesNotMatch(html, /id="back"/, 'no hand-back without ok + state + domain');
67
67
  assert.match(html, /badge-warn">⚠️</, 'a failure wears the warning badge');
68
68
  });
69
69
 
@@ -93,44 +93,43 @@ console.log('\nGET /provisioning/github-app/callback (the public GitHub redirect
93
93
  {
94
94
  const res = await fetch(`${base}/provisioning/github-app/callback?code=GHCODE123&state=GOODSTATE`);
95
95
  const html = await res.text();
96
- t('good state → 200 text/html progress page wired to auto-open', res.status === 200 && /text\/html/.test(res.headers.get('content-type') || '') && /Setting up your instance/.test(html) && /Placing credentials/.test(html) && /__CB_SETUP__/.test(html) && html.includes('GOODSTATE'), `status=${res.status}`);
96
+ t('good state → 200 text/html hand-back page', res.status === 200 && /text\/html/.test(res.headers.get('content-type') || '') && /GitHub app created/.test(html) && /__CB_SETUP__/.test(html) && html.includes('GOODSTATE'), `status=${res.status}`);
97
97
  t('the page names the instance host', /mercury\.example\.com/.test(html));
98
- // task 1003236: a same-tab return (pop-up blocked → form target=_self) must never be
99
- // navigated away from the checklist. The page carries a hidden way back, and the
100
- // countdown sits behind the pop-up guard.
101
- t('carries a hidden "finish your remaining setup steps" link back to the wizard', /<p class="muted hidden" id="back"><a href="\/projects\?view=new">Finish your remaining setup steps/.test(html));
102
- t('the auto-open countdown is reachable only past the pop-up guard', /var popup=!!window\.opener\|\|window\.name==='ghAppSetup';/.test(html) && /if\(!popup\)\{[^}]*return;\}\s*var n=4;\(function tick\(\)\{if\(n<=0\)\{location\.href=target;/.test(html) && (html.match(/location\.href=target/g) || []).length === 1);
98
+ // task 1004548: the founder no longer waits here — the page hands them back to the
99
+ // checklist, which shows the wait. A manual way back is always on the page.
100
+ t('carries a plain "back to my checklist" link (works without script)', /<a class="btn" id="back" href="\/projects\?view=new">Back to my checklist/.test(html));
101
+ t('no progress stepper or countdown left on the page', !/id="steps"/.test(html) && !/Placing credentials/.test(html) && !/location\.href=target/.test(html));
103
102
 
104
103
  // Run the served script itself (not a copy) against a minimal DOM, once as the
105
- // wizard's pop-up and once as a same-tab return, and watch what it does to location.
104
+ // wizard's pop-up and once as a same-tab return, and watch what it does.
106
105
  const script = (html.match(/<script>\s*(\(function\(\)\{[\s\S]*?)<\/script>\s*<\/body>/) || [])[1];
107
- t('the progress page ships the client script', typeof script === 'string' && script.length > 200);
108
- async function runCallback({ opener, name }) {
106
+ t('the hand-back page ships the client script', typeof script === 'string' && script.length > 100);
107
+ async function runCallback({ opener }) {
109
108
  const vm = await import('node:vm');
110
- const el = () => { const cls = new Set(); return { textContent: '', classList: { add: (...c) => c.forEach((x) => cls.add(x)), remove: (...c) => c.forEach((x) => cls.delete(x)), contains: (c) => cls.has(c) }, querySelector: () => el() }; };
111
- const els = { steps: el(), status: el(), cta: el(), go: el(), foot: el(), back: el() };
112
- els.steps.querySelector = () => el();
113
- els.back.classList.add('hidden'); els.cta.classList.add('hidden');
109
+ const back = { href: '/projects?view=new' };
114
110
  const timers = [];
111
+ const calls = { close: 0, focusOpener: 0, replaced: null };
115
112
  const sandbox = {
116
- __CB_SETUP__: { state: 'GOODSTATE', domain: 'mercury.example.com' },
117
- opener, name,
118
- document: { getElementById: (id) => els[id] || null },
119
- location: { pathname: '/api/bongos/provisioning/github-app/callback', href: 'about:callback' },
120
- fetch: async () => ({ ok: true, json: async () => ({ ok: true, ready: true, status: 'done' }) }),
113
+ __CB_SETUP__: { state: 'GOODSTATE', domain: 'mercury.example.com', back: '/projects?view=new' },
114
+ opener: opener ? { closed: false, focus: () => { calls.focusOpener++; } } : null,
115
+ close: () => { calls.close++; },
116
+ document: { getElementById: (id) => (id === 'back' ? back : null) },
117
+ location: { replace: (u) => { calls.replaced = u; } },
121
118
  setTimeout: (fn) => { timers.push(fn); return timers.length; },
122
119
  encodeURIComponent,
123
120
  };
124
121
  sandbox.window = sandbox;
125
122
  vm.runInNewContext(script, sandbox);
126
- for (let i = 0; i < 5; i++) await new Promise((r) => setImmediate(r)); // let the poll's promise chain settle
127
- for (let guard = 0; guard < 10 && timers.length; guard++) { const fn = timers.shift(); fn(); }
128
- return { href: sandbox.location.href, go: els.go.textContent, backHidden: els.back.classList.contains('hidden'), foot: els.foot.textContent };
123
+ const replacedBeforeTimer = calls.replaced;
124
+ while (timers.length) timers.shift()();
125
+ return { ...calls, replacedBeforeTimer, backHref: back.href };
129
126
  }
130
- const popup = await runCallback({ opener: {}, name: 'ghAppSetup' });
131
- t('as the wizard’s pop-up it still counts down and opens the instance', popup.href === 'https://mercury.example.com', JSON.stringify(popup));
132
- const sameTab = await runCallback({ opener: null, name: '' });
133
- t('as a same-tab return it stays put: door shown, way back revealed, no navigation', sameTab.href === 'about:callback' && sameTab.go === 'Open mercury.example.com →' && sameTab.backHidden === false && /checklist is waiting/.test(sameTab.foot), JSON.stringify(sameTab));
127
+ const want = '/projects?view=new#ghstate=GOODSTATE';
128
+ const popup = await runCallback({ opener: true });
129
+ t('as the wizard’s pop-up it focuses the checklist tab and closes itself', popup.focusOpener === 1 && popup.close === 1 && popup.replacedBeforeTimer === null, JSON.stringify(popup));
130
+ t('…and if the browser refuses the close, the pop-up becomes the checklist', popup.replaced === want, JSON.stringify(popup));
131
+ const sameTab = await runCallback({ opener: false });
132
+ t('as a same-tab return it goes straight back to the checklist, carrying the attempt in the fragment', sameTab.close === 0 && sameTab.replaced === want && sameTab.backHref === want, JSON.stringify(sameTab));
134
133
  }
135
134
  {
136
135
  const res = await fetch(`${base}/provisioning/github-app/callback?code=X&state=NOPE`);
@@ -0,0 +1,94 @@
1
+ // tests/wizard_auto_open.mjs — the checklist's "Open my project automatically when it's
2
+ // ready" tick (task 1004548).
3
+ //
4
+ // WHY. The GitHub-App callback page used to hold the founder on its own stepper and open
5
+ // the instance itself. Since task 1004548 it hands them straight back to the checklist,
6
+ // so the auto-open moved there. The rule it must keep: open ONCE, only when sign-in is
7
+ // wired AND the project answers, only after a wait this page actually watched (so a
8
+ // revisit to a long-finished checklist never jumps away), never when the founder
9
+ // unticked it — and prefer a new tab, falling back to this one when the browser blocks
10
+ // an unprompted tab.
11
+ //
12
+ // Executed, not read: the real AUTO_OPEN_KEY…maybeAutoOpen region is sliced out of the
13
+ // page's inline script and run in a node:vm under stubs (the vm-slice idiom of
14
+ // tests/wizard_intent_resume.mjs). The slice boundaries are asserted, so a rename fails
15
+ // loudly instead of testing nothing.
16
+ //
17
+ // Run: node --test tests/wizard_auto_open.mjs
18
+
19
+ import assert from 'node:assert/strict';
20
+ import { test } from 'node:test';
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import vm from 'node:vm';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
27
+ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
28
+ const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
29
+ const SCRIPT = HUB.slice(HUB.lastIndexOf('<script>'), HUB.lastIndexOf('</script>'));
30
+
31
+ const START = SCRIPT.indexOf(" var AUTO_OPEN_KEY = 'cb-open-when-ready';");
32
+ const END = SCRIPT.indexOf(' /* oauth-app — the ADR 0133 one-click GitHub-App manifest flow', START);
33
+ const SLICE = SCRIPT.slice(START, END);
34
+
35
+ test('the auto-open region is still findable (a rename must fail loudly)', () => {
36
+ assert.ok(START > 0 && END > START, 'both slice boundaries found');
37
+ assert.match(SLICE, /function maybeAutoOpen\(\)/);
38
+ assert.match(SLICE, /function autoOpenTick\(\)/);
39
+ });
40
+
41
+ const READY = { manifest: 'done', instStatus: 'active', addrReady: true };
42
+
43
+ function run({ live = READY, armed = true, pref = null, popupBlocked = false, ticks = 1 } = {}) {
44
+ const store = new Map();
45
+ if (pref !== null) store.set('cb-open-when-ready', pref);
46
+ const calls = { opened: [], assigned: [] };
47
+ const sandbox = {
48
+ live: { ...live },
49
+ resolvedDomain: () => 'mercury.example.com',
50
+ localStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, v) },
51
+ window: {
52
+ open: (u, t) => { calls.opened.push([u, t]); return popupBlocked ? null : { opener: 'checklist' }; },
53
+ location: { assign: (u) => { calls.assigned.push(u); } },
54
+ },
55
+ };
56
+ sandbox.globalThis = sandbox;
57
+ vm.createContext(sandbox);
58
+ vm.runInContext(`${SLICE}\nautoOpenArmed = ${armed ? 'true' : 'false'};\nfor (var i = 0; i < ${ticks}; i++) maybeAutoOpen();\nvar tickHtml = autoOpenTick();`, sandbox);
59
+ return { calls, sandbox };
60
+ }
61
+
62
+ test('ready + armed + ticked (the default) → opens the project ONCE, in a new tab', () => {
63
+ const { calls } = run({ ticks: 3 });
64
+ assert.deepEqual(calls.opened, [['https://mercury.example.com', '_blank']], 'one new tab, however many renders follow');
65
+ assert.deepEqual(calls.assigned, [], 'this checklist stays put');
66
+ });
67
+
68
+ test('a browser that blocks the unprompted tab sends THIS tab instead — the founder asked', () => {
69
+ const { calls } = run({ popupBlocked: true });
70
+ assert.deepEqual(calls.assigned, ['https://mercury.example.com']);
71
+ });
72
+
73
+ test('nothing opens until sign-in is wired AND the project answers', () => {
74
+ for (const live of [
75
+ { ...READY, manifest: 'code_received' },
76
+ { ...READY, instStatus: 'provisioning' },
77
+ { ...READY, addrReady: false },
78
+ ]) {
79
+ const { calls } = run({ live });
80
+ assert.deepEqual([calls.opened, calls.assigned], [[], []], JSON.stringify(live));
81
+ }
82
+ });
83
+
84
+ test('unticked → never opens, and the tick renders unticked', () => {
85
+ const { calls, sandbox } = run({ pref: '0' });
86
+ assert.deepEqual([calls.opened, calls.assigned], [[], []]);
87
+ assert.doesNotMatch(sandbox.tickHtml, / checked/);
88
+ assert.match(run().sandbox.tickHtml, / checked>/, 'the default is ticked');
89
+ });
90
+
91
+ test('a checklist that never watched the wait (a revisit) does not jump away', () => {
92
+ const { calls } = run({ armed: false });
93
+ assert.deepEqual([calls.opened, calls.assigned], [[], []]);
94
+ });
@@ -70,10 +70,10 @@ test('both slices are still findable (a rename must fail loudly, not silently pa
70
70
  // A faithful miniature: the stubs record what enterCreated ASKS FOR, which is the whole
71
71
  // contract — which panel is shown, whether the answers were reset, and what the offer
72
72
  // above step 1 was told to name.
73
- function runEnter({ focus, resumeIntent = false, draft = null, record }) {
73
+ function runEnter({ focus, resumeIntent = false, draft = null, record, handbackState = null }) {
74
74
  const store = new Map();
75
75
  if (draft) store.set('draft', JSON.stringify(draft));
76
- const calls = { show: [], resets: 0, offer: [], done: 0, progress: [], polls: 0, focused: 0 };
76
+ const calls = { show: [], resets: 0, offer: [], done: 0, progress: [], polls: 0, focused: 0, remembered: [] };
77
77
  // state.step is the DONE step for the whole life of a finished standup — the value
78
78
  // a draft-blind reading would have trusted
79
79
  const state = { step: draft ? draft.step : 6, name: 'Mercury', slug: 'mercury', domain: '', mode: 'greenfield', noAddress: false, modules: null };
@@ -83,6 +83,8 @@ function runEnter({ focus, resumeIntent = false, draft = null, record }) {
83
83
  live: { manifest: null, appSlug: null },
84
84
  manifestState: null,
85
85
  manifestPending: false,
86
+ handbackState, // the #ghstate=… a GitHub hand-back carries (task 1004548); null on a plain arrival
87
+ rememberManifest(st, status) { calls.remembered.push([st, status]); },
86
88
  sessionStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null) },
87
89
  resetForNewProject() { calls.resets += 1; state.step = 1; state.name = ''; state.slug = ''; },
88
90
  paintResumeOffer(c) { calls.offer.push(c ? (c.name || c.slug) : null); },
@@ -111,6 +113,19 @@ test('a reload / Back / identity settle (focus false) still resumes the done pan
111
113
  assert.deepEqual(calls.offer, [null], 'the offer is down — the done panel is on stage, not step 1');
112
114
  });
113
115
 
116
+ test('a hand-back from the GitHub callback page resumes watching THAT attempt (task 1004548)', () => {
117
+ const { calls, sandbox } = runEnter({ focus: false, record: { ...REC }, handbackState: 'ab'.repeat(32) });
118
+ assert.deepEqual(calls.show, [[6, false]], 'the founder lands on their checklist');
119
+ assert.equal(sandbox.manifestState, 'ab'.repeat(32), 'the sign-in step polls the attempt GitHub just approved');
120
+ assert.equal(sandbox.manifestPending, true, 'and the poll is queued for resumePolls()');
121
+ assert.equal(sandbox.live.manifest, 'code_received', 'it shows the wait, not "Set up GitHub sign-in" again');
122
+ assert.deepEqual(calls.remembered, [['ab'.repeat(32), 'code_received']], 'the attempt is kept across a reload');
123
+ assert.equal(sandbox.handbackState, null, 'read once — a later arrival is not a hand-back');
124
+ const done = runEnter({ focus: false, record: { ...REC, manifest: 'done' }, handbackState: 'cd'.repeat(32) });
125
+ assert.equal(done.sandbox.live.manifest, 'done', 'a finished setup is never walked back to waiting');
126
+ assert.equal(done.sandbox.manifestPending, false, 'and nothing is polled for it');
127
+ });
128
+
114
129
  test('a deliberate "New project" click after a finished standup opens a FRESH step 1', () => {
115
130
  const { calls, state } = runEnter({ focus: true, record: REC });
116
131
  assert.equal(calls.done, 0, 'the previous project\'s done panel is not painted');