@bongos/core 1.21.93 → 1.21.95
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 +63 -38
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +9 -2
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +112 -9
- package/docs/api-reference.md +6 -3
- package/docs/copy-inventory.md +354 -354
- package/docs/copy-registry.json +420 -420
- package/docs/design/project-startup-direction.md +15 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +963 -992
- package/modules/provisioning/github-setup.js +129 -0
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/provisioning.js +3 -0
- package/modules/provisioning/routes/callback-page.js +83 -8
- package/modules/provisioning/routes/github-setup.js +221 -0
- package/modules/provisioning/routes/provisioning.js +11 -45
- package/modules/public-landing/public/projects.html +207 -66
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/oauth-secret.js +4 -2
- package/scripts/gds/provision-github-setup.js +148 -0
- package/scripts/gds/provision.js +19 -5
- package/src/module-api.js +1 -1
- package/tests/github_handback_any_device.mjs +340 -0
- package/tests/github_one_trip.mjs +297 -0
- package/tests/projects_hub.mjs +6 -2
- package/tests/projects_hub_dns_ready.mjs +2 -1
- package/tests/projects_hub_pre_uat.mjs +17 -9
- package/tests/provision.mjs +51 -9
- package/tests/provisioning_callback_page.mjs +3 -2
- package/tests/provisioning_github_app_routes.mjs +103 -12
- package/tests/wizard_draft_resume.mjs +4 -0
- package/tests/wizard_github_feedback.mjs +9 -5
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/provisioning/github-setup.js — "Connect GitHub" in one trip, and the checklist
|
|
4
|
+
// that ticks itself (task 1004649; the design of record is docs/design/mocks/day-night-rooms/
|
|
5
|
+
// part 03: Approve the <name> app on GitHub · Install it on your repo · Builders can sign in ·
|
|
6
|
+
// Command-line sign-in is on).
|
|
7
|
+
//
|
|
8
|
+
// WHY. A founder used to make three separate trips to github.com after Create (approve the
|
|
9
|
+
// app, tick "Enable Device Flow", install the app on the repo), and nothing ever checked the
|
|
10
|
+
// last two. Now the manifest carries a setup_url, so GitHub sends the founder from "app
|
|
11
|
+
// created" straight on to "install it on your repo" and then back to the project page — and
|
|
12
|
+
// every tick on the checklist is a fact the SERVER has seen, never a click the page assumed:
|
|
13
|
+
//
|
|
14
|
+
// approve — GitHub created the app AND the runner placed its credentials (the runner
|
|
15
|
+
// records the app's slug only after the placement write, provision.js).
|
|
16
|
+
// install — the runner, holding the app's own key, asked GitHub whether the app is
|
|
17
|
+
// installed on the project's repo and GitHub said yes ('github-app-installed').
|
|
18
|
+
// The installation_id GitHub appends to the setup_url is NOT trusted: GitHub's
|
|
19
|
+
// docs warn it can be spoofed, so that redirect only records that the founder
|
|
20
|
+
// SAYS they installed it ('github-app-install-reported'), which makes the
|
|
21
|
+
// runner look again.
|
|
22
|
+
// sign_in — the runner saw the project report auth_configured (the manifest row 'done').
|
|
23
|
+
// device — the runner started a device-flow sign-in with the app's client id and GitHub
|
|
24
|
+
// did not refuse it with device_flow_disabled ('github-app-device-flow').
|
|
25
|
+
// GitHub offers no manifest field and no API to switch device flow on (the
|
|
26
|
+
// spike in this task, written up in docs/design/project-startup-direction.md),
|
|
27
|
+
// so this step is OPTIONAL: it reads 'later' until the founder ticks the box on
|
|
28
|
+
// GitHub, and it never holds the project back from being alive.
|
|
29
|
+
//
|
|
30
|
+
// No new columns (ADR 0083 §5 additive rule; this task carries no migration): the verified
|
|
31
|
+
// facts are rows of the existing append-only provisioning_events ledger, read since the
|
|
32
|
+
// latest attempt began. The db is injected.
|
|
33
|
+
|
|
34
|
+
// The provisioning_events names this leg writes. One table, so the runner (which writes the
|
|
35
|
+
// verified ones), the setup redirect (which writes the reported one) and the reader agree.
|
|
36
|
+
const SETUP_EVENTS = Object.freeze({
|
|
37
|
+
installReported: 'github-app-install-reported',
|
|
38
|
+
installed: 'github-app-installed',
|
|
39
|
+
deviceFlow: 'github-app-device-flow',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// How long after an attempt settles the runner keeps checking for the install and the
|
|
43
|
+
// device-flow tick. Past it the checklist stops promising a self-tick for device flow
|
|
44
|
+
// (the step says "turn it on later"); a fresh install report re-opens the window.
|
|
45
|
+
const VERIFY_WINDOW_MS = 2 * 60 * 60 * 1000;
|
|
46
|
+
|
|
47
|
+
// The one-trip install page for the app GitHub just created, carrying the attempt's state
|
|
48
|
+
// so GitHub hands it back on the setup_url redirect. null until the app's slug is known.
|
|
49
|
+
// PURE. (The callback page narrows it to the repo with /permissions?suggested_target_id
|
|
50
|
+
// when it can learn the numeric ids — see callback-page.js.)
|
|
51
|
+
function installUrlFor({ appSlug, state } = {}) {
|
|
52
|
+
if (!appSlug) return null;
|
|
53
|
+
const q = state ? `?state=${encodeURIComponent(state)}` : '';
|
|
54
|
+
return `https://github.com/apps/${encodeURIComponent(appSlug)}/installations/new${q}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// owner/repo from the instance's target_ref, or null. Strict — this string reaches a
|
|
58
|
+
// public read and the callback page's GitHub lookup.
|
|
59
|
+
const REPO_REF_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9._-]{1,100}$/;
|
|
60
|
+
function targetRepo(inst) {
|
|
61
|
+
const ref = String((inst && inst.target_ref) || '').trim().replace(/\.git$/, '');
|
|
62
|
+
return REPO_REF_RE.test(ref) ? ref : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// The verified facts for one instance since `since` (the attempt's created_at): when each
|
|
66
|
+
// setup event was first seen, or null. One indexed read (idx_provisioning_events_instance).
|
|
67
|
+
async function githubSetupFacts(db, instanceId, since) {
|
|
68
|
+
const { rows } = await db.query(
|
|
69
|
+
`SELECT event, min(created_at) AS at FROM provisioning_events
|
|
70
|
+
WHERE instance_id = $1 AND event = ANY($2) AND created_at >= $3
|
|
71
|
+
GROUP BY event`,
|
|
72
|
+
[instanceId, Object.values(SETUP_EVENTS), since || new Date(0).toISOString()]);
|
|
73
|
+
const at = (name) => {
|
|
74
|
+
const r = rows.find((x) => x.event === name);
|
|
75
|
+
return r && r.at ? new Date(r.at).toISOString() : null;
|
|
76
|
+
};
|
|
77
|
+
return { installReportedAt: at(SETUP_EVENTS.installReported), installedAt: at(SETUP_EVENTS.installed), deviceFlowAt: at(SETUP_EVENTS.deviceFlow) };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Each checklist step's state, from the latest attempt's EFFECTIVE status (manifest-truth's
|
|
81
|
+
// vocabulary) + the facts above. A closed vocabulary the page turns into words:
|
|
82
|
+
// 'done' · 'now' (waiting on GitHub, the founder or the platform) · 'todo' (not reached)
|
|
83
|
+
// · 'stopped' (this attempt failed at this step) · 'later' (device flow only: optional, off).
|
|
84
|
+
// PURE.
|
|
85
|
+
function githubChecklist(status, appSlug, facts = {}) {
|
|
86
|
+
const s = String(status || '');
|
|
87
|
+
const inFlight = s === 'pending' || s === 'code_received' || s === 'converting';
|
|
88
|
+
const failed = s === 'error' || s === 'expired';
|
|
89
|
+
const approve = appSlug ? 'done' : inFlight ? 'now' : failed ? 'stopped' : 'todo';
|
|
90
|
+
const install = facts.installedAt ? 'done' : appSlug ? 'now' : 'todo';
|
|
91
|
+
const signIn = s === 'done' ? 'done' : appSlug && failed ? 'stopped' : appSlug ? 'now' : 'todo';
|
|
92
|
+
const device = facts.deviceFlowAt ? 'done' : s === 'done' ? 'later' : 'todo';
|
|
93
|
+
return {
|
|
94
|
+
approve, install, sign_in: signIn, device_flow: device,
|
|
95
|
+
// what "is alive" waits for — device flow is optional by design
|
|
96
|
+
connected: approve === 'done' && install === 'done' && signIn === 'done',
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Is the runner still checking this attempt? While it is, an install or a 'later'
|
|
101
|
+
// device-flow step can still tick itself, so the page keeps polling. PURE.
|
|
102
|
+
function stillVerifying(row, facts = {}, now = Date.now()) {
|
|
103
|
+
const settled = row && (row.resolved_at || row.updated_at);
|
|
104
|
+
const last = Math.max(settled ? new Date(settled).getTime() : 0, facts.installReportedAt ? new Date(facts.installReportedAt).getTime() : 0);
|
|
105
|
+
return last > 0 && now - last < VERIFY_WINDOW_MS;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// What the public status poll and the owner's instance read add for this leg: the checklist,
|
|
109
|
+
// the install link (until the install is verified), the repo it belongs on, and whether the
|
|
110
|
+
// runner is still checking. `status` is manifest-truth's EFFECTIVE status. Never the raw row.
|
|
111
|
+
// `withState` false (the owner's instance read) leaves the state token off the install
|
|
112
|
+
// link: that read never carries the token (task 1003235), and a stateless install still
|
|
113
|
+
// lands on the setup page, which sends the founder back to their projects.
|
|
114
|
+
// A failed facts read is "nothing verified yet" — the honest 'now' states, never a 500.
|
|
115
|
+
async function githubSetupRead(db, inst, row, status, { now = Date.now(), withState = true } = {}) {
|
|
116
|
+
if (!row) return { checklist: githubChecklist(null, null), install_url: null, repo: targetRepo(inst), verifying: false };
|
|
117
|
+
let facts = {};
|
|
118
|
+
// through module.exports so a route test can stub the one DB read on the cached module
|
|
119
|
+
try { facts = await module.exports.githubSetupFacts(db, row.instance_id, row.created_at); } catch (_) { facts = {}; }
|
|
120
|
+
const checklist = githubChecklist(status, row.app_slug, facts);
|
|
121
|
+
return {
|
|
122
|
+
checklist,
|
|
123
|
+
install_url: checklist.install === 'done' ? null : installUrlFor({ appSlug: row.app_slug, state: withState ? row.state : null }),
|
|
124
|
+
repo: targetRepo(inst),
|
|
125
|
+
verifying: stillVerifying(row, facts, now),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { SETUP_EVENTS, VERIFY_WINDOW_MS, installUrlFor, targetRepo, githubSetupFacts, githubChecklist, stillVerifying, githubSetupRead };
|
|
@@ -754,6 +754,9 @@ function buildGithubAppManifest({ instanceName, slug, instanceDomain, mothership
|
|
|
754
754
|
// is never called while inactive; point it at the mothership so it's a real host.
|
|
755
755
|
hook_attributes: { url: `${String(mothershipOrigin || '').replace(/\/+$/, '')}/api/bongos/provisioning/github-app/webhook`, active: false },
|
|
756
756
|
redirect_url: redirectUrl,
|
|
757
|
+
// One trip (task 1004649): after the install GitHub sends the founder here with the
|
|
758
|
+
// attempt's state, and the route hands them back to the project page.
|
|
759
|
+
setup_url: redirectUrl.replace(/\/callback$/, '/setup'), setup_on_update: true,
|
|
757
760
|
callback_urls: [
|
|
758
761
|
`${instOrigin}/api/gds/auth/web/callback`,
|
|
759
762
|
`${instOrigin}/api/bongos/auth/web/callback`,
|
|
@@ -27,6 +27,16 @@ function esc(s) {
|
|
|
27
27
|
));
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
// The way back to the project (task 1004650): the hub's return route, keyed on the attempt's
|
|
31
|
+
// one-time state. The SERVER resolves that state to the project it was minted for and lands
|
|
32
|
+
// its signed-in owner on that project's part 03 (routes/github-setup.js), so the hand-back no
|
|
33
|
+
// longer depends on what the starting tab kept in sessionStorage — approving on a phone or in
|
|
34
|
+
// another window lands on the right project. No state names no project: the projects list.
|
|
35
|
+
const RETURN_PATH = '/api/bongos/provisioning/github-app/return';
|
|
36
|
+
function returnHref(state) {
|
|
37
|
+
return state ? `${RETURN_PATH}?state=${encodeURIComponent(state)}` : '/projects?view=mine';
|
|
38
|
+
}
|
|
39
|
+
|
|
30
40
|
// Accent pair resolved from the instance's own branding — the pack is the palette
|
|
31
41
|
// authority (task 1002810); the literal fallbacks are the neutral pack's values, so
|
|
32
42
|
// a branding validation error still paints the current family, never a stale one.
|
|
@@ -65,15 +75,15 @@ code{font-family:"Fira Code",ui-monospace,SFMono-Regular,monospace;background:rg
|
|
|
65
75
|
// page hands them straight back: as the wizard's pop-up it closes itself so the checklist
|
|
66
76
|
// tab is in front again (that tab is already polling the same status and shows the wait);
|
|
67
77
|
// if the browser refuses the close, or this was a same-tab return (pop-up blocked), this
|
|
68
|
-
// tab becomes the checklist. The
|
|
69
|
-
// server
|
|
78
|
+
// tab becomes the checklist. The way back is the return route (returnHref above): the
|
|
79
|
+
// server names the project for the state, so the checklist opened here is the right one
|
|
80
|
+
// whatever this browser remembers (task 1004650).
|
|
70
81
|
// Deliberately contains NO backticks and NO ${...} so it survives the outer template
|
|
71
82
|
// literal verbatim; the only server data it reads is the JSON config block above it.
|
|
72
83
|
const HANDBACK_SCRIPT = `
|
|
73
84
|
(function(){
|
|
74
85
|
var cfg=window.__CB_SETUP__||{};
|
|
75
|
-
var back=cfg.back||'/projects?view=
|
|
76
|
-
if(cfg.state)back+='#ghstate='+encodeURIComponent(cfg.state);
|
|
86
|
+
var back=cfg.back||'/projects?view=mine';
|
|
77
87
|
var link=document.getElementById('back');if(link)link.href=back;
|
|
78
88
|
var popup=false;
|
|
79
89
|
try{popup=!!window.opener&&!window.opener.closed;}catch(e){}
|
|
@@ -82,15 +92,67 @@ const HANDBACK_SCRIPT = `
|
|
|
82
92
|
})();
|
|
83
93
|
`;
|
|
84
94
|
|
|
95
|
+
// Client script for the ONE-TRIP variant (task 1004649). GitHub just created the app and
|
|
96
|
+
// sent the founder here; the runner now turns GitHub's code into credentials and names the
|
|
97
|
+
// app. This page waits for that name on the state-keyed status poll, then sends the founder
|
|
98
|
+
// straight on to installing the app — narrowed to the project's own repo when GitHub will
|
|
99
|
+
// tell an anonymous caller the repo's numeric ids (a public repo; a private one falls back
|
|
100
|
+
// to the plain install page, where GitHub asks which account). GitHub's setup_url brings
|
|
101
|
+
// them back to the checklist afterwards. Already installed, or the attempt stopped → back
|
|
102
|
+
// to the checklist at once, which says what happened. A wait past five minutes stops and
|
|
103
|
+
// says so; the way back is always on the page. Same no-backtick / no-${...} rule as above.
|
|
104
|
+
const INSTALL_SCRIPT = `
|
|
105
|
+
(function(){
|
|
106
|
+
var cfg=window.__CB_SETUP__||{};
|
|
107
|
+
var back=cfg.back||'/projects?view=mine';
|
|
108
|
+
var link=document.getElementById('back');if(link)link.href=back;
|
|
109
|
+
var note=document.getElementById('note');
|
|
110
|
+
var say=function(t){if(note)note.textContent=t;};
|
|
111
|
+
var tries=0,MAX=150;
|
|
112
|
+
function narrow(base,ownerId,repoId){
|
|
113
|
+
var parts=String(base).split('?');
|
|
114
|
+
return parts[0]+'/permissions?'+(parts[1]?parts[1]+'&':'')+'suggested_target_id='+encodeURIComponent(ownerId)+'&repository_ids[]='+encodeURIComponent(repoId);
|
|
115
|
+
}
|
|
116
|
+
function go(url){say('Opening the install page on GitHub…');location.replace(url);}
|
|
117
|
+
function toInstall(d){
|
|
118
|
+
if(!d.repo){go(d.install_url);return;}
|
|
119
|
+
fetch('https://api.github.com/repos/'+d.repo,{headers:{Accept:'application/vnd.github+json'}})
|
|
120
|
+
.then(function(r){return r.ok?r.json():null;})
|
|
121
|
+
.then(function(j){go(j&&j.id&&j.owner&&j.owner.id?narrow(d.install_url,j.owner.id,j.id):d.install_url);})
|
|
122
|
+
.catch(function(){go(d.install_url);});
|
|
123
|
+
}
|
|
124
|
+
function tick(){
|
|
125
|
+
tries++;
|
|
126
|
+
fetch('/api/bongos/provisioning/github-app/status?state='+encodeURIComponent(cfg.state||''))
|
|
127
|
+
.then(function(r){return r.ok?r.json():null;})
|
|
128
|
+
.then(function(d){
|
|
129
|
+
if(d&&d.checklist&&d.checklist.install==='done'){location.replace(back);return;}
|
|
130
|
+
if(d&&(d.status==='error'||d.status==='expired')){location.replace(back);return;}
|
|
131
|
+
if(d&&d.install_url){toInstall(d);return;}
|
|
132
|
+
again();
|
|
133
|
+
})
|
|
134
|
+
.catch(again);
|
|
135
|
+
}
|
|
136
|
+
function again(){
|
|
137
|
+
if(tries>=MAX){say('This is taking longer than usual. Go back to your project: the install step waits for you there.');return;}
|
|
138
|
+
setTimeout(tick,2000);
|
|
139
|
+
}
|
|
140
|
+
tick();
|
|
141
|
+
})();
|
|
142
|
+
`;
|
|
143
|
+
|
|
85
144
|
// The standalone landing page for the public GitHub redirect (the mothership serves it;
|
|
86
145
|
// it is NOT the hall shell). Two variants share the Cloud Bongos brand shell (task 2096):
|
|
87
146
|
// • hand-back — ok + state + domain: a brief "GitHub app created" that returns the
|
|
88
147
|
// founder to their setup checklist, where the wait is shown (task 1004548).
|
|
148
|
+
// • install — the hand-back's inputs + next:'install' (task 1004649): "GitHub app
|
|
149
|
+
// created", then straight on to installing it on the repo (INSTALL_SCRIPT).
|
|
89
150
|
// • message — everything else (errors + the no-domain success fallback): a calm badge
|
|
90
151
|
// + `body` (server-authored, trusted HTML).
|
|
91
|
-
function callbackPage({ ok, title, body, state, domain }) {
|
|
152
|
+
function callbackPage({ ok, title, body, state, domain, next }) {
|
|
92
153
|
const handback = !!(ok && state && domain);
|
|
93
|
-
const
|
|
154
|
+
const back = returnHref(state);
|
|
155
|
+
const cfgJson = JSON.stringify({ state: state || '', domain: domain || '', back }).replace(/</g, '\\u003c');
|
|
94
156
|
const head = `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
95
157
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
96
158
|
<meta name="color-scheme" content="light dark">
|
|
@@ -98,13 +160,26 @@ function callbackPage({ ok, title, body, state, domain }) {
|
|
|
98
160
|
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
99
161
|
<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">
|
|
100
162
|
<style>${PAGE_STYLE}</style></head><body>`;
|
|
163
|
+
if (handback && next === 'install') {
|
|
164
|
+
return `${head}
|
|
165
|
+
<main class="card">
|
|
166
|
+
<div class="badge-warn">✓</div>
|
|
167
|
+
<h1>${esc(title)}</h1>
|
|
168
|
+
<p class="sub">Next, install it on your repo. Taking you there now.</p>
|
|
169
|
+
<p class="muted" id="note" role="status" aria-live="polite">Waiting for GitHub to hand over the app…</p>
|
|
170
|
+
<div class="cta"><a class="btn" id="back" href="${esc(back)}">Back to my project</a></div>
|
|
171
|
+
</main>
|
|
172
|
+
<script>window.__CB_SETUP__=${cfgJson};</script>
|
|
173
|
+
<script>${INSTALL_SCRIPT}</script>
|
|
174
|
+
</body></html>`;
|
|
175
|
+
}
|
|
101
176
|
if (handback) {
|
|
102
177
|
return `${head}
|
|
103
178
|
<main class="card">
|
|
104
179
|
<div class="badge-warn">✓</div>
|
|
105
180
|
<h1>${esc(title)}</h1>
|
|
106
181
|
<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="
|
|
182
|
+
<div class="cta"><a class="btn" id="back" href="${esc(back)}">Back to my checklist →</a></div>
|
|
108
183
|
</main>
|
|
109
184
|
<script>window.__CB_SETUP__=${cfgJson};</script>
|
|
110
185
|
<script>${HANDBACK_SCRIPT}</script>
|
|
@@ -119,4 +194,4 @@ function callbackPage({ ok, title, body, state, domain }) {
|
|
|
119
194
|
</body></html>`;
|
|
120
195
|
}
|
|
121
196
|
|
|
122
|
-
module.exports = { esc, callbackPage };
|
|
197
|
+
module.exports = { esc, callbackPage, returnHref, RETURN_PATH };
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// modules/provisioning/routes/github-setup.js — the GitHub leg's two PUBLIC, state-keyed
|
|
2
|
+
// routes: the progress poll the project page and the callback page read, and the setup_url
|
|
3
|
+
// GitHub sends the founder to after installing the app (task 1004649 — "Connect GitHub in
|
|
4
|
+
// one trip"; ADR 0133 is the manifest design).
|
|
5
|
+
//
|
|
6
|
+
// Its own routes file (declared in module.json) because routes/provisioning.js sits at the
|
|
7
|
+
// 1500-line size ratchet; the status poll moved here from it unchanged in key and contract.
|
|
8
|
+
// Both routes are public by the same model as the manifest callback: the unguessable,
|
|
9
|
+
// single-use `state` minted for the signed-in owner is the only key, and neither route
|
|
10
|
+
// reveals a secret or changes authority. The checklist they report is derived in
|
|
11
|
+
// ../github-setup.js from what the control-plane runner has VERIFIED.
|
|
12
|
+
//
|
|
13
|
+
// Two signed-in routes join them (task 1004650 — the hand-back from any tab or device):
|
|
14
|
+
// the RETURN route the callback and setup pages send the founder to, which lands the state's
|
|
15
|
+
// own project for its owner and nobody else, and the RESUME read the project page asks when
|
|
16
|
+
// its tab remembers nothing — so a phone, a second window or a fresh tab picks up part 03 of
|
|
17
|
+
// the right project from what the server knows, never from sessionStorage.
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const express = require('express');
|
|
21
|
+
const api = require('../../../src/module-api');
|
|
22
|
+
const { pool } = api;
|
|
23
|
+
const provisioning = require('../provisioning');
|
|
24
|
+
const githubSetup = require('../github-setup');
|
|
25
|
+
const { makeAskRateLimiter } = require('../rate-limit');
|
|
26
|
+
const { callbackPage, returnHref } = require('./callback-page');
|
|
27
|
+
|
|
28
|
+
const log = api.logger('provisioning');
|
|
29
|
+
|
|
30
|
+
// The state tokens this leg mints are hex (provisioning.js); anything else is not one of ours.
|
|
31
|
+
const STATE_RE = /^[0-9a-f]{16,128}$/i;
|
|
32
|
+
|
|
33
|
+
// An attempt or a project older than this is not "in progress" for the resume read: a
|
|
34
|
+
// founder who walked away a week ago and opens the create page gets a fresh page.
|
|
35
|
+
const RESUME_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
36
|
+
|
|
37
|
+
// Is this instance one the hand-back may land on for this builder? Strictly the OWNER —
|
|
38
|
+
// no Archon override: the return route names a project to whoever holds the state, and a
|
|
39
|
+
// project page is where its owner, and only its owner, connects GitHub. A gone project is
|
|
40
|
+
// no landing either. PURE.
|
|
41
|
+
function ownsLanding(inst, builderId) {
|
|
42
|
+
return !!(inst && builderId != null && String(inst.owner_builder_id) === String(builderId)
|
|
43
|
+
&& !inst.torn_down_at && inst.status !== 'torn_down');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The project page's resume record: just what part 03 needs to name and watch the project
|
|
47
|
+
// (the page reads the rest from the owner's instance read). Never the state token. PURE.
|
|
48
|
+
function resumeRecord(inst) {
|
|
49
|
+
return { id: inst.id, slug: inst.slug, domain: inst.domain || null, onboard_mode: inst.onboard_mode || 'greenfield', repo: githubSetup.targetRepo(inst) };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The project page to land on: part 03 of that project, by id. The state rides the
|
|
53
|
+
// FRAGMENT (never sent to a server) so the page can keep watching that attempt. PURE.
|
|
54
|
+
function landingHref(instanceId, state) {
|
|
55
|
+
return `/projects?view=new&id=${encodeURIComponent(String(instanceId))}${state ? `#ghstate=${encodeURIComponent(state)}` : ''}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// GitHub's installation id is digits; anything else is dropped before it reaches a log line.
|
|
59
|
+
function installationIdOf(q) {
|
|
60
|
+
const s = String((q && q.installation_id) || '');
|
|
61
|
+
return /^\d{1,20}$/.test(s) ? s : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = function buildGithubSetupRouter() {
|
|
65
|
+
const router = express.Router();
|
|
66
|
+
// the page polls every few seconds, the callback page every two — per-IP, in-module
|
|
67
|
+
const statusRateLimit = makeAskRateLimiter({ max: 120 });
|
|
68
|
+
const setupRateLimit = makeAskRateLimiter({ max: 30 });
|
|
69
|
+
const returnRateLimit = makeAskRateLimiter({ max: 30 });
|
|
70
|
+
|
|
71
|
+
// GET /provisioning/github-app/status?state=<> — the PUBLIC progress read (task 1004548;
|
|
72
|
+
// the checklist since task 1004649). Reveals the manifest lifecycle status, the
|
|
73
|
+
// instance's own (already-public) domain and repo, the App's public slug, and the
|
|
74
|
+
// four-step checklist with the one-trip install link — never a secret. `ready` is true
|
|
75
|
+
// iff sign-in is verified (the auto-open signal). `terminal` now waits for the install
|
|
76
|
+
// too: a finished sign-in with the app not yet installed is not the end of the trip.
|
|
77
|
+
// rank: public — no session, no mutation, no secret; an unknown state is a 404 no-op.
|
|
78
|
+
router.get('/provisioning/github-app/status', statusRateLimit, async (req, res) => {
|
|
79
|
+
try {
|
|
80
|
+
const state = String(req.query.state || '');
|
|
81
|
+
if (!state) return res.status(400).json({ ok: false, error: 'missing_state' });
|
|
82
|
+
const row = await provisioning.getOAuthManifestByState(pool, state);
|
|
83
|
+
if (!row) return res.status(404).json({ ok: false, error: 'unknown_state' });
|
|
84
|
+
const inst = await provisioning.getInstanceById(pool, row.instance_id).catch(() => null);
|
|
85
|
+
// EFFECTIVE status + how far a failure got (task 1004572, manifest-truth.js)
|
|
86
|
+
const { status, failure } = provisioning.publicGithubApp(row);
|
|
87
|
+
const setup = await githubSetup.githubSetupRead(pool, inst, row, status);
|
|
88
|
+
const failed = status === 'error' || status === 'expired';
|
|
89
|
+
return res.json({
|
|
90
|
+
ok: true,
|
|
91
|
+
status,
|
|
92
|
+
failure,
|
|
93
|
+
ready: status === 'done',
|
|
94
|
+
terminal: failed || (setup.checklist.connected && (setup.checklist.device_flow === 'done' || !setup.verifying)),
|
|
95
|
+
domain: inst && inst.domain ? inst.domain : null,
|
|
96
|
+
instance_status: inst ? inst.status : null,
|
|
97
|
+
// The App's public slug (task 1003049) — its GitHub page is public by design.
|
|
98
|
+
app_slug: row.app_slug || null,
|
|
99
|
+
checklist: setup.checklist,
|
|
100
|
+
install_url: setup.install_url,
|
|
101
|
+
repo: setup.repo,
|
|
102
|
+
verifying: setup.verifying,
|
|
103
|
+
});
|
|
104
|
+
} catch (err) {
|
|
105
|
+
log.error('[provisioning] GET /provisioning/github-app/status', err);
|
|
106
|
+
return res.status(500).json({ ok: false, error: 'status_check_failed' });
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// GET /provisioning/github-app/setup?installation_id=&setup_action=&state= — the App's
|
|
111
|
+
// setup_url (task 1004649). GitHub sends the founder's BROWSER here after they install
|
|
112
|
+
// the app (or change its repos), carrying the state we put on the install link. GitHub's
|
|
113
|
+
// own docs warn the installation_id can be spoofed, so this route trusts NOTHING it is
|
|
114
|
+
// handed: it records only that an install was REPORTED for the attempt — which makes the
|
|
115
|
+
// runner ask GitHub, with the app's own key, whether the app really is on the repo — and
|
|
116
|
+
// hands the founder back to the project page. A missing or unknown state gets the same
|
|
117
|
+
// way back, without the attempt. rank: public — a browser redirect with no session; no
|
|
118
|
+
// secret revealed, no authority changed, the only write an audit row.
|
|
119
|
+
router.get('/provisioning/github-app/setup', setupRateLimit, async (req, res) => {
|
|
120
|
+
const sendPage = (status, page) => res.status(status).type('html').send(page);
|
|
121
|
+
try {
|
|
122
|
+
const state = String(req.query.state || '');
|
|
123
|
+
const row = STATE_RE.test(state) ? await provisioning.getOAuthManifestByState(pool, state) : null;
|
|
124
|
+
if (!row) {
|
|
125
|
+
return sendPage(200, callbackPage({ ok: true, title: 'Installed on GitHub',
|
|
126
|
+
body: '<p>GitHub has installed the app. Go back to your project’s page: it checks the install itself.</p><p><a class="btn" href="/projects?view=mine">Back to my projects →</a></p>' }));
|
|
127
|
+
}
|
|
128
|
+
const id = installationIdOf(req.query);
|
|
129
|
+
const action = String(req.query.setup_action || '') === 'update' ? 'update' : 'install';
|
|
130
|
+
await provisioning.recordEvent(pool, {
|
|
131
|
+
instanceId: row.instance_id, ownerBuilderId: row.requested_by, event: githubSetup.SETUP_EVENTS.installReported,
|
|
132
|
+
detail: `GitHub returned from the app ${action}${id ? ` (installation ${id}, unverified)` : ''}; awaiting the runner's check`, actor: 'github',
|
|
133
|
+
}).catch(() => {});
|
|
134
|
+
const inst = await provisioning.getInstanceById(pool, row.instance_id).catch(() => null);
|
|
135
|
+
const domain = inst && inst.domain ? inst.domain : null;
|
|
136
|
+
return sendPage(200, domain
|
|
137
|
+
? callbackPage({ ok: true, title: 'Installed on GitHub', state, domain })
|
|
138
|
+
: callbackPage({ ok: true, title: 'Installed on GitHub',
|
|
139
|
+
body: '<p>GitHub has installed the app. We check it on your project’s page.</p><p><a class="btn" href="/projects?view=mine">Back to my projects →</a></p>' }));
|
|
140
|
+
} catch (err) {
|
|
141
|
+
log.error('[provisioning] GET /provisioning/github-app/setup', err);
|
|
142
|
+
return sendPage(500, callbackPage({ ok: false, title: 'Something went wrong',
|
|
143
|
+
body: '<p>We couldn’t record GitHub’s response. Your project’s page checks the install itself.</p><p><a class="btn" href="/projects?view=mine">Back to my projects →</a></p>' }));
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// GET /provisioning/github-app/return?state=<> — where the callback and setup pages send
|
|
148
|
+
// the founder back (task 1004650). The SERVER resolves the one-time state to the project
|
|
149
|
+
// it was minted for and 302s that project's signed-in OWNER to its part 03
|
|
150
|
+
// (/projects?view=new&id=<id>), so the hand-back works from any tab, window or device —
|
|
151
|
+
// nothing in browser storage is read. Not signed in → through the web sign-in and back
|
|
152
|
+
// here. Signed in as anyone else, an unknown or malformed state, or a gone project → one
|
|
153
|
+
// refusal page that names no project (no oracle about which). The state stays single-use
|
|
154
|
+
// where it matters: only the manifest callback's code exchange consumes it, and this route
|
|
155
|
+
// changes nothing — it reveals a project's page only to the person who already owns it.
|
|
156
|
+
// rank: public — as a GATE: it is a browser redirect, so no middleware 401s a signed-out
|
|
157
|
+
// visitor (they are sent through sign-in instead). WHO lands is decided in-handler from
|
|
158
|
+
// the live session: the project's own owner, no Archon override, and nobody else ever
|
|
159
|
+
// learns which project the state names. It reveals no secret and writes nothing.
|
|
160
|
+
router.get('/provisioning/github-app/return', returnRateLimit, async (req, res) => {
|
|
161
|
+
res.set('Cache-Control', 'no-store');
|
|
162
|
+
res.set('Referrer-Policy', 'no-referrer');
|
|
163
|
+
const refuse = () => res.status(404).type('html').send(callbackPage({ ok: false, title: 'This isn’t your project',
|
|
164
|
+
body: '<p>This link is for a project on another account, or it has expired — sign in with the account that started the project, or open your own projects.</p><p><a class="btn" href="/projects?view=mine">My projects →</a></p>' }));
|
|
165
|
+
try {
|
|
166
|
+
const state = String(req.query.state || '');
|
|
167
|
+
if (!STATE_RE.test(state)) return refuse();
|
|
168
|
+
const session = await api.resolveSession(req);
|
|
169
|
+
if (!session) {
|
|
170
|
+
// the relative return passes the web flow's open-redirect guard (safeReturnTo);
|
|
171
|
+
// the repo scope rides every hub sign-in door (ADR 0364)
|
|
172
|
+
return res.redirect(302, `/api/bongos/auth/web/start?return=${encodeURIComponent(returnHref(state))}&repos=1`);
|
|
173
|
+
}
|
|
174
|
+
if (session.status === 'inactive') return refuse();
|
|
175
|
+
const row = await provisioning.getOAuthManifestByState(pool, state);
|
|
176
|
+
const inst = row ? await provisioning.getInstanceById(pool, row.instance_id).catch(() => null) : null;
|
|
177
|
+
if (!ownsLanding(inst, session.builder_id)) return refuse();
|
|
178
|
+
return res.redirect(302, landingHref(inst.id, state));
|
|
179
|
+
} catch (err) {
|
|
180
|
+
log.error('[provisioning] GET /provisioning/github-app/return', err);
|
|
181
|
+
return res.status(500).type('html').send(callbackPage({ ok: false, title: 'Something went wrong',
|
|
182
|
+
body: '<p>We couldn’t find your project just now — it is on your projects page.</p><p><a class="btn" href="/projects?view=mine">My projects →</a></p>' }));
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// GET /provisioning/github-app/resume[?id=<n>] — the project page's "which project am I
|
|
187
|
+
// connecting?" read when its tab remembers nothing (task 1004650): a hand-back on another
|
|
188
|
+
// device, a fresh tab, a cleared browser. With an id: that project, if the caller owns it.
|
|
189
|
+
// Without: the caller's newest project whose GitHub checklist is not connected yet, made or
|
|
190
|
+
// last tried within a week — so opening the create page later resumes part 03 from the
|
|
191
|
+
// server's view. Either way { project: null } when there is none; never the state token.
|
|
192
|
+
// rank: any authenticated builder (requireBuilder), own projects only — no Archon override.
|
|
193
|
+
router.get('/provisioning/github-app/resume', api.requireBuilder, async (req, res) => {
|
|
194
|
+
try {
|
|
195
|
+
const me = req.builder && req.builder.id;
|
|
196
|
+
const asked = String(req.query.id || '');
|
|
197
|
+
if (asked) {
|
|
198
|
+
if (!/^\d{1,12}$/.test(asked)) return res.json({ ok: true, project: null });
|
|
199
|
+
const inst = await provisioning.getInstanceById(pool, Number(asked)).catch(() => null);
|
|
200
|
+
return res.json({ ok: true, project: ownsLanding(inst, me) ? resumeRecord(inst) : null });
|
|
201
|
+
}
|
|
202
|
+
const now = Date.now();
|
|
203
|
+
const owned = (await provisioning.listInstancesForOwner(pool, me)).filter((i) => ownsLanding(i, me)).slice(0, 5);
|
|
204
|
+
// the (at most five) candidates are read together; the newest unfinished one wins
|
|
205
|
+
const open = await Promise.all(owned.map(async (inst) => {
|
|
206
|
+
const row = await provisioning.getLatestOAuthManifestForInstance(pool, inst.id);
|
|
207
|
+
const touched = Math.max(Date.parse(inst.created_at) || 0, row ? Date.parse(row.created_at) || 0 : 0);
|
|
208
|
+
if (now - touched > RESUME_WINDOW_MS) return false;
|
|
209
|
+
const setup = await githubSetup.githubSetupRead(pool, inst, row, row ? provisioning.publicGithubApp(row).status : null, { withState: false });
|
|
210
|
+
return !setup.checklist.connected;
|
|
211
|
+
}));
|
|
212
|
+
const found = owned.find((_inst, i) => open[i]);
|
|
213
|
+
return res.json({ ok: true, project: found ? resumeRecord(found) : null });
|
|
214
|
+
} catch (err) {
|
|
215
|
+
log.error('[provisioning] GET /provisioning/github-app/resume', err);
|
|
216
|
+
return res.status(500).json({ ok: false, error: 'resume_read_failed' });
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
return router;
|
|
221
|
+
};
|
|
@@ -62,7 +62,7 @@ const { requireRoomOnBox } = require('../capacity-gate'); // a full box refuse
|
|
|
62
62
|
const { adoptCredentialFault } = require('../credential-preflight'); const { termsAcknowledgement } = require('../terms-ack');
|
|
63
63
|
const catalogBridge = require('../catalog-bridge');
|
|
64
64
|
const capacity = require('../capacity'); // the box's slot ceiling (ADR 0285, task 1003927)
|
|
65
|
-
const { callbackPage } = require('./callback-page');
|
|
65
|
+
const { callbackPage } = require('./callback-page'); const githubSetup = require('../github-setup');
|
|
66
66
|
// the LOUD body validators (task 1003504) — lifted out when this file hit the size ratchet
|
|
67
67
|
const { badProjectDetail, badModuleSelection, badProjectType, screenedOut } = require('./body-validators'); const demo = require('../demo'); const birth = require('../birth');
|
|
68
68
|
const { failFrom } = require('../public-refusal'); // every catch: a declared refusal reaches the owner, anything else stays opaque (task 1004126)
|
|
@@ -171,11 +171,7 @@ module.exports = function provisioningRoutes() {
|
|
|
171
171
|
const router = express.Router();
|
|
172
172
|
// Per-IP limiter for the public tls-check `ask` endpoint (SEC review, task 1945).
|
|
173
173
|
const askRateLimit = makeAskRateLimiter();
|
|
174
|
-
//
|
|
175
|
-
// 2096): the onboarding page polls it every ~3s while it waits, so legitimate volume is
|
|
176
|
-
// higher than tls-check's one-shot ask. 120/min/IP gives comfortable headroom while
|
|
177
|
-
// still shedding a flood; in-memory, own bucket (does not share tls-check's budget).
|
|
178
|
-
const statusRateLimit = makeAskRateLimiter({ max: 120 });
|
|
174
|
+
// (The public status poll and its own limiter moved to ./github-setup.js, task 1004649.)
|
|
179
175
|
// Limiter for the slug-availability check (task 1002898 N10). Signed-in-only, but a
|
|
180
176
|
// session is cheap and the route answers a yes/no about EVERY project slug on the
|
|
181
177
|
// instance — an enumeration oracle if left unthrottled. A wizard debouncing one handle
|
|
@@ -675,6 +671,9 @@ module.exports = function provisioningRoutes() {
|
|
|
675
671
|
instance: ownerInstance(inst),
|
|
676
672
|
open_intent: open ? { action: open.action, state: open.state } : null,
|
|
677
673
|
github_app: provisioning.publicGithubApp(manifest),
|
|
674
|
+
// the four-step checklist from what the runner verified (task 1004649) — the
|
|
675
|
+
// install link rides WITHOUT the state token this read never carries
|
|
676
|
+
github_setup: await githubSetup.githubSetupRead(pool, inst, manifest, manifest ? provisioning.publicGithubApp(manifest).status : null, { withState: false }),
|
|
678
677
|
});
|
|
679
678
|
} catch (err) {
|
|
680
679
|
log.error('[provisioning] GET /provisioning/instances/:id', err);
|
|
@@ -1392,41 +1391,8 @@ module.exports = function provisioningRoutes() {
|
|
|
1392
1391
|
}
|
|
1393
1392
|
});
|
|
1394
1393
|
|
|
1395
|
-
// GET /provisioning/github-app/status
|
|
1396
|
-
//
|
|
1397
|
-
// Same public-by-state model as the callback: the unguessable, single-use `state` token
|
|
1398
|
-
// is the only key, and the response reveals only the manifest lifecycle status + the
|
|
1399
|
-
// instance's own (already-public) domain — never a secret. Per-IP rate-limited because
|
|
1400
|
-
// the page polls it every ~3s. `ready` is true iff the manifest reached 'done' (creds
|
|
1401
|
-
// placed + /healthz verified) — the setup checklist then opens https://<domain>.
|
|
1402
|
-
// rank: public — no session, no mutation, no secret; an unknown state is a 404 no-op.
|
|
1403
|
-
router.get('/provisioning/github-app/status', statusRateLimit, async (req, res) => {
|
|
1404
|
-
try {
|
|
1405
|
-
const state = String(req.query.state || '');
|
|
1406
|
-
if (!state) return res.status(400).json({ ok: false, error: 'missing_state' });
|
|
1407
|
-
const row = await provisioning.getOAuthManifestByState(pool, state);
|
|
1408
|
-
if (!row) return res.status(404).json({ ok: false, error: 'unknown_state' });
|
|
1409
|
-
const inst = await provisioning.getInstanceById(pool, row.instance_id).catch(() => null);
|
|
1410
|
-
// EFFECTIVE status + how far a failure got (task 1004572, manifest-truth.js)
|
|
1411
|
-
const { status, failure } = provisioning.publicGithubApp(row);
|
|
1412
|
-
return res.json({
|
|
1413
|
-
ok: true,
|
|
1414
|
-
status,
|
|
1415
|
-
failure,
|
|
1416
|
-
ready: status === 'done',
|
|
1417
|
-
terminal: status === 'done' || status === 'error' || status === 'expired',
|
|
1418
|
-
domain: inst && inst.domain ? inst.domain : null,
|
|
1419
|
-
instance_status: inst ? inst.status : null,
|
|
1420
|
-
// The App's public slug (task 1003049): lets the wizard render the one-click
|
|
1421
|
-
// install link (github.com/apps/<slug>/installations/new) for the app-install
|
|
1422
|
-
// step. Public info — the App's page is public by design (task 1003048).
|
|
1423
|
-
app_slug: row.app_slug || null,
|
|
1424
|
-
});
|
|
1425
|
-
} catch (err) {
|
|
1426
|
-
log.error('[provisioning] GET /provisioning/github-app/status', err);
|
|
1427
|
-
return res.status(500).json({ ok: false, error: 'status_check_failed' });
|
|
1428
|
-
}
|
|
1429
|
-
});
|
|
1394
|
+
// GET /provisioning/github-app/status + /setup — the state-keyed poll and the App's
|
|
1395
|
+
// setup_url — live in ./github-setup.js (task 1004649).
|
|
1430
1396
|
|
|
1431
1397
|
// GET /provisioning/github-app/callback?code=&state= — GitHub redirects the owner's
|
|
1432
1398
|
// BROWSER here after they approve the App Manifest (task 2080, ADR 0133). It is a
|
|
@@ -1465,11 +1431,11 @@ module.exports = function provisioningRoutes() {
|
|
|
1465
1431
|
}).catch(() => {});
|
|
1466
1432
|
const inst = await provisioning.getInstanceById(pool, row.instance_id).catch(() => null);
|
|
1467
1433
|
const domain = inst && inst.domain ? inst.domain : null;
|
|
1468
|
-
//
|
|
1469
|
-
//
|
|
1470
|
-
//
|
|
1434
|
+
// One trip (task 1004649): the page waits for the runner to name the app, then sends
|
|
1435
|
+
// the founder on to installing it on their repo; GitHub's setup_url brings them back
|
|
1436
|
+
// to the checklist. Falls back to a static success if we have no domain to point at.
|
|
1471
1437
|
return sendPage(200, domain
|
|
1472
|
-
? callbackPage({ ok: true, title: 'GitHub app created', state, domain })
|
|
1438
|
+
? callbackPage({ ok: true, title: 'GitHub app created', state, domain, next: 'install' })
|
|
1473
1439
|
: callbackPage({ ok: true, title: 'GitHub app created',
|
|
1474
1440
|
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>' }));
|
|
1475
1441
|
} catch (err) {
|