@bongos/core 1.21.95 → 1.21.97

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.
@@ -37,8 +37,27 @@ 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',
43
+ // the founder pressed "Pick up where it stopped" or "Check again" (task 1004652). Like an
44
+ // install report it re-opens the runner's checking window; it claims nothing itself.
45
+ checkRequested: 'github-app-check-requested',
40
46
  });
41
47
 
48
+ const { effectiveManifestStatus } = require('./manifest-truth');
49
+
50
+ // GitHub's manifest conversion code lasts an hour from GitHub's redirect, and the redirect
51
+ // comes after the link was minted, so an attempt younger than this can still be converted
52
+ // with the code it already holds — no new approval, so no second app (task 1004652).
53
+ const CODE_LIFE_MS = 60 * 60 * 1000;
54
+
55
+ // A GitHub login (1-39 chars, letters/digits and single inner hyphens). The org name the
56
+ // mint route takes from the page passes this before it reaches a GitHub URL, and the
57
+ // recorded one passes it again before it reaches a link. PURE.
58
+ const GITHUB_LOGIN_RE = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/;
59
+ function validGithubLogin(s) { return typeof s === 'string' && GITHUB_LOGIN_RE.test(s); }
60
+
42
61
  // How long after an attempt settles the runner keeps checking for the install and the
43
62
  // device-flow tick. Past it the checklist stops promising a self-tick for device flow
44
63
  // (the step says "turn it on later"); a fresh install report re-opens the window.
