@bongos/core 1.21.96 → 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.
@@ -29,6 +29,9 @@ provisioning.getOAuthManifestByState = async (_db, state) => (state === 'GOODSTA
29
29
  ? { id: 99, instance_id: 42, requested_by: 7, status: 'pending', expires_at: new Date(Date.now() + 600000).toISOString() }
30
30
  : null);
31
31
  provisioning.attachManifestCode = async (_db, id, code) => ({ id, status: 'code_received', github_code: code });
32
+ // the latest attempt the mint guard and the pick-up route read (task 1004652); none by default
33
+ let latest = null;
34
+ provisioning.getLatestOAuthManifestForInstance = async () => latest;
32
35
 
33
36
  // A branding with a known publicOrigin so mothershipOrigin() resolves deterministically.
34
37
  const branding = require(`${ROOT}/src/branding.js`);
@@ -281,6 +284,76 @@ console.log('\nGET /provisioning/github-app/status (the page poll → auto-open
281
284
  t('missing state → 400', res.status === 400, `status=${res.status}`);
282
285
  }
283
286
 
287
+ console.log('\nNever a second app (task 1004652): the mint guard and "Pick up where it stopped":');
288
+ {
289
+ const mint = async () => fetch(`${base}/provisioning/instances/42/github-app`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
290
+ const soon = new Date(Date.now() + 600000).toISOString();
291
+ // a link still waiting on GitHub is handed back — the SAME state, no new attempt row
292
+ stored = null;
293
+ latest = { id: 70, instance_id: 42, state: 'WAITINGSTATE', status: 'pending', attempts: 0, expires_at: soon, created_at: new Date().toISOString() };
294
+ let res = await mint();
295
+ let j = await res.json();
296
+ t('a waiting link is reopened, not re-minted: same state, same expiry, no new row', res.status === 200 && j.state === 'WAITINGSTATE' && j.expires_at === soon && stored === null && j.post_url === 'https://github.com/settings/apps/new?state=WAITINGSTATE', JSON.stringify(j));
297
+ // GitHub already made the app (keys placed, sign-in unconfirmed): refused, nothing minted
298
+ for (const row of [
299
+ { id: 71, instance_id: 42, state: 'S71', status: 'error', attempts: 1, app_id: 5, app_slug: 'mercury-app', expires_at: soon },
300
+ { id: 72, instance_id: 42, state: 'S72', status: 'done', attempts: 1, app_id: 5, app_slug: 'mercury-app', expires_at: soon },
301
+ { id: 73, instance_id: 42, state: 'S73', status: 'error', attempts: 2, app_id: null, app_slug: null, expires_at: soon },
302
+ ]) {
303
+ stored = null; latest = row;
304
+ res = await mint(); j = await res.json();
305
+ t(`an attempt GitHub approved (${row.status}, attempts ${row.attempts}) refuses a new link: 409 app_exists, nothing minted`, res.status === 409 && j.error.code === 'app_exists' && stored === null, JSON.stringify(j));
306
+ }
307
+ stored = null; latest = { id: 74, instance_id: 42, state: 'S74', status: 'converting', attempts: 1, expires_at: soon };
308
+ res = await mint(); j = await res.json();
309
+ t('an attempt the runner is converting refuses a new link: 409 github_in_progress', res.status === 409 && j.error.code === 'github_in_progress' && stored === null, JSON.stringify(j));
310
+ // nothing was made on GitHub: a fresh link is minted
311
+ for (const row of [
312
+ { id: 75, instance_id: 42, state: 'S75', status: 'pending', attempts: 0, expires_at: new Date(Date.now() - 1000).toISOString() },
313
+ { id: 76, instance_id: 42, state: 'S76', status: 'error', attempts: 0, expires_at: soon },
314
+ ]) {
315
+ stored = null; latest = row;
316
+ res = await mint(); j = await res.json();
317
+ t(`nothing made on GitHub (${row.status}, attempts ${row.attempts}) → a fresh state is minted`, res.status === 200 && stored && stored.state === j.state && j.state !== row.state, JSON.stringify(j));
318
+ }
319
+
320
+ // the pick-up route: 'convert' re-queues the SAME attempt with the code it holds
321
+ const queries = [];
322
+ const pg = require(`${ROOT}/src/module-api.js`).pool;
323
+ const prevQuery = pg.query;
324
+ pg.query = async (sql, params) => { queries.push({ sql, params }); return { rows: /UPDATE provisioning_oauth_manifests/.test(sql) ? [{ id: params[0] }] : [] }; };
325
+ const pickUp = async () => fetch(`${base}/provisioning/instances/42/github-app/pick-up`, { method: 'POST' });
326
+ latest = { id: 80, instance_id: 42, state: 'S80', status: 'error', attempts: 1, github_code: 'CODE', app_id: null, app_slug: null, created_at: new Date(Date.now() - 10 * 60000).toISOString(), expires_at: soon };
327
+ const before = reported.length;
328
+ stored = null;
329
+ res = await pickUp(); j = await res.json();
330
+ const requeue = queries.find((q) => /UPDATE provisioning_oauth_manifests/.test(q.sql));
331
+ t("pick-up 'convert' re-queues attempt 80 itself (status code_received, attempts reset), mints nothing", res.status === 200 && j.action === 'convert' && requeue && requeue.params[0] === 80 && /status = 'code_received', attempts = 0/.test(requeue.sql) && /github_code IS NOT NULL/.test(requeue.sql) && stored === null, JSON.stringify(j));
332
+ // 'check': keys placed, the runner's window closed → a check request, no new attempt
333
+ queries.length = 0;
334
+ latest = { id: 81, instance_id: 42, state: 'S81', status: 'error', attempts: 1, app_id: 5, app_slug: 'mercury-app', created_at: new Date(Date.now() - 5 * 3600000).toISOString(), resolved_at: new Date(Date.now() - 4 * 3600000).toISOString(), expires_at: soon };
335
+ res = await pickUp(); j = await res.json();
336
+ const asked = reported.slice(before).filter((e) => e.event === 'github-app-check-requested');
337
+ t("pick-up 'check' records one check request for the project and writes no attempt", res.status === 200 && j.action === 'check' && asked.length === 1 && asked[0].instanceId === 42 && !queries.some((q) => /UPDATE|INSERT/.test(q.sql)) && stored === null, JSON.stringify(j));
338
+ // nothing the route itself can pick up: 409 naming the page's own action
339
+ for (const [row, action] of [
340
+ [{ id: 82, instance_id: 42, state: 'S82', status: 'pending', attempts: 0, expires_at: soon }, 'reopen'],
341
+ [{ id: 83, instance_id: 42, state: 'S83', status: 'error', attempts: 2, github_code: null, app_id: null, created_at: new Date().toISOString(), expires_at: soon }, 'operator'],
342
+ ]) {
343
+ latest = row;
344
+ res = await pickUp(); j = await res.json();
345
+ t(`pick-up on '${action}' is the page's own: 409 nothing_to_pick_up, action named`, res.status === 409 && j.error.code === 'nothing_to_pick_up' && j.error.action === action, JSON.stringify(j));
346
+ }
347
+ // another builder's project is a 404, like a missing one
348
+ const prevInst = provisioning.getInstanceById;
349
+ provisioning.getInstanceById = async () => ({ id: 42, owner_builder_id: 8, slug: 'mercury', domain: 'mercury.example.com', status: 'active' });
350
+ res = await pickUp();
351
+ t('pick-up on someone else’s project → 404', res.status === 404, `status=${res.status}`);
352
+ provisioning.getInstanceById = prevInst;
353
+ pg.query = prevQuery;
354
+ latest = null;
355
+ }
356
+
284
357
  console.log('\nparseId contract (the (req,res) signature — a bad id is a clean 400, not a 500):');
285
358
  {
286
359
  const res = await fetch(`${base}/provisioning/instances/not-a-number/github-app`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
@@ -69,9 +69,9 @@ test('publicGithubApp: effective status, failure only on error, started_at, and
69
69
 
70
70
  // ── 2. the words for each stage ─────────────────────────────────────────────
71
71
 
72
- // from the shared helpers of the four GitHub steps (task 1004649) to the next step
72
+ // from the shared helpers of the four GitHub steps (task 1004649) to the renderer table
73
73
  const RS = SCRIPT.indexOf(' /* The four GitHub steps of part 03');
74
- const RE = SCRIPT.indexOf(' /* device-flow', RS);
74
+ const RE = SCRIPT.indexOf(' var STEP_RENDERERS = {', RS);
75
75
  const RENDER = SCRIPT.slice(RS, RE);
76
76
 
77
77
  test('the GitHub step region is still findable (a rename must fail loudly)', () => {
@@ -79,11 +79,10 @@ test('the GitHub step region is still findable (a rename must fail loudly)', ()
79
79
  assert.match(RENDER, /function renderStepOauthApp\(\)/);
80
80
  });
81
81
 
82
- function stage(live, { stale = false } = {}) {
82
+ function stage(live, { tab = null, fn = 'renderStepOauthApp' } = {}) {
83
83
  const sandbox = {
84
- live: { manifest: null, manifestMsg: '', manifestCode: '', manifestFailure: null, appSlug: null, setup: null, installUrl: null, instStatus: 'active', addrReady: true, ...live },
84
+ live: { manifest: null, manifestMsg: '', manifestCode: '', manifestFailure: null, appSlug: null, setup: null, installUrl: null, instStatus: 'active', addrReady: true, pickUp: null, verifying: false, askedPlatform: false, ...live },
85
85
  state: { name: 'Mercury', slug: 'mercury' },
86
- manifestStale: stale,
87
86
  autoOpenArmed: false,
88
87
  resolvedDomain: () => 'mercury.example.com',
89
88
  apexBase: () => 'example.com',
@@ -95,17 +94,26 @@ function stage(live, { stale = false } = {}) {
95
94
  progressMarkup: (now, done, note, cls) => `<prog now=${now} done=${done} cls="${cls}">${note}</prog>`,
96
95
  };
97
96
  vm.createContext(sandbox);
98
- vm.runInContext(`${RENDER}\nvar out = renderStepOauthApp();`, sandbox);
97
+ vm.runInContext(`${RENDER}\nghTabTrouble = ${JSON.stringify(tab)};\nvar out = ${fn}();`, sandbox);
99
98
  return sandbox.out;
100
99
  }
101
100
  const startButton = /data-action="manifest-start"/;
101
+ const NEVER_A_CHORE = /\/contact|Contact us|delete it first|Developer settings → GitHub Apps\) so|Try again anyway|Start the setup again/;
102
102
 
103
- test('pending: waiting for approval, and says where a missing GitHub tab went', () => {
103
+ test('pending: waiting for approval, says where a missing GitHub tab went, and reopens the SAME setup', () => {
104
104
  const html = stage({ manifest: 'pending' });
105
105
  assert.match(html, /Waiting for your approval on GitHub/);
106
106
  assert.match(html, /No GitHub tab\? It may have opened behind this window or in another one/);
107
- assert.doesNotMatch(html, startButton, 'no restart offer while the wait is young');
108
- assert.match(stage({ manifest: 'pending' }, { stale: true }), /Start the setup again/, 'offered once the wait has gone wrong');
107
+ assert.doesNotMatch(html, startButton, 'no new-link button while GitHub holds this one (task 1004652)');
108
+ assert.match(html, /class="linklike" data-action="reopen-github">No GitHub tab\? Reopen GitHub/, 'the way back is there from the first second, not after 90');
109
+ // the tab this page opened, seen from here (task 1004652)
110
+ const behind = stage({ manifest: 'pending' }, { tab: 'behind' });
111
+ assert.match(behind, /Your GitHub tab opened behind this window/);
112
+ assert.match(behind, /class="ghbtn" data-action="reopen-github">Reopen GitHub ↗/);
113
+ const closed = stage({ manifest: 'pending' }, { tab: 'closed' });
114
+ assert.match(closed, /The GitHub tab closed before the app was approved, so nothing was made on GitHub/);
115
+ assert.match(closed, /data-action="reopen-github"/);
116
+ assert.doesNotMatch(behind + closed, startButton);
109
117
  });
110
118
 
111
119
  test('converting is the SAME stage as code_received — approved, placing credentials — never "waiting for approval"', () => {
@@ -118,24 +126,59 @@ test('converting is the SAME stage as code_received — approved, placing creden
118
126
  }
119
127
  });
120
128
 
121
- test('a failure AFTER approval says an app now exists, names it, and does not push a second one', () => {
122
- const html = stage({ manifest: 'error', manifestFailure: 'sign_in_unconfirmed', appSlug: 'mercury-cloudbongos-com' });
123
- assert.match(html, /GitHub approved the app and the platform placed its credentials/);
124
- assert.match(html, /href="https:\/\/github\.com\/settings\/apps\/mercury-cloudbongos-com"/);
125
- assert.match(html, /would make a second app/);
126
- assert.match(html, /href="\/contact"/);
127
- assert.match(html, /cls="isErr"/, 'the strip shows where it stopped');
128
- assert.doesNotMatch(html, /Nothing was changed on GitHub/, 'the old claim was false here');
129
- assert.doesNotMatch(html, /class="ghbtn"/, 'no big primary "set up" button inviting a second app');
130
- assert.match(html, /Try again anyway/, '…but the founder is never left with no way forward');
129
+ test('sign-in unconfirmed: the app stays ticked, sign-in is the stopped step, and picking it up makes no second app', () => {
130
+ const live = { manifest: 'error', manifestFailure: 'sign_in_unconfirmed', appSlug: 'mercury-cloudbongos-com', pickUp: 'check' };
131
+ const approve = stage(live);
132
+ assert.match(approve, /^<li class="sdone"/, 'GitHub made the app and its keys are placed: that much is true');
133
+ const signIn = stage(live, { fn: 'renderStepFirstSignIn' });
134
+ assert.match(signIn, /^<li class="sstopped"/);
135
+ assert.match(signIn, /Your project didn’t confirm that sign-in works\. Nothing is lost, and no second app will be made\./);
136
+ assert.match(signIn, /data-action="pick-up">Pick up where it stopped/);
137
+ assert.match(signIn, /data-action="ask-platform">Send it to the platform team/, 'a project that still will not answer goes to the platform team from here');
138
+ assert.doesNotMatch(approve + signIn, startButton, 'no new link: it would make a second app');
139
+ assert.doesNotMatch(approve + signIn, NEVER_A_CHORE);
140
+ // while the runner is asking again, the step says so and offers no button that repeats it
141
+ const checking = stage({ ...live, pickUp: null, verifying: true }, { fn: 'renderStepFirstSignIn' });
142
+ assert.match(checking, /We’re asking it again, with the app GitHub already made/);
143
+ assert.doesNotMatch(checking, /data-action="pick-up"/);
144
+ // sent: the step says the platform team has it
145
+ assert.match(stage({ ...live, askedPlatform: true }, { fn: 'renderStepFirstSignIn' }), /Sent\. The platform team has it/);
146
+ assert.match(stage({ ...live, askedPlatform: 'failed' }, { fn: 'renderStepFirstSignIn' }), /That didn’t send\. Try again\.[\s\S]*data-action="ask-platform"/);
131
147
  });
132
148
 
133
- test('approved but never finished: delete the app GitHub made first, then try again', () => {
134
- const html = stage({ manifest: 'error', manifestFailure: 'finish_failed' });
135
- assert.match(html, /GitHub approved the app, but the platform couldn’t finish/);
136
- assert.match(html, /delete it first/);
149
+ test('approved, conversion stopped with its code still young: pick it up, no second app', () => {
150
+ const html = stage({ manifest: 'error', manifestFailure: 'finish_failed', pickUp: 'convert' });
151
+ assert.match(html, /^<li class="sstopped"/);
152
+ assert.match(html, /GitHub approved the app, and the platform stopped before it finished collecting the app’s keys\. Nothing is lost, and no second app will be made\./);
153
+ assert.match(html, /data-action="pick-up">Pick up where it stopped/);
154
+ assert.doesNotMatch(html, startButton);
155
+ assert.doesNotMatch(html, NEVER_A_CHORE);
156
+ });
157
+
158
+ test('approved but the keys never reached us: the platform team, sent from the page — never a GitHub chore', () => {
159
+ const html = stage({ manifest: 'error', manifestFailure: 'finish_failed', pickUp: 'operator' });
160
+ assert.match(html, /GitHub made the app, but its keys never reached your project/);
161
+ assert.match(html, /data-action="ask-platform">Send it to the platform team/);
162
+ assert.doesNotMatch(html, startButton, 'a new link would leave the founder with two apps');
163
+ assert.doesNotMatch(html, NEVER_A_CHORE);
137
164
  assert.doesNotMatch(html, /Nothing was changed on GitHub/);
138
- assert.match(html, startButton);
165
+ });
166
+
167
+ test('the install the runner stopped checking for: the mockup’s sentence, and a pick-up that opens the SAME app’s install', () => {
168
+ const html = stage({ manifest: 'done', appSlug: 'mercury-app', installUrl: 'https://github.com/apps/mercury-app/installations/new', pickUp: 'check' }, { fn: 'renderStepAppInstall' });
169
+ assert.match(html, /^<li class="sstopped" data-onboard-step="app-install">/);
170
+ assert.match(html, /GitHub stopped before the app was installed on your repo\. Nothing is lost, and no second app will be made\./);
171
+ assert.match(html, /<a class="ghbtn" href="https:\/\/github\.com\/apps\/mercury-app\/installations\/new" target="_blank" rel="noopener" data-action="pick-up">Pick up where it stopped ↗<\/a>/);
172
+ // still being checked: the ordinary step, no stop
173
+ assert.doesNotMatch(stage({ manifest: 'done', appSlug: 'mercury-app', pickUp: null, verifying: true }, { fn: 'renderStepAppInstall' }), /sstopped/);
174
+ });
175
+
176
+ test('device flow after the runner stopped checking: "Check again", once the install has ticked', () => {
177
+ const setup = { approve: 'done', install: 'done', sign_in: 'done', device_flow: 'later', connected: true };
178
+ const again = stage({ manifest: 'done', appSlug: 'mercury-app', setup, pickUp: 'check' }, { fn: 'renderStepDeviceFlow' });
179
+ assert.match(again, /data-action="pick-up">Turned it on\? Check again/);
180
+ assert.doesNotMatch(stage({ manifest: 'done', appSlug: 'mercury-app', setup, pickUp: null, verifying: true }, { fn: 'renderStepDeviceFlow' }), /Check again/);
181
+ assert.doesNotMatch(stage({ manifest: 'done', appSlug: 'mercury-app', setup: { ...setup, install: 'now', connected: false }, pickUp: 'check' }, { fn: 'renderStepDeviceFlow' }), /Check again/, 'the install step carries the action until it ticks');
139
182
  });
140
183
 
141
184
  test('a failure before GitHub (or a refused start) keeps the honest "nothing changed" line', () => {
@@ -175,7 +218,8 @@ function reloaded(githubApp, now) {
175
218
  const sandbox = {
176
219
  data: { github_app: githubApp },
177
220
  live: { manifest: null, manifestFailure: null, appSlug: null },
178
- manifestPollTimer: null, manifestStartedAt: 0, manifestStale: false,
221
+ manifestPollTimer: null, manifestStartedAt: 0, ghTabTrouble: null,
222
+ document: { hidden: false, hasFocus: () => true },
179
223
  rememberManifest: () => {}, renderSteps: () => renders.push(1),
180
224
  Date: { now: () => now, parse: Date.parse },
181
225
  };
@@ -184,19 +228,41 @@ function reloaded(githubApp, now) {
184
228
  return { sandbox, renders, tick: () => vm.runInContext(SEED, sandbox) };
185
229
  }
186
230
 
187
- test('a reloaded page times the wait from when the link was minted, and still reaches "start again"', () => {
231
+ test('a reloaded page seeds the wait from when the link was minted, and keeps watching it', () => {
188
232
  const minted = '2026-10-04T20:20:00Z';
189
233
  const young = reloaded({ status: 'pending', app_slug: null, failure: null, started_at: minted }, Date.parse(minted) + 30000);
190
234
  assert.equal(young.sandbox.live.manifest, 'pending');
191
235
  assert.equal(young.sandbox.manifestStartedAt, Date.parse(minted), 'seeded from the server');
192
- assert.equal(young.sandbox.manifestStale, false);
193
236
 
194
237
  const old = reloaded({ status: 'pending', app_slug: null, failure: null, started_at: minted }, Date.parse(minted) + 120000);
195
- old.tick(); // the next 5s read: same status, so the wait timer decides
196
- assert.equal(old.sandbox.manifestStale, true, 'the offer appears — it used to be never after a reload');
238
+ old.tick(); // the next 5s read: same status
239
+ // a reloaded page holds no handle on a GitHub tab, so it claims nothing about one; the
240
+ // step's "Reopen GitHub" is always on show instead (task 1004652)
241
+ assert.equal(old.sandbox.ghTabTrouble, null);
197
242
  assert.equal(vm.runInContext('manifestInFlight()', old.sandbox), true);
198
243
  });
199
244
 
245
+ test('the GitHub tab this page opened: behind or closed is noticed in seconds, not after 90', () => {
246
+ const r = reloaded({ status: 'pending', app_slug: null, failure: null, started_at: null }, 0);
247
+ const at = (tab, left, since, now, focused) => vm.runInContext(`ghTabState(${tab}, ${left}, ${since}, ${now}, ${focused})`, r.sandbox);
248
+ assert.equal(at('null', false, 0, 60000, true), null, 'no handle, nothing claimed');
249
+ assert.equal(at('{ closed: true }', true, 0, 1000, false), 'closed', 'closed is seen at once');
250
+ assert.equal(at('{ closed: false }', false, 0, 5000, true), 'behind', 'this page kept the focus five seconds after opening it');
251
+ assert.equal(at('{ closed: false }', false, 0, 3000, true), null, 'not before a few seconds');
252
+ assert.equal(at('{ closed: false }', true, 0, 60000, false), null, 'the founder went to the tab: it is in front');
253
+ assert.equal(at('{ closed: false }', false, 0, 60000, false), null, 'this page lost the focus: not behind');
254
+ assert.doesNotMatch(SCRIPT, /90000/, 'the 90-second wait is gone');
255
+ // the poll's check redraws only on a change, and stops once the link is no longer waiting
256
+ r.sandbox.ghTab = { closed: true };
257
+ vm.runInContext('ghTab = { closed: true }; ghTabSince = 0; noteManifestWait();', r.sandbox);
258
+ assert.equal(r.sandbox.ghTabTrouble, 'closed');
259
+ const n = r.renders.length;
260
+ vm.runInContext('noteManifestWait();', r.sandbox);
261
+ assert.equal(r.renders.length, n, 'no redraw when nothing moved');
262
+ vm.runInContext("live.manifest = 'code_received'; noteManifestWait();", r.sandbox);
263
+ assert.equal(r.sandbox.ghTabTrouble, null, 'approved: the tab no longer matters');
264
+ });
265
+
200
266
  test('a reloaded page learns HOW a failure happened, not just that it did', () => {
201
267
  const r = reloaded({ status: 'error', app_slug: 'mercury-cloudbongos-com', failure: 'sign_in_unconfirmed', started_at: null }, NOW);
202
268
  assert.equal(r.sandbox.live.manifest, 'error');