@bongos/core 1.19.529 → 1.19.530
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.
- package/.bongos-core.json +24 -14
- package/docs/module-api-changelog.md +2 -0
- package/modules/provisioning/routes/callback-page.js +159 -0
- package/modules/provisioning/routes/provisioning.js +4 -136
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/design_tokens_sync.mjs +1 -1
- package/tests/provisioning_callback_page.mjs +74 -0
- package/tests/provisioning_module_selection.mjs +64 -4
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.530",
|
|
6
|
+
"core_contract": "1.19.530",
|
|
7
|
+
"source_commit": "f0252c888bf69ec2aadf5067e81ab801f6e6ef70",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-04T13:
|
|
9
|
+
"built_at": "2026-09-04T13:34:55.894Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 435,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1978,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2437,
|
|
20
|
+
"tree_sha256": "9d176777fdd02fcc72db1a22651a24c4cbb6d8ff5a4531567c1d07e25511c7e1",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -2617,7 +2617,7 @@
|
|
|
2617
2617
|
{
|
|
2618
2618
|
"path": "docs/module-api-changelog.md",
|
|
2619
2619
|
"mode": "0000644",
|
|
2620
|
-
"sha256": "
|
|
2620
|
+
"sha256": "b4be57f7972652783e91059ffccf9b0df8d972611955a66368c32bc14120dddc"
|
|
2621
2621
|
},
|
|
2622
2622
|
{
|
|
2623
2623
|
"path": "docs/modules-contract.md",
|
|
@@ -6064,10 +6064,15 @@
|
|
|
6064
6064
|
"mode": "0000644",
|
|
6065
6065
|
"sha256": "4c933aad1307378496d47960e24c9d797a1f122f28aefa27b769f336e4cf2e75"
|
|
6066
6066
|
},
|
|
6067
|
+
{
|
|
6068
|
+
"path": "modules/provisioning/routes/callback-page.js",
|
|
6069
|
+
"mode": "0000644",
|
|
6070
|
+
"sha256": "6b43ebf71a778277b94de1b9400f690fac9f51b48010e50a159af57195f99f02"
|
|
6071
|
+
},
|
|
6067
6072
|
{
|
|
6068
6073
|
"path": "modules/provisioning/routes/provisioning.js",
|
|
6069
6074
|
"mode": "0000644",
|
|
6070
|
-
"sha256": "
|
|
6075
|
+
"sha256": "e08199bc9146bd3122ca038637a0e390b9f5e636a102fa290dba414cf6cafdc1"
|
|
6071
6076
|
},
|
|
6072
6077
|
{
|
|
6073
6078
|
"path": "modules/provisioning/starter-bundles.js",
|
|
@@ -7327,12 +7332,12 @@
|
|
|
7327
7332
|
{
|
|
7328
7333
|
"path": "package-lock.json",
|
|
7329
7334
|
"mode": "0000644",
|
|
7330
|
-
"sha256": "
|
|
7335
|
+
"sha256": "c5ca6f4757bfbd6c174c11a75b767eb787bd202c8eda8e9ee1b51b7d76608b2a"
|
|
7331
7336
|
},
|
|
7332
7337
|
{
|
|
7333
7338
|
"path": "package.json",
|
|
7334
7339
|
"mode": "0000644",
|
|
7335
|
-
"sha256": "
|
|
7340
|
+
"sha256": "2795c4357358560ad6802a780c375b49326f09c1c79ea047702cdcc2b8c602dd"
|
|
7336
7341
|
},
|
|
7337
7342
|
{
|
|
7338
7343
|
"path": "public-docs/index.html",
|
|
@@ -9012,7 +9017,7 @@
|
|
|
9012
9017
|
{
|
|
9013
9018
|
"path": "src/module-api.js",
|
|
9014
9019
|
"mode": "0000644",
|
|
9015
|
-
"sha256": "
|
|
9020
|
+
"sha256": "a92fb66032716db9e4517d003bde07cb8283a2282d2b3925283c2018cb348598"
|
|
9016
9021
|
},
|
|
9017
9022
|
{
|
|
9018
9023
|
"path": "src/module-loader/catalog.js",
|
|
@@ -9752,7 +9757,7 @@
|
|
|
9752
9757
|
{
|
|
9753
9758
|
"path": "tests/design_tokens_sync.mjs",
|
|
9754
9759
|
"mode": "0000644",
|
|
9755
|
-
"sha256": "
|
|
9760
|
+
"sha256": "6fc1f0a595d3042b5b57b708643cfdc18007b3b33bb6684ffbeb46217da4c376"
|
|
9756
9761
|
},
|
|
9757
9762
|
{
|
|
9758
9763
|
"path": "tests/dev_lib.mjs",
|
|
@@ -11274,6 +11279,11 @@
|
|
|
11274
11279
|
"mode": "0000644",
|
|
11275
11280
|
"sha256": "9a04386b3047177bb55b6d392db88764f06f92e5c0b20c16ed9e0f8ce520b07d"
|
|
11276
11281
|
},
|
|
11282
|
+
{
|
|
11283
|
+
"path": "tests/provisioning_callback_page.mjs",
|
|
11284
|
+
"mode": "0000644",
|
|
11285
|
+
"sha256": "74d53ae7f3c93883bdc6db5187c904cc6c641267233d70bfef1a25d5b2a4ca05"
|
|
11286
|
+
},
|
|
11277
11287
|
{
|
|
11278
11288
|
"path": "tests/provisioning_default_domain.mjs",
|
|
11279
11289
|
"mode": "0000644",
|
|
@@ -11292,7 +11302,7 @@
|
|
|
11292
11302
|
{
|
|
11293
11303
|
"path": "tests/provisioning_module_selection.mjs",
|
|
11294
11304
|
"mode": "0000644",
|
|
11295
|
-
"sha256": "
|
|
11305
|
+
"sha256": "4a27e1641d949f261a8ba1b72816a8b077fc8a55862c4eef4091b453b7cfd45c"
|
|
11296
11306
|
},
|
|
11297
11307
|
{
|
|
11298
11308
|
"path": "tests/provisioning_onboard_mode.mjs",
|
|
@@ -1495,5 +1495,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1495
1495
|
landed since 1.19.527 with no explicit bump. run 33829339717. (task 1002620)
|
|
1496
1496
|
1.19.529 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1497
1497
|
landed since 1.19.528 with no explicit bump. run 33877197857. (task 1002620)
|
|
1498
|
+
1.19.530 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1499
|
+
landed since 1.19.529 with no explicit bump. run 33878817005. (task 1002620)
|
|
1498
1500
|
---------------------------------------------------------------------------
|
|
1499
1501
|
```
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// modules/provisioning/routes/callback-page.js — the standalone HTML the public
|
|
2
|
+
// GitHub-App-Manifest redirect lands on (task 1003570; the page itself is task 2096).
|
|
3
|
+
//
|
|
4
|
+
// WHY. This is pure PRESENTATION for exactly one route — `GET /provisioning/
|
|
5
|
+
// github-app/callback` in routes/provisioning.js — and it is ~135 lines of escaper,
|
|
6
|
+
// palette, stylesheet, client script and template with no request, DB or auth logic
|
|
7
|
+
// in it. Carrying it inside the route file pushed that file onto the 1500-line
|
|
8
|
+
// OVERSIZE_LINES fitness ratchet (scripts/gds/fitness-ratchets.js), where the next
|
|
9
|
+
// single added line failed `unit` and the only obvious escape — raising the baseline
|
|
10
|
+
// — is the one thing the ratchet forbids (ADR 0243 §4/§5 for the argument that put
|
|
11
|
+
// it there). Moved here verbatim so the route file has room to change again.
|
|
12
|
+
//
|
|
13
|
+
// Reaches core ONLY through the doorway src/module-api.js (the one-way rule, ADR
|
|
14
|
+
// 0083); it is required by routes/provisioning.js and by nothing else.
|
|
15
|
+
//
|
|
16
|
+
// Shape: no DB, no network, no request object — given a plain options bag it returns
|
|
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.
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const api = require('../../../src/module-api');
|
|
22
|
+
|
|
23
|
+
// Minimal HTML escaper for the callback landing page (no template engine here).
|
|
24
|
+
function esc(s) {
|
|
25
|
+
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
|
|
26
|
+
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
|
27
|
+
));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Accent pair resolved from the instance's own branding — the pack is the palette
|
|
31
|
+
// authority (task 1002810); the literal fallbacks are the neutral pack's values, so
|
|
32
|
+
// a branding validation error still paints the current family, never a stale one.
|
|
33
|
+
const ACCENT = (() => {
|
|
34
|
+
try {
|
|
35
|
+
const c = api.branding().theme?.ui?.colors || {};
|
|
36
|
+
return { sun: c.accent || '#fb9569', sun600: c.accentDeep || '#a3481a' };
|
|
37
|
+
} catch { return { sun: '#fb9569', sun600: '#a3481a' }; }
|
|
38
|
+
})();
|
|
39
|
+
|
|
40
|
+
// Brand styling for the standalone onboarding pages — sunrise palette + Baloo 2 /
|
|
41
|
+
// Quicksand (accents from the branding pack, config/branding.neutral.json resolved
|
|
42
|
+
// under the instance's own config/branding.json). Light + dark aware; no external
|
|
43
|
+
// dependency beyond an optional Google-Fonts link (system fallback if it doesn't load).
|
|
44
|
+
const PAGE_STYLE = `
|
|
45
|
+
:root{color-scheme:light dark;--sun:${ACCENT.sun};--sun600:${ACCENT.sun600};--sunInk:#2a1305;--coral:#fb7185;--sky:#38bdf8;--sky600:#0284c7;--ok:#16a34a;--warn:#f59e0b;--ink:#2a1f17;--inkSoft:#6b5d52;--card:#fffdfa;--line:#f0e2cf;--bg1:#fde8c8;--bg2:#fff4e6;--bg3:#fffdfa}
|
|
46
|
+
@media (prefers-color-scheme:dark){:root{--ink:#f5efe6;--inkSoft:#cbbdad;--card:#241b13;--line:#3a2c1e;--bg1:#2a1d10;--bg2:#1b130b;--bg3:#130d07}}
|
|
47
|
+
*{box-sizing:border-box}html,body{height:100%}
|
|
48
|
+
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}
|
|
49
|
+
.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)}
|
|
50
|
+
.badge-warn{font-size:2.4rem;line-height:1;margin:0 auto 1rem}
|
|
51
|
+
h1{font-family:"Baloo 2",system-ui,sans-serif;font-weight:700;font-size:1.5rem;line-height:1.15;margin:.2rem 0 .55rem}
|
|
52
|
+
.sub{color:var(--inkSoft);font-size:1.02rem;line-height:1.5;margin:0 auto;max-width:24rem}
|
|
53
|
+
.sub p{margin:.6rem 0}
|
|
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
|
+
@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
|
+
.cta{margin-top:1.4rem}
|
|
72
|
+
.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
|
+
.btn:hover{transform:translateY(-1px);box-shadow:0 14px 28px -8px rgba(249,115,22,.8)}
|
|
74
|
+
.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
|
+
`;
|
|
77
|
+
|
|
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 = `
|
|
87
|
+
(function(){
|
|
88
|
+
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();
|
|
111
|
+
})();
|
|
112
|
+
`;
|
|
113
|
+
|
|
114
|
+
// The standalone landing page for the public GitHub redirect (the mothership serves it;
|
|
115
|
+
// 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.
|
|
118
|
+
// • message — everything else (errors + the no-domain success fallback): a calm badge
|
|
119
|
+
// + `body` (server-authored, trusted HTML).
|
|
120
|
+
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');
|
|
123
|
+
const head = `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
124
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
125
|
+
<meta name="color-scheme" content="light dark">
|
|
126
|
+
<title>${esc(title)}</title>
|
|
127
|
+
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
128
|
+
<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
|
+
<style>${PAGE_STYLE}</style></head><body>`;
|
|
130
|
+
if (progress) {
|
|
131
|
+
return `${head}
|
|
132
|
+
<main class="card">
|
|
133
|
+
<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>
|
|
145
|
+
</main>
|
|
146
|
+
<script>window.__CB_SETUP__=${cfgJson};</script>
|
|
147
|
+
<script>${CALLBACK_SCRIPT}</script>
|
|
148
|
+
</body></html>`;
|
|
149
|
+
}
|
|
150
|
+
return `${head}
|
|
151
|
+
<main class="card">
|
|
152
|
+
<div class="badge-warn">${ok ? '✓' : '⚠️'}</div>
|
|
153
|
+
<h1>${esc(title)}</h1>
|
|
154
|
+
<div class="sub">${body || ''}</div>
|
|
155
|
+
</main>
|
|
156
|
+
</body></html>`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = { esc, callbackPage };
|
|
@@ -27,6 +27,9 @@
|
|
|
27
27
|
//
|
|
28
28
|
// Reaches core ONLY through the doorway src/module-api.js (the one-way rule, ADR
|
|
29
29
|
// 0083); it requires this module's own provisioning.js (same-dir), never a sibling.
|
|
30
|
+
// The GitHub-App callback page's presentation (esc / ACCENT / the stylesheet / the
|
|
31
|
+
// client script / callbackPage) lives in ./callback-page.js — moved out under task
|
|
32
|
+
// 1003570 because this file had reached the 1500-line fitness ratchet.
|
|
30
33
|
'use strict';
|
|
31
34
|
|
|
32
35
|
const crypto = require('node:crypto');
|
|
@@ -39,6 +42,7 @@ const { onboardPlan, stepsForShape } = require('../onboard-plan');
|
|
|
39
42
|
const { ALWAYS_ON_CORE, OPTIONAL_MODULES, starterBundleAnswerTable, optionalModules, effectiveModules } = require('../starter-bundles');
|
|
40
43
|
const { recommendationsForDetails } = require('../recommendations');
|
|
41
44
|
const { makeAskRateLimiter, clientIp } = require('../rate-limit');
|
|
45
|
+
const { callbackPage } = require('./callback-page');
|
|
42
46
|
// task 1003208: structured logging (pino via the doorway) — was console.*.
|
|
43
47
|
const log = api.logger('provisioning');
|
|
44
48
|
|
|
@@ -139,142 +143,6 @@ function badModuleSelection(modules) {
|
|
|
139
143
|
return null;
|
|
140
144
|
}
|
|
141
145
|
|
|
142
|
-
// Minimal HTML escaper for the callback landing page (no template engine here).
|
|
143
|
-
function esc(s) {
|
|
144
|
-
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
|
|
145
|
-
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
|
146
|
-
));
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Accent pair resolved from the instance's own branding — the pack is the palette
|
|
150
|
-
// authority (task 1002810); the literal fallbacks are the neutral pack's values, so
|
|
151
|
-
// a branding validation error still paints the current family, never a stale one.
|
|
152
|
-
const ACCENT = (() => {
|
|
153
|
-
try {
|
|
154
|
-
const c = api.branding().theme?.ui?.colors || {};
|
|
155
|
-
return { sun: c.accent || '#fb9569', sun600: c.accentDeep || '#a3481a' };
|
|
156
|
-
} catch { return { sun: '#fb9569', sun600: '#a3481a' }; }
|
|
157
|
-
})();
|
|
158
|
-
|
|
159
|
-
// Brand styling for the standalone onboarding pages — sunrise palette + Baloo 2 /
|
|
160
|
-
// Quicksand (accents from the branding pack, config/branding.neutral.json resolved
|
|
161
|
-
// under the instance's own config/branding.json). Light + dark aware; no external
|
|
162
|
-
// dependency beyond an optional Google-Fonts link (system fallback if it doesn't load).
|
|
163
|
-
const PAGE_STYLE = `
|
|
164
|
-
:root{color-scheme:light dark;--sun:${ACCENT.sun};--sun600:${ACCENT.sun600};--sunInk:#2a1305;--coral:#fb7185;--sky:#38bdf8;--sky600:#0284c7;--ok:#16a34a;--warn:#f59e0b;--ink:#2a1f17;--inkSoft:#6b5d52;--card:#fffdfa;--line:#f0e2cf;--bg1:#fde8c8;--bg2:#fff4e6;--bg3:#fffdfa}
|
|
165
|
-
@media (prefers-color-scheme:dark){:root{--ink:#f5efe6;--inkSoft:#cbbdad;--card:#241b13;--line:#3a2c1e;--bg1:#2a1d10;--bg2:#1b130b;--bg3:#130d07}}
|
|
166
|
-
*{box-sizing:border-box}html,body{height:100%}
|
|
167
|
-
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}
|
|
168
|
-
.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)}
|
|
169
|
-
.badge-warn{font-size:2.4rem;line-height:1;margin:0 auto 1rem}
|
|
170
|
-
h1{font-family:"Baloo 2",system-ui,sans-serif;font-weight:700;font-size:1.5rem;line-height:1.15;margin:.2rem 0 .55rem}
|
|
171
|
-
.sub{color:var(--inkSoft);font-size:1.02rem;line-height:1.5;margin:0 auto;max-width:24rem}
|
|
172
|
-
.sub p{margin:.6rem 0}
|
|
173
|
-
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}
|
|
174
|
-
@media (prefers-color-scheme:dark){code{color:var(--sky)}}
|
|
175
|
-
.steps{display:flex;align-items:flex-start;justify-content:center;margin:1.9rem 0 1.1rem}
|
|
176
|
-
.step{flex:1 1 0;max-width:9rem;position:relative}
|
|
177
|
-
.step::before{content:"";position:absolute;top:16px;left:-50%;width:100%;height:3px;background:var(--line);z-index:0;transition:background .4s ease}
|
|
178
|
-
.step:first-child::before{display:none}
|
|
179
|
-
.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}
|
|
180
|
-
.lbl{margin-top:.55rem;font-size:.78rem;color:var(--inkSoft);font-weight:600;line-height:1.25;padding:0 .2rem}
|
|
181
|
-
.step.done .dot{background:var(--ok);border-color:var(--ok);color:#fff}
|
|
182
|
-
.step.done::before{background:var(--ok)}
|
|
183
|
-
.step.active .dot{border-color:var(--sun);color:var(--sun600);animation:pulse 1.5s ease-in-out infinite}
|
|
184
|
-
.step.active .lbl{color:var(--ink)}
|
|
185
|
-
.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}
|
|
186
|
-
@keyframes spin{to{transform:rotate(360deg)}}
|
|
187
|
-
@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)}}
|
|
188
|
-
@media (prefers-reduced-motion:reduce){.step.active .dot,.step.active .dot::after{animation:none}}
|
|
189
|
-
.status-line{min-height:1.4em;color:var(--inkSoft);font-size:.94rem;margin:.4rem auto 0;max-width:24rem;line-height:1.45}
|
|
190
|
-
.cta{margin-top:1.4rem}
|
|
191
|
-
.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}
|
|
192
|
-
.btn:hover{transform:translateY(-1px);box-shadow:0 14px 28px -8px rgba(249,115,22,.8)}
|
|
193
|
-
.muted{color:var(--inkSoft);opacity:.9;font-size:.85rem;margin:1.1rem auto 0;max-width:24rem;line-height:1.45}
|
|
194
|
-
.hidden{display:none!important}
|
|
195
|
-
`;
|
|
196
|
-
|
|
197
|
-
// Client script for the PROGRESS variant only (task 2096): poll the public status
|
|
198
|
-
// endpoint, advance the stepper, and auto-open the instance the moment its credentials
|
|
199
|
-
// land. Deliberately contains NO backticks and NO ${...} so it survives the outer
|
|
200
|
-
// template literal verbatim; the only server data it reads is the JSON config block
|
|
201
|
-
// written just above it. Fails soft — a poll hiccup just retries; a long wait reveals a
|
|
202
|
-
// manual "open" button rather than ever falsely erroring. The auto-open countdown runs
|
|
203
|
-
// ONLY when this page is the wizard's pop-up; a same-tab return (pop-up blocked) keeps
|
|
204
|
-
// the founder here with the door and a way back to the checklist (task 1003236).
|
|
205
|
-
const CALLBACK_SCRIPT = `
|
|
206
|
-
(function(){
|
|
207
|
-
var cfg=window.__CB_SETUP__||{};
|
|
208
|
-
var state=cfg.state||'',domain=cfg.domain||'';
|
|
209
|
-
if(!state||!domain)return;
|
|
210
|
-
var target='https://'+domain;
|
|
211
|
-
var pollUrl=location.pathname.replace('/callback','/status')+'?state='+encodeURIComponent(state);
|
|
212
|
-
var steps=document.getElementById('steps'),statusEl=document.getElementById('status');
|
|
213
|
-
var cta=document.getElementById('cta'),go=document.getElementById('go'),foot=document.getElementById('foot'),back=document.getElementById('back');
|
|
214
|
-
/* Opened from the wizard as a pop-up (window.opener alive, or the tab still carries the name the wizard gave it):
|
|
215
|
-
the checklist is in the other tab, so this one may auto-open the instance. Same-tab fallback (pop-up blocked,
|
|
216
|
-
form target=_self): this IS the founder's checklist tab — never navigate it away; offer both doors (task 1003236). */
|
|
217
|
-
var popup=!!window.opener||window.name==='ghAppSetup';
|
|
218
|
-
var finished=false,tries=0,manual=false;
|
|
219
|
-
function dot(n){return steps.querySelector('[data-step="'+n+'"]');}
|
|
220
|
-
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');}}}
|
|
221
|
-
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';}}
|
|
222
|
-
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');
|
|
223
|
-
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;}
|
|
224
|
-
var n=4;(function tick(){if(n<=0){location.href=target;return;}go.textContent='Opening '+domain+' in '+n+'\\u2026';n--;setTimeout(tick,1000);})();}
|
|
225
|
-
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';}
|
|
226
|
-
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.';}
|
|
227
|
-
function schedule(){if(!finished)setTimeout(poll,3000);}
|
|
228
|
-
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();});}
|
|
229
|
-
setActive(2);poll();
|
|
230
|
-
})();
|
|
231
|
-
`;
|
|
232
|
-
|
|
233
|
-
// The standalone landing page for the public GitHub redirect (the mothership serves it;
|
|
234
|
-
// it is NOT the hall shell). Two variants share the Cloud Bongos brand shell (task 2096):
|
|
235
|
-
// • progress — ok + state + domain: an animated stepper that polls the status endpoint
|
|
236
|
-
// and auto-opens the instance when its credentials are placed.
|
|
237
|
-
// • message — everything else (errors + the no-domain success fallback): a calm badge
|
|
238
|
-
// + `body` (server-authored, trusted HTML).
|
|
239
|
-
function callbackPage({ ok, title, body, state, domain }) {
|
|
240
|
-
const progress = !!(ok && state && domain);
|
|
241
|
-
const cfgJson = JSON.stringify({ state: state || '', domain: domain || '' }).replace(/</g, '\\u003c');
|
|
242
|
-
const head = `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
243
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
244
|
-
<meta name="color-scheme" content="light dark">
|
|
245
|
-
<title>${esc(title)}</title>
|
|
246
|
-
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
247
|
-
<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">
|
|
248
|
-
<style>${PAGE_STYLE}</style></head><body>`;
|
|
249
|
-
if (progress) {
|
|
250
|
-
return `${head}
|
|
251
|
-
<main class="card">
|
|
252
|
-
<h1>${esc(title)}</h1>
|
|
253
|
-
<p class="sub">Finishing sign-in setup for <code>${esc(domain)}</code>.</p>
|
|
254
|
-
<div class="steps" id="steps" aria-label="Setup progress">
|
|
255
|
-
<div class="step done" data-step="1"><div class="dot">✓</div><div class="lbl">GitHub app created</div></div>
|
|
256
|
-
<div class="step active" data-step="2"><div class="dot">2</div><div class="lbl">Placing credentials</div></div>
|
|
257
|
-
<div class="step" data-step="3"><div class="dot">3</div><div class="lbl">Ready to sign in</div></div>
|
|
258
|
-
</div>
|
|
259
|
-
<p class="status-line" id="status">Hang tight — keep this tab open while we finish.</p>
|
|
260
|
-
<div class="cta hidden" id="cta"><a class="btn" id="go" href="https://${esc(domain)}">Open ${esc(domain)} →</a></div>
|
|
261
|
-
<p class="muted hidden" id="back"><a href="/projects?view=new">Finish your remaining setup steps →</a></p>
|
|
262
|
-
<p class="muted" id="foot">This page opens <code>${esc(domain)}</code> for you the moment it’s ready.</p>
|
|
263
|
-
<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>
|
|
264
|
-
</main>
|
|
265
|
-
<script>window.__CB_SETUP__=${cfgJson};</script>
|
|
266
|
-
<script>${CALLBACK_SCRIPT}</script>
|
|
267
|
-
</body></html>`;
|
|
268
|
-
}
|
|
269
|
-
return `${head}
|
|
270
|
-
<main class="card">
|
|
271
|
-
<div class="badge-warn">${ok ? '✓' : '⚠️'}</div>
|
|
272
|
-
<h1>${esc(title)}</h1>
|
|
273
|
-
<div class="sub">${body || ''}</div>
|
|
274
|
-
</main>
|
|
275
|
-
</body></html>`;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
146
|
// Register this module's kernel seam (ADR 0083 / the registerBoxSeams precedent).
|
|
279
147
|
// Idempotent — the loader calls this factory once at boot, but tests may
|
|
280
148
|
// instantiate the router repeatedly; gating on hasProvider keeps the port
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.530",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.530",
|
|
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.
|
|
3
|
+
"version": "1.19.530",
|
|
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",
|
package/src/module-api.js
CHANGED
|
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
45
45
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
46
46
|
// the entry to that file. Look for a version's history there, not here.
|
|
47
47
|
// ---------------------------------------------------------------------------
|
|
48
|
-
const CORE_VERSION = '1.19.
|
|
48
|
+
const CORE_VERSION = '1.19.530'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
49
49
|
|
|
50
50
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
51
51
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -132,7 +132,7 @@ test('the explore page mirrors the pack, var for var', () => {
|
|
|
132
132
|
|
|
133
133
|
test('the provisioning onboarding pages resolve their accent pair from branding', () => {
|
|
134
134
|
const source = fs.readFileSync(
|
|
135
|
-
path.join(REPO_ROOT, 'modules/provisioning/routes/
|
|
135
|
+
path.join(REPO_ROOT, 'modules/provisioning/routes/callback-page.js'), 'utf8');
|
|
136
136
|
assert.match(source, /--sun:\$\{/, '--sun interpolates a resolved value, never a literal');
|
|
137
137
|
assert.match(source, /--sun600:\$\{/, '--sun600 interpolates a resolved value, never a literal');
|
|
138
138
|
const block = source.match(/const ACCENT =[\s\S]*?\}\)\(\);/);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// tests/provisioning_callback_page.mjs — the pin for the extracted callback-page
|
|
2
|
+
// module (task 1003570).
|
|
3
|
+
//
|
|
4
|
+
// WHY. `esc` and `callbackPage` were carried inside modules/provisioning/routes/
|
|
5
|
+
// provisioning.js and were only ever exercised THROUGH the route
|
|
6
|
+
// (tests/provisioning_github_app_routes.mjs mounts the router and reads the served
|
|
7
|
+
// HTML). Once they became a module of their own they needed a pin of their own, or
|
|
8
|
+
// the extraction would be a surface nothing tests directly: this file requires
|
|
9
|
+
// callback-page.js and asserts the escaping contract and that both page variants
|
|
10
|
+
// carry the ESCAPED values, so a later edit that drops an esc() call — the XSS shape
|
|
11
|
+
// this helper exists to prevent — fails here rather than in a served page.
|
|
12
|
+
//
|
|
13
|
+
// DB-free and network-free: callback-page.js reaches core only for branding().
|
|
14
|
+
//
|
|
15
|
+
// Run: node --test --test-reporter=tap tests/provisioning_callback_page.mjs
|
|
16
|
+
|
|
17
|
+
import { test } from 'node:test';
|
|
18
|
+
import { strict as assert } from 'node:assert';
|
|
19
|
+
|
|
20
|
+
// ESM named imports, deliberately — knip credits a named CJS export to a
|
|
21
|
+
// destructured/named binding but not to a property read off a createRequire
|
|
22
|
+
// namespace, and these two exports have no other reader outside the route file.
|
|
23
|
+
import { esc, callbackPage } from '../modules/provisioning/routes/callback-page.js';
|
|
24
|
+
|
|
25
|
+
test('esc replaces every HTML-significant character, and nothing else', () => {
|
|
26
|
+
assert.equal(esc('&<>"\''), '&<>"'');
|
|
27
|
+
assert.equal(esc('<script>alert(1)</script>'), '<script>alert(1)</script>');
|
|
28
|
+
// repeats are all replaced, not just the first
|
|
29
|
+
assert.equal(esc('<<>>'), '<<>>');
|
|
30
|
+
// ordinary text passes through untouched
|
|
31
|
+
assert.equal(esc('mercury.example.com'), 'mercury.example.com');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('esc renders an absent value as the empty string, never "null"/"undefined"', () => {
|
|
35
|
+
assert.equal(esc(null), '');
|
|
36
|
+
assert.equal(esc(undefined), '');
|
|
37
|
+
assert.equal(esc(0), '0');
|
|
38
|
+
assert.equal(esc(false), 'false');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('the progress variant escapes the title + domain it interpolates', () => {
|
|
42
|
+
const html = callbackPage({
|
|
43
|
+
ok: true,
|
|
44
|
+
title: 'Setting up <your> instance',
|
|
45
|
+
state: 'GOODSTATE',
|
|
46
|
+
domain: 'evil".example.com',
|
|
47
|
+
});
|
|
48
|
+
assert.match(html, /<title>Setting up <your> instance<\/title>/);
|
|
49
|
+
assert.match(html, /evil"\.example\.com/, 'the domain rides through esc');
|
|
50
|
+
assert.doesNotMatch(html, /Setting up <your> instance/, 'no raw title anywhere on the page');
|
|
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"\}/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('the message variant escapes the title but passes server-authored body HTML through', () => {
|
|
58
|
+
const html = callbackPage({
|
|
59
|
+
ok: false,
|
|
60
|
+
title: 'This setup link has <expired>',
|
|
61
|
+
body: '<p>Ask the owner for a fresh link.</p>',
|
|
62
|
+
});
|
|
63
|
+
assert.match(html, /<h1>This setup link has <expired><\/h1>/);
|
|
64
|
+
assert.match(html, /<div class="sub"><p>Ask the owner for a fresh link\.<\/p><\/div>/,
|
|
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');
|
|
67
|
+
assert.match(html, /badge-warn">⚠️</, 'a failure wears the warning badge');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('ok without state or domain falls back to the message variant', () => {
|
|
71
|
+
const html = callbackPage({ ok: true, title: 'GitHub app created', body: '<p>Done.</p>' });
|
|
72
|
+
assert.doesNotMatch(html, /__CB_SETUP__/, 'nothing to poll for, so no client config block');
|
|
73
|
+
assert.match(html, /badge-warn">✓</, 'but it is still a success');
|
|
74
|
+
});
|
|
@@ -30,7 +30,7 @@ import http from 'node:http';
|
|
|
30
30
|
// ESM named imports off the CJS module on purpose — knip cannot trace a
|
|
31
31
|
// createRequire() import, so these would read as dead exports (the repo's knip
|
|
32
32
|
// learning; tests/provisioning_starter_bundles.mjs does the same).
|
|
33
|
-
import { ALWAYS_ON_CORE, bundleForProjectType } from '../modules/provisioning/starter-bundles.js';
|
|
33
|
+
import { ALWAYS_ON_CORE, bundleForProjectType, effectiveModules } from '../modules/provisioning/starter-bundles.js';
|
|
34
34
|
|
|
35
35
|
const require = createRequire(import.meta.url);
|
|
36
36
|
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
@@ -152,9 +152,13 @@ t('publicInstance: a row written before provisioning_014 reads as the bundle, no
|
|
|
152
152
|
|
|
153
153
|
let lastCreateArgs = null;
|
|
154
154
|
let existingRow = null;
|
|
155
|
+
// The row PATCH …/detail reads, and the row its UPDATE returned. Both null by
|
|
156
|
+
// default, so the POST /instances tests below see the same "no such id" world.
|
|
157
|
+
let detailRow = null;
|
|
158
|
+
let lastUpdatedRow = null;
|
|
155
159
|
provisioning.getInstanceBySlug = async () => existingRow;
|
|
156
160
|
provisioning.getInstanceByDomain = async () => null;
|
|
157
|
-
provisioning.getInstanceById = async () =>
|
|
161
|
+
provisioning.getInstanceById = async () => detailRow;
|
|
158
162
|
provisioning.createInstance = async (_db, args) => {
|
|
159
163
|
lastCreateArgs = args;
|
|
160
164
|
return existingRow
|
|
@@ -164,6 +168,18 @@ provisioning.createInstance = async (_db, args) => {
|
|
|
164
168
|
provisioning.enqueueIntent = async (_db, id, action) => ({ intent: { id: 9, action, state: 'pending' }, created: true });
|
|
165
169
|
provisioning.getOpenIntent = async () => null;
|
|
166
170
|
provisioning.recordEvent = async () => {};
|
|
171
|
+
// Mirrors the real statement: `detail = detail || $n::jsonb` MERGES the
|
|
172
|
+
// normalized answers onto what is already stored. The row the route then hands
|
|
173
|
+
// `effectiveModules` is that merged one, so a stub that returned the pre-patch
|
|
174
|
+
// row would hide exactly the staleness this test exists to catch.
|
|
175
|
+
provisioning.updateInstanceDetail = async (_db, _id, patch) => {
|
|
176
|
+
lastUpdatedRow = { ...detailRow };
|
|
177
|
+
if (patch.description !== undefined) lastUpdatedRow.description = patch.description;
|
|
178
|
+
if (patch.detail !== undefined) {
|
|
179
|
+
lastUpdatedRow.detail = { ...(detailRow.detail || {}), ...provisioning.normalizeProjectDetail(patch.detail) };
|
|
180
|
+
}
|
|
181
|
+
return lastUpdatedRow;
|
|
182
|
+
};
|
|
167
183
|
|
|
168
184
|
const api = require('../src/module-api.js');
|
|
169
185
|
api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
|
|
@@ -185,11 +201,11 @@ const server = app.listen(0);
|
|
|
185
201
|
await new Promise((r) => server.once('listening', r));
|
|
186
202
|
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
187
203
|
|
|
188
|
-
function
|
|
204
|
+
function send(method, path, body) {
|
|
189
205
|
return new Promise((resolve, reject) => {
|
|
190
206
|
const payload = JSON.stringify(body);
|
|
191
207
|
const req = http.request(`${base}${path}`, {
|
|
192
|
-
method
|
|
208
|
+
method,
|
|
193
209
|
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
|
|
194
210
|
}, (res) => {
|
|
195
211
|
let d = '';
|
|
@@ -200,6 +216,8 @@ function post(path, body) {
|
|
|
200
216
|
req.end(payload);
|
|
201
217
|
});
|
|
202
218
|
}
|
|
219
|
+
const post = (path, body) => send('POST', path, body);
|
|
220
|
+
const patch = (path, body) => send('PATCH', path, body);
|
|
203
221
|
|
|
204
222
|
await ta('POST /instances: the picker\'s ids ride the create request through to storage', async () => {
|
|
205
223
|
// Arrange
|
|
@@ -281,6 +299,48 @@ await ta('POST /instances: a re-request never changes an existing project\'s mod
|
|
|
281
299
|
existingRow = null;
|
|
282
300
|
});
|
|
283
301
|
|
|
302
|
+
// ── PATCH /instances/:id/detail, over the same mounted router ──────────────
|
|
303
|
+
// The interview's answer MOVES a bundle-sourced project's modules (BV1.R22, ADR
|
|
304
|
+
// 0243), so the reply that takes the answer says what the project now has rather
|
|
305
|
+
// than making the caller re-read. openapi.json names `modules` in this response's
|
|
306
|
+
// `required` list; nothing kept that promise until here — only the pure
|
|
307
|
+
// `effectiveModules` was pinned (tests/provisioning_starter_bundles.mjs).
|
|
308
|
+
|
|
309
|
+
await ta('PATCH /detail: the reply carries modules, resolved from the row the UPDATE returned', async () => {
|
|
310
|
+
// Arrange — a game project that skipped the picker, so its modules still
|
|
311
|
+
// resolve from the bundle and an interview answer can still move them.
|
|
312
|
+
detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null, detail: {} };
|
|
313
|
+
lastUpdatedRow = null;
|
|
314
|
+
|
|
315
|
+
// Act — the owner says they are building it alone
|
|
316
|
+
const res = await patch('/provisioning/instances/41/detail', { detail: { team_shape: 'solo' } });
|
|
317
|
+
|
|
318
|
+
// Assert — the field is there, and it is the POST-patch resolution: a route
|
|
319
|
+
// that answered off `inst` instead of `updated` would still say 'dev-box'.
|
|
320
|
+
assert.equal(res.status, 200, res.raw);
|
|
321
|
+
assert.ok(lastUpdatedRow, 'the route ran the update');
|
|
322
|
+
assert.deepEqual(res.body.modules, effectiveModules(lastUpdatedRow));
|
|
323
|
+
assert.deepEqual(res.body.modules.selected, ['discord'], 'solo drops the shared dev box out of the game bundle');
|
|
324
|
+
assert.equal(res.body.modules.source, 'bundle');
|
|
325
|
+
assert.ok(res.body.modules.adjustments.some((a) => a.module === 'dev-box'), 'and the change carries its reason');
|
|
326
|
+
detailRow = null; lastUpdatedRow = null;
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
await ta('PATCH /detail: a description-only patch answers with modules too — the field is not conditional', async () => {
|
|
330
|
+
// openapi lists `modules` as REQUIRED, so every 200 off this route carries it,
|
|
331
|
+
// including the ones that moved nothing a bundle reads.
|
|
332
|
+
detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'research', modules: ['agents', 'discord'], detail: {} };
|
|
333
|
+
lastUpdatedRow = null;
|
|
334
|
+
|
|
335
|
+
const res = await patch('/provisioning/instances/41/detail', { description: 'A study of small things.' });
|
|
336
|
+
|
|
337
|
+
assert.equal(res.status, 200, res.raw);
|
|
338
|
+
assert.deepEqual(res.body.modules, effectiveModules(lastUpdatedRow));
|
|
339
|
+
assert.deepEqual(res.body.modules.selected, ['agents', 'discord'], 'a chosen set is not re-derived from the type');
|
|
340
|
+
assert.equal(res.body.modules.source, 'chosen');
|
|
341
|
+
detailRow = null; lastUpdatedRow = null;
|
|
342
|
+
});
|
|
343
|
+
|
|
284
344
|
server.close();
|
|
285
345
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
286
346
|
process.exit(failed === 0 ? 0 : 1);
|