@@ -65,16 +84,24 @@ function targetRepo(inst) {
65
84
  // The verified facts for one instance since `since` (the attempt's created_at): when each
66
85
  // setup event was first seen, or null. One indexed read (idx_provisioning_events_instance).
67
86
  async function githubSetupFacts(db, instanceId, since) {
87
+ // `detail` is the newest row's, for the one event that carries a value (appOrg)
68
88
  const { rows } = await db.query(
69
- `SELECT event, min(created_at) AS at FROM provisioning_events
89
+ `SELECT event, min(created_at) AS at, (array_agg(detail ORDER BY created_at DESC))[1] AS detail
90
+ FROM provisioning_events
70
91
  WHERE instance_id = $1 AND event = ANY($2) AND created_at >= $3
71
92
  GROUP BY event`,
72
93
  [instanceId, Object.values(SETUP_EVENTS), since || new Date(0).toISOString()]);
94
+ const find = (name) => rows.find((x) => x.event === name);
73
95
  const at = (name) => {
74
- const r = rows.find((x) => x.event === name);
96
+ const r = find(name);
75
97
  return r && r.at ? new Date(r.at).toISOString() : null;
76
98
  };
77
- return { installReportedAt: at(SETUP_EVENTS.installReported), installedAt: at(SETUP_EVENTS.installed), deviceFlowAt: at(SETUP_EVENTS.deviceFlow) };
99
+ const org = find(SETUP_EVENTS.appOrg);
100
+ return {
101
+ installReportedAt: at(SETUP_EVENTS.installReported), installedAt: at(SETUP_EVENTS.installed), deviceFlowAt: at(SETUP_EVENTS.deviceFlow),
102
+ checkRequestedAt: at(SETUP_EVENTS.checkRequested),
103
+ appOrg: org && validGithubLogin(org.detail) ? org.detail : null,
104
+ };
78
105
  }
79
106
 
80
107
  // Each checklist step's state, from the latest attempt's EFFECTIVE status (manifest-truth's
@@ -101,10 +128,65 @@ function githubChecklist(status, appSlug, facts = {}) {
101
128
  // device-flow step can still tick itself, so the page keeps polling. PURE.
102
129
  function stillVerifying(row, facts = {}, now = Date.now()) {
103
130
  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);
131
+ const t = (iso) => (iso ? new Date(iso).getTime() : 0);
132
+ const last = Math.max(t(settled), t(facts.installReportedAt), t(facts.checkRequestedAt));
105
133
  return last > 0 && now - last < VERIFY_WINDOW_MS;
106
134
  }
107
135
 
136
+ // What "Pick up where it stopped" does for this attempt (task 1004652) — a closed
137
+ // vocabulary, and never "make another app" once GitHub has made one:
138
+ // 'reopen' — the link is still waiting on GitHub: post the SAME attempt again.
139
+ // 'fresh' — nothing was made on GitHub (the link lapsed, or the runner never took
140
+ // it up): a new link is safe.
141
+ // 'convert' — GitHub approved and the code it gave is still young: convert it again.
142
+ // 'check' — the app exists with its keys placed, a step is unverified and the runner
143
+ // has stopped checking: ask it to look again (sign-in, install, device flow).
144
+ // 'operator' — GitHub approved but the app's keys never reached us and its code is
145
+ // spent or too old: only the platform team can finish it.
146
+ // null — nothing to pick up: in flight, still being checked, or all done.
147
+ // PURE.
148
+ function pickUpFor(row, status, checklist, verifying, now = Date.now()) {
149
+ if (!row) return null;
150
+ if (status === 'pending') return 'reopen';
151
+ if (status === 'expired') return 'fresh';
152
+ if (row.app_slug || row.app_id) {
153
+ if (checklist && checklist.connected && checklist.device_flow === 'done') return null;
154
+ return verifying ? null : 'check';
155
+ }
156
+ if (status !== 'error') return null;
157
+ if (!(Number(row.attempts) > 0)) return 'fresh';
158
+ const age = now - new Date(row.created_at || 0).getTime();
159
+ return row.github_code && age < CODE_LIFE_MS ? 'convert' : 'operator';
160
+ }
161
+
162
+ // May the mint route make a new link for this instance, given its latest attempt (task
163
+ // 1004652)? null = yes. { reuse } = the latest link is still waiting on GitHub, so hand
164
+ // back the SAME state (a second click, a reopened tab, another device) instead of a second
165
+ // attempt. { refuse, message } = GitHub has approved an app for this project already, so a
166
+ // new link would make a second one. PURE.
167
+ function mintGuard(row, now = Date.now()) {
168
+ if (!row) return null;
169
+ const status = effectiveManifestStatus(row, now);
170
+ if (status === 'pending') return { reuse: row.state, expiresAt: new Date(row.expires_at).toISOString() };
171
+ if (status === 'expired' || (status === 'error' && !(Number(row.attempts) > 0) && !row.app_id)) return null;
172
+ if (status === 'code_received' || status === 'converting') {
173
+ return { refuse: 'github_in_progress', message: 'GitHub already approved this project’s app, and the platform is finishing it. The checklist ticks itself.' };
174
+ }
175
+ return { refuse: 'app_exists', message: 'GitHub already made this project’s app, so we won’t make a second one. Pick up where it stopped instead.' };
176
+ }
177
+
178
+ // Put an approved attempt whose conversion stopped back in the runner's queue, with the
179
+ // code it still holds (pickUpFor 'convert'). The attempt count restarts so the runner
180
+ // tries it again. Only an attempt still in that shape moves; false otherwise.
181
+ async function requeueConversion(db, id) {
182
+ const { rows } = await db.query(
183
+ `UPDATE provisioning_oauth_manifests
184
+ SET status = 'code_received', attempts = 0, last_error = NULL, resolved_at = NULL, updated_at = now()
185
+ WHERE id = $1 AND status = 'error' AND app_id IS NULL AND github_code IS NOT NULL
186
+ RETURNING id`, [id]);
187
+ return rows.length > 0;
188
+ }
189
+
108
190
  // What the public status poll and the owner's instance read add for this leg: the checklist,
109
191
  // the install link (until the install is verified), the repo it belongs on, and whether the
110
192
  // runner is still checking. `status` is manifest-truth's EFFECTIVE status. Never the raw row.
@@ -113,17 +195,26 @@ function stillVerifying(row, facts = {}, now = Date.now()) {
113
195
  // lands on the setup page, which sends the founder back to their projects.
114
196
  // A failed facts read is "nothing verified yet" — the honest 'now' states, never a 500.
115
197
  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 };
198
+ if (!row) return { checklist: githubChecklist(null, null), install_url: null, repo: targetRepo(inst), verifying: false, app_org: null, pick_up: null };
117
199
  let facts = {};
118
200
  // through module.exports so a route test can stub the one DB read on the cached module
119
201
  try { facts = await module.exports.githubSetupFacts(db, row.instance_id, row.created_at); } catch (_) { facts = {}; }
120
202
  const checklist = githubChecklist(status, row.app_slug, facts);
203
+ const verifying = stillVerifying(row, facts, now);
121
204
  return {
122
205
  checklist,
123
206
  install_url: checklist.install === 'done' ? null : installUrlFor({ appSlug: row.app_slug, state: withState ? row.state : null }),
124
207
  repo: targetRepo(inst),
125
- verifying: stillVerifying(row, facts, now),
208
+ verifying,
209
+ // what "Pick up where it stopped" will do (task 1004652); never a secret, never the code
210
+ pick_up: pickUpFor(row, status, checklist, verifying, now),
211
+ // the organisation this attempt makes the app on, or null for the founder's account
212
+ // (task 1004651): the page links the app's settings under the right owner
213
+ app_org: validGithubLogin(facts.appOrg) ? facts.appOrg : null,
126
214
  };
127
215
  }
128
216
 
129
- module.exports = { SETUP_EVENTS, VERIFY_WINDOW_MS, installUrlFor, targetRepo, githubSetupFacts, githubChecklist, stillVerifying, githubSetupRead };
217
+ module.exports = {
218
+ SETUP_EVENTS, VERIFY_WINDOW_MS, installUrlFor, targetRepo, githubSetupFacts, githubChecklist, stillVerifying, githubSetupRead,
219
+ validGithubLogin, pickUpFor, mintGuard, requeueConversion,
220
+ };
@@ -91,7 +91,8 @@ module.exports = function buildGithubSetupRouter() {
91
91
  status,
92
92
  failure,
93
93
  ready: status === 'done',
94
- terminal: failed || (setup.checklist.connected && (setup.checklist.device_flow === 'done' || !setup.verifying)),
94
+ // a stopped sign-in the runner is still re-checking is not the end (task 1004652)
95
+ terminal: (failed && !setup.verifying) || (setup.checklist.connected && (setup.checklist.device_flow === 'done' || !setup.verifying)),
95
96
  domain: inst && inst.domain ? inst.domain : null,
96
97
  instance_status: inst ? inst.status : null,
97
98
  // The App's public slug (task 1003049) — its GitHub page is public by design.
@@ -100,6 +101,8 @@ module.exports = function buildGithubSetupRouter() {
100
101
  install_url: setup.install_url,
101
102
  repo: setup.repo,
102
103
  verifying: setup.verifying,
104
+ app_org: setup.app_org,
105
+ pick_up: setup.pick_up,
103
106
  });
104
107
  } catch (err) {
105
108
  log.error('[provisioning] GET /provisioning/github-app/status', err);
@@ -183,6 +186,46 @@ module.exports = function buildGithubSetupRouter() {
183
186
  }
184
187
  });
185
188
 
189
+ // POST /provisioning/instances/:id/github-app/pick-up — "Pick up where it stopped" and
190
+ // "Check again" (task 1004652). It resumes the latest attempt from the step it stopped at,
191
+ // with the app GitHub already made, and never mints a new link: 'convert' puts an approved
192
+ // attempt whose code is still young back in the runner's queue; 'check' records a check
193
+ // request, which re-opens the runner's checking window for sign-in, the install and
194
+ // device flow. Every other pick-up is the page's own ('reopen' and 'fresh' go through the
195
+ // mint route, whose guard reuses or refuses; 'operator' files a blocker), so they are a
196
+ // 409 naming the action. Answers with the fresh read. rank: any authenticated builder
197
+ // (requireBuilder), the project's OWNER only — no Archon override, as with the mint route;
198
+ // anyone else gets the same 404 as a missing project.
199
+ router.post('/provisioning/instances/:id/github-app/pick-up', api.requireBuilder, returnRateLimit, async (req, res) => {
200
+ const id = /^\d{1,12}$/.test(String(req.params.id)) ? Number(req.params.id) : null;
201
+ if (!id) return res.fail('bad_id', 400);
202
+ try {
203
+ const inst = await provisioning.getInstanceById(pool, id);
204
+ if (!ownsLanding(inst, req.builder && req.builder.id)) return res.fail('instance_not_found', 404);
205
+ const row = await provisioning.getLatestOAuthManifestForInstance(pool, id);
206
+ const read = async (r) => githubSetup.githubSetupRead(pool, inst, r, r ? provisioning.publicGithubApp(r).status : null, { withState: false });
207
+ const before = await read(row);
208
+ const action = before.pick_up;
209
+ if (action === 'convert') {
210
+ if (!(await githubSetup.requeueConversion(pool, row.id))) return res.fail('nothing_to_pick_up', { status: 409, message: 'This step has already moved on.' });
211
+ } else if (action === 'check') {
212
+ await provisioning.recordEvent(pool, {
213
+ instanceId: id, ownerBuilderId: req.builder.id, event: githubSetup.SETUP_EVENTS.checkRequested,
214
+ detail: 'the founder asked to pick up the GitHub setup where it stopped', actor: 'api:self',
215
+ });
216
+ } else {
217
+ return res.fail('nothing_to_pick_up', { status: 409, message: 'Nothing to pick up here.', action: action || null });
218
+ }
219
+ const after = await provisioning.getLatestOAuthManifestForInstance(pool, id);
220
+ const pub = provisioning.publicGithubApp(after);
221
+ const setup = await read(after);
222
+ return res.json({ ok: true, action, status: pub.status, failure: pub.failure, app_slug: after.app_slug || null, ...setup });
223
+ } catch (err) {
224
+ log.error('[provisioning] POST /provisioning/instances/:id/github-app/pick-up', err);
225
+ return res.status(500).json({ ok: false, error: 'pick_up_failed' });
226
+ }
227
+ });
228
+
186
229
  // GET /provisioning/github-app/resume[?id=<n>] — the project page's "which project am I
187
230
  // connecting?" read when its tab remembers nothing (task 1004650): a hand-back on another
188
231
  // device, a fresh tab, a cleared browser. With an id: that project, if the caller owns it.
@@ -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,20 +1260,38 @@ 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
  }
1252
- const state = crypto.randomBytes(32).toString('hex');
1253
- const expiresAt = new Date(Date.now() + MANIFEST_STATE_TTL_MS).toISOString();
1254
- await provisioning.createOAuthManifest(pool, { instanceId: id, state, requestedBy: req.builder.id, expiresAt });
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';
1271
+ // never a second app (task 1004652): a link still waiting on GitHub is handed back,
1272
+ // and once GitHub has approved one for this project no new link is made
1273
+ const guard = githubSetup.mintGuard(await provisioning.getLatestOAuthManifestForInstance(pool, id));
1274
+ if (guard && guard.refuse) return res.fail(guard.refuse, { status: 409, message: guard.message });
1275
+ const state = guard ? guard.reuse : crypto.randomBytes(32).toString('hex');
1276
+ const expiresAt = guard ? guard.expiresAt : new Date(Date.now() + MANIFEST_STATE_TTL_MS).toISOString();
1277
+ if (!guard) await provisioning.createOAuthManifest(pool, { instanceId: id, state, requestedBy: req.builder.id, expiresAt });
1255
1278
  await provisioning.recordEvent(pool, {
1256
1279
  instanceId: id, ownerBuilderId: req.builder.id, event: 'oauth-manifest-start',
1257
- detail: 'GitHub App Manifest link minted', actor: 'api:self',
1280
+ detail: guard ? 'GitHub App Manifest link reopened (same attempt)' : 'GitHub App Manifest link minted', actor: 'api:self',
1258
1281
  }).catch(() => {});
1282
+ // which account owns this attempt's app, so the page can link its settings there
1283
+ // from any tab or device (github-setup.js reads it back as app_org)
1284
+ if (account === 'org') {
1285
+ await provisioning.recordEvent(pool, {
1286
+ instanceId: id, ownerBuilderId: req.builder.id, event: githubSetup.SETUP_EVENTS.appOrg,
1287
+ detail: orgLogin, actor: 'api:self',
1288
+ }).catch(() => {});
1289
+ }
1259
1290
 
1260
1291
  const manifest = provisioning.buildGithubAppManifest({
1261
1292
  instanceName: inst.slug, slug: inst.slug, instanceDomain: inst.domain,
1262
1293
  mothershipOrigin: origin, brand: mothershipBrand(),
1263
1294
  });
1264
- const orgLogin = ownerFromTargetRef(inst.target_ref);
1265
- const account = req.body && req.body.account === 'org' && orgLogin ? 'org' : 'personal';
1266
1295
  res.json({
1267
1296
  state,
1268
1297
  account,