@bongos/core 1.21.94 → 1.21.96
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 +45 -35
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +13 -3
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/api/openapi.json +181 -7
- package/docs/api-reference.md +9 -5
- package/docs/copy-inventory.md +367 -358
- package/docs/copy-registry.json +536 -425
- package/docs/design/project-startup-direction.md +17 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +351 -348
- package/modules/provisioning/github-setup.js +27 -5
- package/modules/provisioning/routes/callback-page.js +20 -10
- package/modules/provisioning/routes/github-setup.js +111 -2
- package/modules/provisioning/routes/provisioning.js +29 -4
- package/modules/public-landing/public/projects.html +367 -24
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/bongos/github-repos.js +129 -0
- package/src/bongos/routes/github.js +71 -5
- package/src/module-api.js +1 -1
- package/tests/github_handback_any_device.mjs +340 -0
- package/tests/github_org_account.mjs +378 -0
- package/tests/projects_hub.mjs +6 -2
- package/tests/provisioning_callback_page.mjs +3 -2
- package/tests/provisioning_github_app_routes.mjs +15 -10
- package/tests/wizard_draft_resume.mjs +4 -0
- package/tests/wizard_github_feedback.mjs +2 -1
|
@@ -37,8 +37,17 @@ const SETUP_EVENTS = Object.freeze({
|
|
|
37
37
|
installReported: 'github-app-install-reported',
|
|
38
38
|
installed: 'github-app-installed',
|
|
39
39
|
deviceFlow: 'github-app-device-flow',
|
|
40
|
+
// the organisation the founder chose to make the app on (task 1004651); its detail is
|
|
41
|
+
// the org's login. Written by the mint route; absent = the founder's personal account.
|
|
42
|
+
appOrg: 'github-app-org',
|
|
40
43
|
});
|
|
41
44
|
|
|
45
|
+
// A GitHub login (1-39 chars, letters/digits and single inner hyphens). The org name the
|
|
46
|
+
// mint route takes from the page passes this before it reaches a GitHub URL, and the
|
|
47
|
+
// recorded one passes it again before it reaches a link. PURE.
|
|
48
|
+
const GITHUB_LOGIN_RE = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/;
|
|
49
|
+
function validGithubLogin(s) { return typeof s === 'string' && GITHUB_LOGIN_RE.test(s); }
|
|
50
|
+
|
|
42
51
|
// How long after an attempt settles the runner keeps checking for the install and the
|
|
43
52
|
// device-flow tick. Past it the checklist stops promising a self-tick for device flow
|
|
44
53
|
// (the step says "turn it on later"); a fresh install report re-opens the window.
|
|
@@ -65,16 +74,23 @@ function targetRepo(inst) {
|
|
|
65
74
|
// The verified facts for one instance since `since` (the attempt's created_at): when each
|
|
66
75
|
// setup event was first seen, or null. One indexed read (idx_provisioning_events_instance).
|
|
67
76
|
async function githubSetupFacts(db, instanceId, since) {
|
|
77
|
+
// `detail` is the newest row's, for the one event that carries a value (appOrg)
|
|
68
78
|
const { rows } = await db.query(
|
|
69
|
-
`SELECT event, min(created_at) AS at
|
|
79
|
+
`SELECT event, min(created_at) AS at, (array_agg(detail ORDER BY created_at DESC))[1] AS detail
|
|
80
|
+
FROM provisioning_events
|
|
70
81
|
WHERE instance_id = $1 AND event = ANY($2) AND created_at >= $3
|
|
71
82
|
GROUP BY event`,
|
|
72
83
|
[instanceId, Object.values(SETUP_EVENTS), since || new Date(0).toISOString()]);
|
|
84
|
+
const find = (name) => rows.find((x) => x.event === name);
|
|
73
85
|
const at = (name) => {
|
|
74
|
-
const r =
|
|
86
|
+
const r = find(name);
|
|
75
87
|
return r && r.at ? new Date(r.at).toISOString() : null;
|
|
76
88
|
};
|
|
77
|
-
|
|
89
|
+
const org = find(SETUP_EVENTS.appOrg);
|
|
90
|
+
return {
|
|
91
|
+
installReportedAt: at(SETUP_EVENTS.installReported), installedAt: at(SETUP_EVENTS.installed), deviceFlowAt: at(SETUP_EVENTS.deviceFlow),
|
|
92
|
+
appOrg: org && validGithubLogin(org.detail) ? org.detail : null,
|
|
93
|
+
};
|
|
78
94
|
}
|
|
79
95
|
|
|
80
96
|
// Each checklist step's state, from the latest attempt's EFFECTIVE status (manifest-truth's
|
|
@@ -113,7 +129,7 @@ function stillVerifying(row, facts = {}, now = Date.now()) {
|
|
|
113
129
|
// lands on the setup page, which sends the founder back to their projects.
|
|
114
130
|
// A failed facts read is "nothing verified yet" — the honest 'now' states, never a 500.
|
|
115
131
|
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 };
|
|
132
|
+
if (!row) return { checklist: githubChecklist(null, null), install_url: null, repo: targetRepo(inst), verifying: false, app_org: null };
|
|
117
133
|
let facts = {};
|
|
118
134
|
// through module.exports so a route test can stub the one DB read on the cached module
|
|
119
135
|
try { facts = await module.exports.githubSetupFacts(db, row.instance_id, row.created_at); } catch (_) { facts = {}; }
|
|
@@ -123,7 +139,13 @@ async function githubSetupRead(db, inst, row, status, { now = Date.now(), withSt
|
|
|
123
139
|
install_url: checklist.install === 'done' ? null : installUrlFor({ appSlug: row.app_slug, state: withState ? row.state : null }),
|
|
124
140
|
repo: targetRepo(inst),
|
|
125
141
|
verifying: stillVerifying(row, facts, now),
|
|
142
|
+
// the organisation this attempt makes the app on, or null for the founder's account
|
|
143
|
+
// (task 1004651): the page links the app's settings under the right owner
|
|
144
|
+
app_org: validGithubLogin(facts.appOrg) ? facts.appOrg : null,
|
|
126
145
|
};
|
|
127
146
|
}
|
|
128
147
|
|
|
129
|
-
module.exports = {
|
|
148
|
+
module.exports = {
|
|
149
|
+
SETUP_EVENTS, VERIFY_WINDOW_MS, installUrlFor, targetRepo, githubSetupFacts, githubChecklist, stillVerifying, githubSetupRead,
|
|
150
|
+
validGithubLogin,
|
|
151
|
+
};
|
|
@@ -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){}
|
|
@@ -94,8 +104,7 @@ const HANDBACK_SCRIPT = `
|
|
|
94
104
|
const INSTALL_SCRIPT = `
|
|
95
105
|
(function(){
|
|
96
106
|
var cfg=window.__CB_SETUP__||{};
|
|
97
|
-
var back=cfg.back||'/projects?view=
|
|
98
|
-
if(cfg.state)back+='#ghstate='+encodeURIComponent(cfg.state);
|
|
107
|
+
var back=cfg.back||'/projects?view=mine';
|
|
99
108
|
var link=document.getElementById('back');if(link)link.href=back;
|
|
100
109
|
var note=document.getElementById('note');
|
|
101
110
|
var say=function(t){if(note)note.textContent=t;};
|
|
@@ -142,7 +151,8 @@ const INSTALL_SCRIPT = `
|
|
|
142
151
|
// + `body` (server-authored, trusted HTML).
|
|
143
152
|
function callbackPage({ ok, title, body, state, domain, next }) {
|
|
144
153
|
const handback = !!(ok && state && domain);
|
|
145
|
-
const
|
|
154
|
+
const back = returnHref(state);
|
|
155
|
+
const cfgJson = JSON.stringify({ state: state || '', domain: domain || '', back }).replace(/</g, '\\u003c');
|
|
146
156
|
const head = `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
147
157
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
148
158
|
<meta name="color-scheme" content="light dark">
|
|
@@ -157,7 +167,7 @@ function callbackPage({ ok, title, body, state, domain, next }) {
|
|
|
157
167
|
<h1>${esc(title)}</h1>
|
|
158
168
|
<p class="sub">Next, install it on your repo. Taking you there now.</p>
|
|
159
169
|
<p class="muted" id="note" role="status" aria-live="polite">Waiting for GitHub to hand over the app…</p>
|
|
160
|
-
<div class="cta"><a class="btn" id="back" href="
|
|
170
|
+
<div class="cta"><a class="btn" id="back" href="${esc(back)}">Back to my project</a></div>
|
|
161
171
|
</main>
|
|
162
172
|
<script>window.__CB_SETUP__=${cfgJson};</script>
|
|
163
173
|
<script>${INSTALL_SCRIPT}</script>
|
|
@@ -169,7 +179,7 @@ function callbackPage({ ok, title, body, state, domain, next }) {
|
|
|
169
179
|
<div class="badge-warn">✓</div>
|
|
170
180
|
<h1>${esc(title)}</h1>
|
|
171
181
|
<p class="sub">Taking you back to your setup checklist — it finishes setting up <code>${esc(domain)}</code> there.</p>
|
|
172
|
-
<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>
|
|
173
183
|
</main>
|
|
174
184
|
<script>window.__CB_SETUP__=${cfgJson};</script>
|
|
175
185
|
<script>${HANDBACK_SCRIPT}</script>
|
|
@@ -184,4 +194,4 @@ function callbackPage({ ok, title, body, state, domain, next }) {
|
|
|
184
194
|
</body></html>`;
|
|
185
195
|
}
|
|
186
196
|
|
|
187
|
-
module.exports = { esc, callbackPage };
|
|
197
|
+
module.exports = { esc, callbackPage, returnHref, RETURN_PATH };
|
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
// single-use `state` minted for the signed-in owner is the only key, and neither route
|
|
10
10
|
// reveals a secret or changes authority. The checklist they report is derived in
|
|
11
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.
|
|
12
18
|
'use strict';
|
|
13
19
|
|
|
14
20
|
const express = require('express');
|
|
@@ -17,10 +23,38 @@ const { pool } = api;
|
|
|
17
23
|
const provisioning = require('../provisioning');
|
|
18
24
|
const githubSetup = require('../github-setup');
|
|
19
25
|
const { makeAskRateLimiter } = require('../rate-limit');
|
|
20
|
-
const { callbackPage } = require('./callback-page');
|
|
26
|
+
const { callbackPage, returnHref } = require('./callback-page');
|
|
21
27
|
|
|
22
28
|
const log = api.logger('provisioning');
|
|
23
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
|
+
|
|
24
58
|
// GitHub's installation id is digits; anything else is dropped before it reaches a log line.
|
|
25
59
|
function installationIdOf(q) {
|
|
26
60
|
const s = String((q && q.installation_id) || '');
|
|
@@ -32,6 +66,7 @@ module.exports = function buildGithubSetupRouter() {
|
|
|
32
66
|
// the page polls every few seconds, the callback page every two — per-IP, in-module
|
|
33
67
|
const statusRateLimit = makeAskRateLimiter({ max: 120 });
|
|
34
68
|
const setupRateLimit = makeAskRateLimiter({ max: 30 });
|
|
69
|
+
const returnRateLimit = makeAskRateLimiter({ max: 30 });
|
|
35
70
|
|
|
36
71
|
// GET /provisioning/github-app/status?state=<> — the PUBLIC progress read (task 1004548;
|
|
37
72
|
// the checklist since task 1004649). Reveals the manifest lifecycle status, the
|
|
@@ -65,6 +100,7 @@ module.exports = function buildGithubSetupRouter() {
|
|
|
65
100
|
install_url: setup.install_url,
|
|
66
101
|
repo: setup.repo,
|
|
67
102
|
verifying: setup.verifying,
|
|
103
|
+
app_org: setup.app_org,
|
|
68
104
|
});
|
|
69
105
|
} catch (err) {
|
|
70
106
|
log.error('[provisioning] GET /provisioning/github-app/status', err);
|
|
@@ -85,7 +121,7 @@ module.exports = function buildGithubSetupRouter() {
|
|
|
85
121
|
const sendPage = (status, page) => res.status(status).type('html').send(page);
|
|
86
122
|
try {
|
|
87
123
|
const state = String(req.query.state || '');
|
|
88
|
-
const row =
|
|
124
|
+
const row = STATE_RE.test(state) ? await provisioning.getOAuthManifestByState(pool, state) : null;
|
|
89
125
|
if (!row) {
|
|
90
126
|
return sendPage(200, callbackPage({ ok: true, title: 'Installed on GitHub',
|
|
91
127
|
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>' }));
|
|
@@ -109,5 +145,78 @@ module.exports = function buildGithubSetupRouter() {
|
|
|
109
145
|
}
|
|
110
146
|
});
|
|
111
147
|
|
|
148
|
+
// GET /provisioning/github-app/return?state=<> — where the callback and setup pages send
|
|
149
|
+
// the founder back (task 1004650). The SERVER resolves the one-time state to the project
|
|
150
|
+
// it was minted for and 302s that project's signed-in OWNER to its part 03
|
|
151
|
+
// (/projects?view=new&id=<id>), so the hand-back works from any tab, window or device —
|
|
152
|
+
// nothing in browser storage is read. Not signed in → through the web sign-in and back
|
|
153
|
+
// here. Signed in as anyone else, an unknown or malformed state, or a gone project → one
|
|
154
|
+
// refusal page that names no project (no oracle about which). The state stays single-use
|
|
155
|
+
// where it matters: only the manifest callback's code exchange consumes it, and this route
|
|
156
|
+
// changes nothing — it reveals a project's page only to the person who already owns it.
|
|
157
|
+
// rank: public — as a GATE: it is a browser redirect, so no middleware 401s a signed-out
|
|
158
|
+
// visitor (they are sent through sign-in instead). WHO lands is decided in-handler from
|
|
159
|
+
// the live session: the project's own owner, no Archon override, and nobody else ever
|
|
160
|
+
// learns which project the state names. It reveals no secret and writes nothing.
|
|
161
|
+
router.get('/provisioning/github-app/return', returnRateLimit, async (req, res) => {
|
|
162
|
+
res.set('Cache-Control', 'no-store');
|
|
163
|
+
res.set('Referrer-Policy', 'no-referrer');
|
|
164
|
+
const refuse = () => res.status(404).type('html').send(callbackPage({ ok: false, title: 'This isn’t your project',
|
|
165
|
+
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>' }));
|
|
166
|
+
try {
|
|
167
|
+
const state = String(req.query.state || '');
|
|
168
|
+
if (!STATE_RE.test(state)) return refuse();
|
|
169
|
+
const session = await api.resolveSession(req);
|
|
170
|
+
if (!session) {
|
|
171
|
+
// the relative return passes the web flow's open-redirect guard (safeReturnTo);
|
|
172
|
+
// the repo scope rides every hub sign-in door (ADR 0364)
|
|
173
|
+
return res.redirect(302, `/api/bongos/auth/web/start?return=${encodeURIComponent(returnHref(state))}&repos=1`);
|
|
174
|
+
}
|
|
175
|
+
if (session.status === 'inactive') return refuse();
|
|
176
|
+
const row = await provisioning.getOAuthManifestByState(pool, state);
|
|
177
|
+
const inst = row ? await provisioning.getInstanceById(pool, row.instance_id).catch(() => null) : null;
|
|
178
|
+
if (!ownsLanding(inst, session.builder_id)) return refuse();
|
|
179
|
+
return res.redirect(302, landingHref(inst.id, state));
|
|
180
|
+
} catch (err) {
|
|
181
|
+
log.error('[provisioning] GET /provisioning/github-app/return', err);
|
|
182
|
+
return res.status(500).type('html').send(callbackPage({ ok: false, title: 'Something went wrong',
|
|
183
|
+
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>' }));
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// GET /provisioning/github-app/resume[?id=<n>] — the project page's "which project am I
|
|
188
|
+
// connecting?" read when its tab remembers nothing (task 1004650): a hand-back on another
|
|
189
|
+
// device, a fresh tab, a cleared browser. With an id: that project, if the caller owns it.
|
|
190
|
+
// Without: the caller's newest project whose GitHub checklist is not connected yet, made or
|
|
191
|
+
// last tried within a week — so opening the create page later resumes part 03 from the
|
|
192
|
+
// server's view. Either way { project: null } when there is none; never the state token.
|
|
193
|
+
// rank: any authenticated builder (requireBuilder), own projects only — no Archon override.
|
|
194
|
+
router.get('/provisioning/github-app/resume', api.requireBuilder, async (req, res) => {
|
|
195
|
+
try {
|
|
196
|
+
const me = req.builder && req.builder.id;
|
|
197
|
+
const asked = String(req.query.id || '');
|
|
198
|
+
if (asked) {
|
|
199
|
+
if (!/^\d{1,12}$/.test(asked)) return res.json({ ok: true, project: null });
|
|
200
|
+
const inst = await provisioning.getInstanceById(pool, Number(asked)).catch(() => null);
|
|
201
|
+
return res.json({ ok: true, project: ownsLanding(inst, me) ? resumeRecord(inst) : null });
|
|
202
|
+
}
|
|
203
|
+
const now = Date.now();
|
|
204
|
+
const owned = (await provisioning.listInstancesForOwner(pool, me)).filter((i) => ownsLanding(i, me)).slice(0, 5);
|
|
205
|
+
// the (at most five) candidates are read together; the newest unfinished one wins
|
|
206
|
+
const open = await Promise.all(owned.map(async (inst) => {
|
|
207
|
+
const row = await provisioning.getLatestOAuthManifestForInstance(pool, inst.id);
|
|
208
|
+
const touched = Math.max(Date.parse(inst.created_at) || 0, row ? Date.parse(row.created_at) || 0 : 0);
|
|
209
|
+
if (now - touched > RESUME_WINDOW_MS) return false;
|
|
210
|
+
const setup = await githubSetup.githubSetupRead(pool, inst, row, row ? provisioning.publicGithubApp(row).status : null, { withState: false });
|
|
211
|
+
return !setup.checklist.connected;
|
|
212
|
+
}));
|
|
213
|
+
const found = owned.find((_inst, i) => open[i]);
|
|
214
|
+
return res.json({ ok: true, project: found ? resumeRecord(found) : null });
|
|
215
|
+
} catch (err) {
|
|
216
|
+
log.error('[provisioning] GET /provisioning/github-app/resume', err);
|
|
217
|
+
return res.status(500).json({ ok: false, error: 'resume_read_failed' });
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
112
221
|
return router;
|
|
113
222
|
};
|
|
@@ -1230,11 +1230,22 @@ module.exports = function provisioningRoutes() {
|
|
|
1230
1230
|
// GitHub, the owner approves, and GitHub redirects back to the public callback below.
|
|
1231
1231
|
// The web tier only RECORDS the attempt (state token) — the control-plane runner does
|
|
1232
1232
|
// the token exchange + cred placement (trust boundary intact, ADR 0111 §2).
|
|
1233
|
-
// Body: { account? ('personal'|'org') }.
|
|
1233
|
+
// Body: { account? ('personal'|'org'), org? (a GitHub organisation login) }.
|
|
1234
|
+
// A personal or an organisation account (task 1004651): account 'org' makes the app on
|
|
1235
|
+
// the organisation the founder chose on the page (`org`), else on the repo's owner. The
|
|
1236
|
+
// org name is checked against GitHub's login rules here, never trusted from the page;
|
|
1237
|
+
// whether the founder may make an app there is GitHub's to enforce on its own page
|
|
1238
|
+
// (this module holds no GitHub token to ask with, ADR 0111 §2). Anything other than
|
|
1239
|
+
// account 'org' is the founder's personal account, exactly as before.
|
|
1234
1240
|
router.post('/provisioning/instances/:id/github-app', auth.requireBuilder, async (req, res) => {
|
|
1235
1241
|
const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
|
|
1236
1242
|
if (!id) return;
|
|
1237
|
-
if (validateOrRespond(req, res, { account: { type: 'string', maxLength: 12 } })) return;
|
|
1243
|
+
if (validateOrRespond(req, res, { account: { type: 'string', maxLength: 12 }, org: { type: 'string', maxLength: 39 } })) return;
|
|
1244
|
+
const wantsOrg = !!(req.body && req.body.account === 'org');
|
|
1245
|
+
const typedOrg = wantsOrg && typeof req.body.org === 'string' ? req.body.org.trim() : '';
|
|
1246
|
+
if (typedOrg && !githubSetup.validGithubLogin(typedOrg)) {
|
|
1247
|
+
return res.fail('bad_org', { status: 400, message: 'That isn’t a GitHub organisation name: letters, numbers and single dashes, up to 39.' });
|
|
1248
|
+
}
|
|
1238
1249
|
try {
|
|
1239
1250
|
const inst = await provisioning.getInstanceById(pool, id);
|
|
1240
1251
|
if (!inst || String(inst.owner_builder_id) !== String(req.builder.id)) {
|
|
@@ -1249,6 +1260,14 @@ module.exports = function provisioningRoutes() {
|
|
|
1249
1260
|
if (!origin) {
|
|
1250
1261
|
return res.fail('mothership_origin_unset', { status: 500, message: 'This control plane has no publicOrigin configured — cannot build the GitHub redirect URL.' });
|
|
1251
1262
|
}
|
|
1263
|
+
// the org candidate: the one the founder chose, else the repo's owner (a login only)
|
|
1264
|
+
const repoOwner = ownerFromTargetRef(inst.target_ref);
|
|
1265
|
+
const orgLogin = typedOrg || (githubSetup.validGithubLogin(repoOwner) ? repoOwner : null);
|
|
1266
|
+
// an explicit organisation choice never falls back to the personal account unseen
|
|
1267
|
+
if (wantsOrg && !orgLogin) {
|
|
1268
|
+
return res.fail('org_required', { status: 400, message: 'Name the GitHub organisation to make the app on.' });
|
|
1269
|
+
}
|
|
1270
|
+
const account = wantsOrg ? 'org' : 'personal';
|
|
1252
1271
|
const state = crypto.randomBytes(32).toString('hex');
|
|
1253
1272
|
const expiresAt = new Date(Date.now() + MANIFEST_STATE_TTL_MS).toISOString();
|
|
1254
1273
|
await provisioning.createOAuthManifest(pool, { instanceId: id, state, requestedBy: req.builder.id, expiresAt });
|
|
@@ -1256,13 +1275,19 @@ module.exports = function provisioningRoutes() {
|
|
|
1256
1275
|
instanceId: id, ownerBuilderId: req.builder.id, event: 'oauth-manifest-start',
|
|
1257
1276
|
detail: 'GitHub App Manifest link minted', actor: 'api:self',
|
|
1258
1277
|
}).catch(() => {});
|
|
1278
|
+
// which account owns this attempt's app, so the page can link its settings there
|
|
1279
|
+
// from any tab or device (github-setup.js reads it back as app_org)
|
|
1280
|
+
if (account === 'org') {
|
|
1281
|
+
await provisioning.recordEvent(pool, {
|
|
1282
|
+
instanceId: id, ownerBuilderId: req.builder.id, event: githubSetup.SETUP_EVENTS.appOrg,
|
|
1283
|
+
detail: orgLogin, actor: 'api:self',
|
|
1284
|
+
}).catch(() => {});
|
|
1285
|
+
}
|
|
1259
1286
|
|
|
1260
1287
|
const manifest = provisioning.buildGithubAppManifest({
|
|
1261
1288
|
instanceName: inst.slug, slug: inst.slug, instanceDomain: inst.domain,
|
|
1262
1289
|
mothershipOrigin: origin, brand: mothershipBrand(),
|
|
1263
1290
|
});
|
|
1264
|
-
const orgLogin = ownerFromTargetRef(inst.target_ref);
|
|
1265
|
-
const account = req.body && req.body.account === 'org' && orgLogin ? 'org' : 'personal';
|
|
1266
1291
|
res.json({
|
|
1267
1292
|
state,
|
|
1268
1293
|
account,
|