@bongos/core 1.21.93 → 1.21.94

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.
Files changed (33) hide show
  1. package/.bongos-core.json +55 -35
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +4 -2
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/api/openapi.json +38 -7
  8. package/docs/api-reference.md +4 -3
  9. package/docs/copy-inventory.md +354 -354
  10. package/docs/copy-registry.json +420 -420
  11. package/docs/design/project-startup-direction.md +7 -0
  12. package/docs/module-api-changelog.md +2 -0
  13. package/docs/page-readings.json +963 -992
  14. package/modules/provisioning/github-setup.js +129 -0
  15. package/modules/provisioning/module.json +2 -1
  16. package/modules/provisioning/provisioning.js +3 -0
  17. package/modules/provisioning/routes/callback-page.js +66 -1
  18. package/modules/provisioning/routes/github-setup.js +113 -0
  19. package/modules/provisioning/routes/provisioning.js +11 -45
  20. package/modules/public-landing/public/projects.html +105 -57
  21. package/package-lock.json +2 -2
  22. package/package.json +1 -1
  23. package/release-notes.json +6 -0
  24. package/scripts/gds/oauth-secret.js +4 -2
  25. package/scripts/gds/provision-github-setup.js +148 -0
  26. package/scripts/gds/provision.js +19 -5
  27. package/src/module-api.js +1 -1
  28. package/tests/github_one_trip.mjs +297 -0
  29. package/tests/projects_hub_dns_ready.mjs +2 -1
  30. package/tests/projects_hub_pre_uat.mjs +17 -9
  31. package/tests/provision.mjs +51 -9
  32. package/tests/provisioning_github_app_routes.mjs +94 -8
  33. package/tests/wizard_github_feedback.mjs +8 -5
@@ -0,0 +1,297 @@
1
+ // tests/github_one_trip.mjs — "Connect GitHub in one trip: approving the app goes straight
2
+ // to installing it on the repo, and the checklist ticks itself" (task 1004649).
3
+ //
4
+ // Four layers, DB-free and network-free:
5
+ // 1. modules/provisioning/github-setup.js — each checklist step's state from server facts,
6
+ // the honest "not yet" states included; the install link; the verify window (PURE).
7
+ // 2. scripts/gds/provision-github-setup.js — the runner's check, asked of GitHub with the
8
+ // app's OWN key: the creds read back out of a REAL webEnvBody, a JWT that verifies,
9
+ // installed / not installed / cannot tell, device flow on / off / cannot tell, and the
10
+ // tick recording only what GitHub confirmed — never a secret in a log line.
11
+ // 3. the REAL projection (githubSetupRead) run into the REAL page renderers (projects.html,
12
+ // the vm-slice idiom): each server state draws the step the founder should see.
13
+ // 4. the device-flow finding is written down where the next builder will look.
14
+ //
15
+ // Run: node --test tests/github_one_trip.mjs
16
+
17
+ import assert from 'node:assert/strict';
18
+ import { test } from 'node:test';
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import vm from 'node:vm';
22
+ import crypto from 'node:crypto';
23
+ import { createRequire } from 'node:module';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ const require = createRequire(import.meta.url);
27
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
28
+ const GS = require(path.join(ROOT, 'modules', 'provisioning', 'github-setup.js'));
29
+ const RUN = require(path.join(ROOT, 'scripts', 'gds', 'provision-github-setup.js'));
30
+ const UNITS = require(path.join(ROOT, 'scripts', 'gds', 'provision-units.js'));
31
+ const HUB = fs.readFileSync(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
32
+ const SCRIPT = HUB.slice(HUB.lastIndexOf('<script>'), HUB.lastIndexOf('</script>'));
33
+
34
+ // ── 1. the checklist from server facts ──────────────────────────────────────
35
+
36
+ test('each step reads only what the server has seen — the honest "not yet" states included', () => {
37
+ const none = GS.githubChecklist(null, null);
38
+ assert.deepEqual(none, { approve: 'todo', install: 'todo', sign_in: 'todo', device_flow: 'todo', connected: false }, 'never started');
39
+ assert.equal(GS.githubChecklist('pending', null).approve, 'now', 'waiting for the approval on GitHub');
40
+ for (const s of ['code_received', 'converting']) {
41
+ const c = GS.githubChecklist(s, null);
42
+ assert.equal(c.approve, 'now', `${s}: approved, but no keys placed yet — not ticked`);
43
+ assert.equal(c.install, 'todo', `${s}: nothing to install before the app is named`);
44
+ }
45
+ const placed = GS.githubChecklist('converting', 'mercury-app');
46
+ assert.deepEqual([placed.approve, placed.install, placed.sign_in, placed.device_flow], ['done', 'now', 'now', 'todo'], 'keys placed: approve ticks, install and sign-in are under way');
47
+ const signedIn = GS.githubChecklist('done', 'mercury-app');
48
+ assert.deepEqual([signedIn.install, signedIn.sign_in, signedIn.device_flow, signedIn.connected], ['now', 'done', 'later', false],
49
+ 'sign-in verified is NOT connected: the install has not been confirmed by GitHub');
50
+ const installed = GS.githubChecklist('done', 'mercury-app', { installedAt: '2026-10-08T10:00:00Z' });
51
+ assert.equal(installed.connected, true, 'approve + install + sign-in = connected');
52
+ assert.equal(installed.device_flow, 'later', 'device flow stays optional and never holds "connected" back');
53
+ assert.equal(GS.githubChecklist('done', 'mercury-app', { installedAt: 'x', deviceFlowAt: 'y' }).device_flow, 'done');
54
+ // a REPORTED install (the setup_url redirect, spoofable) ticks nothing
55
+ assert.equal(GS.githubChecklist('done', 'mercury-app', { installReportedAt: '2026-10-08T10:00:00Z' }).install, 'now');
56
+ // failures: where it stopped
57
+ assert.equal(GS.githubChecklist('error', null).approve, 'stopped');
58
+ assert.equal(GS.githubChecklist('expired', null).approve, 'stopped');
59
+ const signInFailed = GS.githubChecklist('error', 'mercury-app');
60
+ assert.deepEqual([signInFailed.approve, signInFailed.sign_in], ['done', 'stopped'], 'an app exists; sign-in is where it stopped');
61
+ });
62
+
63
+ test('the install link names the app and carries the attempt; the repo is strict', () => {
64
+ assert.equal(GS.installUrlFor({ appSlug: null, state: 'abc' }), null);
65
+ assert.equal(GS.installUrlFor({ appSlug: 'mercury-app', state: 'ab cd' }), 'https://github.com/apps/mercury-app/installations/new?state=ab%20cd');
66
+ assert.equal(GS.installUrlFor({ appSlug: 'mercury-app' }), 'https://github.com/apps/mercury-app/installations/new');
67
+ assert.equal(GS.targetRepo({ target_ref: 'acme/mercury' }), 'acme/mercury');
68
+ assert.equal(GS.targetRepo({ target_ref: 'acme/mercury.git' }), 'acme/mercury');
69
+ for (const bad of ['', 'acme', '../x/y', 'a/b/c', 'acme/mer cury', '-acme/x']) assert.equal(GS.targetRepo({ target_ref: bad }), null, bad);
70
+ });
71
+
72
+ test('the runner checks for a bounded window after the attempt settles, re-opened by an install report', () => {
73
+ const now = Date.parse('2026-10-08T12:00:00Z');
74
+ assert.equal(GS.stillVerifying({ resolved_at: '2026-10-08T11:00:00Z' }, {}, now), true);
75
+ assert.equal(GS.stillVerifying({ resolved_at: '2026-10-08T09:00:00Z' }, {}, now), false);
76
+ assert.equal(GS.stillVerifying({ resolved_at: '2026-10-08T09:00:00Z' }, { installReportedAt: '2026-10-08T11:30:00Z' }, now), true);
77
+ assert.equal(GS.stillVerifying(null, {}, now), false);
78
+ });
79
+
80
+ test('githubSetupRead: one events read since the attempt began; the owner read leaves the state off', async () => {
81
+ const seen = [];
82
+ const db = { query: async (sql, params) => { seen.push({ sql, params }); return { rows: [{ event: GS.SETUP_EVENTS.installed, at: '2026-10-08T10:00:00Z' }] }; } };
83
+ const row = { instance_id: 42, state: 'S1', created_at: '2026-10-08T09:00:00Z', app_slug: 'mercury-app', status: 'done' };
84
+ const pub = await GS.githubSetupRead(db, { target_ref: 'acme/mercury' }, row, 'done', { now: Date.parse('2026-10-08T10:30:00Z') });
85
+ assert.equal(seen.length, 1);
86
+ assert.match(seen[0].sql, /FROM provisioning_events/);
87
+ assert.deepEqual(seen[0].params, [42, Object.values(GS.SETUP_EVENTS), '2026-10-08T09:00:00Z']);
88
+ assert.equal(pub.checklist.install, 'done');
89
+ assert.equal(pub.install_url, null, 'no install link once it is verified');
90
+ const notYet = await GS.githubSetupRead({ query: async () => ({ rows: [] }) }, {}, row, 'done', { withState: false });
91
+ assert.equal(notYet.install_url, 'https://github.com/apps/mercury-app/installations/new', 'the owner read never carries the state token');
92
+ const broken = await GS.githubSetupRead({ query: async () => { throw new Error('db down'); } }, {}, row, 'done');
93
+ assert.equal(broken.checklist.install, 'now', 'a failed read is "nothing verified yet", never a 500');
94
+ });
95
+
96
+ // ── 2. the runner's check ───────────────────────────────────────────────────
97
+
98
+ const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
99
+ const PEM = privateKey.export({ type: 'pkcs1', format: 'pem' });
100
+ const INST = { id: 42, slug: 'mercury', port: 4100, domain: 'mercury.example.com', target_ref: 'acme/mercury', hosting_shape: 'co-tenant' };
101
+ const ENV = UNITS.webEnvBody(INST, { clientId: 'Iv23liABCDEF123456', clientSecret: 's'.repeat(40), appId: 5189633, appPem: PEM });
102
+
103
+ test('the app creds come back out of the web.env the runner itself writes', () => {
104
+ const c = RUN.appCredsFromEnv(ENV);
105
+ assert.equal(c.appId, '5189633');
106
+ assert.equal(c.clientId, 'Iv23liABCDEF123456');
107
+ assert.equal(c.pem.trim(), PEM.trim(), 'the one-line pem is restored to real newlines');
108
+ assert.deepEqual(RUN.appCredsFromEnv(''), { appId: null, pem: null, clientId: null });
109
+ });
110
+
111
+ test('the app JWT is RS256, signed by the app key, issued by the app id, under ten minutes', () => {
112
+ const jwt = RUN.appJwt({ appId: 5189633, pem: PEM, nowSec: 1_800_000_000 });
113
+ const [h, b, s] = jwt.split('.');
114
+ const ok = crypto.createVerify('RSA-SHA256').update(`${h}.${b}`).verify(publicKey, Buffer.from(s, 'base64url'));
115
+ assert.equal(ok, true);
116
+ const body = JSON.parse(Buffer.from(b, 'base64url').toString());
117
+ assert.equal(body.iss, '5189633');
118
+ assert.ok(body.exp - body.iat <= 600);
119
+ });
120
+
121
+ test('installed / not installed / cannot tell — only a 200 with an id is "installed"', async () => {
122
+ const calls = [];
123
+ const answer = (status, body) => async (url, opts) => { calls.push({ url, opts }); return { status, ok: status >= 200 && status < 300, json: async () => body }; };
124
+ assert.equal(await RUN.installedOnRepo({ appId: 1, pem: PEM, repo: 'acme/mercury', fetchImpl: answer(200, { id: 99 }) }), true);
125
+ assert.equal(calls[0].url, 'https://api.github.com/repos/acme/mercury/installation');
126
+ assert.match(calls[0].opts.headers.Authorization, /^Bearer [\w-]+\.[\w-]+\.[\w-]+$/, 'asked as the app');
127
+ assert.equal(await RUN.installedOnRepo({ appId: 1, pem: PEM, repo: 'acme/mercury', fetchImpl: answer(404, {}) }), false);
128
+ assert.equal(await RUN.installedOnRepo({ appId: 1, pem: PEM, repo: 'acme/mercury', fetchImpl: answer(500, {}) }), null);
129
+ assert.equal(await RUN.installedOnRepo({ appId: 1, pem: PEM, repo: 'acme/mercury', fetchImpl: async () => { throw new Error('offline'); } }), null);
130
+ });
131
+
132
+ test('device flow on / off / cannot tell — read from GitHub\'s own answer to a device sign-in start', async () => {
133
+ const with_ = (body) => async () => ({ status: 200, ok: true, json: async () => body });
134
+ assert.equal(await RUN.deviceFlowOn({ clientId: 'Iv1', fetchImpl: with_({ device_code: 'dc', user_code: 'AB-CD' }) }), true);
135
+ assert.equal(await RUN.deviceFlowOn({ clientId: 'Iv1', fetchImpl: with_({ error: 'device_flow_disabled' }) }), false);
136
+ assert.equal(await RUN.deviceFlowOn({ clientId: 'Iv1', fetchImpl: with_({ error: 'slow_down' }) }), null, 'any other answer is never read as off');
137
+ });
138
+
139
+ // A fake db that answers the runner's two reads: the attempts in the window, and the facts.
140
+ function fakeDb({ attempts, facts = [] }) {
141
+ return { query: async (sql) => (/WITH latest AS/.test(sql) ? { rows: attempts } : /FROM provisioning_events/.test(sql) ? { rows: facts } : { rows: [] }) };
142
+ }
143
+ const ATTEMPT = { id: 18, instance_id: 42, created_at: '2026-10-08T09:00:00Z', requested_by: 7, slug: 'mercury', target_ref: 'acme/mercury', hosting_shape: 'co-tenant', instance_status: 'active' };
144
+
145
+ test('the runner records what GitHub confirmed — and only that — reading the creds on the box', async () => {
146
+ const events = [], logs = [], cmds = [];
147
+ const fetchImpl = async (url) => (/\/installation$/.test(url)
148
+ ? { status: 200, ok: true, json: async () => ({ id: 31337 }) }
149
+ : { status: 200, ok: true, json: async () => ({ error: 'device_flow_disabled' }) });
150
+ const out = await RUN.verifyGithubAppSetups({
151
+ apply: true, privileged: true, log: (m) => logs.push(m), fetchImpl,
152
+ db: fakeDb({ attempts: [ATTEMPT] }),
153
+ exec: (cmd) => { cmds.push(cmd); return { ok: true, stdout: ENV }; },
154
+ provisioning: { recordEvent: async (_db, e) => { events.push(e); } },
155
+ });
156
+ assert.deepEqual(out, { checked: 1, installed: 1, deviceFlow: 0 });
157
+ assert.deepEqual(cmds, ['sudo cat /etc/mercury/web.env'], 'the creds are read where the runner placed them');
158
+ assert.deepEqual(events.map((e) => e.event), ['github-app-installed'], 'device flow off records nothing');
159
+ assert.match(events[0].detail, /acme\/mercury/);
160
+ const said = logs.join('\n') + JSON.stringify(events);
161
+ assert.doesNotMatch(said, /BEGIN RSA|s{40}|Iv23liABCDEF123456/, 'no key, secret or client id ever reaches a log or an event');
162
+ });
163
+
164
+ test('an attempt already fully verified is skipped; dry-run asks GitHub nothing', async () => {
165
+ let asked = 0;
166
+ const deps = {
167
+ apply: true, log: () => {}, fetchImpl: async () => { asked++; return { status: 200, ok: true, json: async () => ({}) }; },
168
+ db: fakeDb({ attempts: [ATTEMPT], facts: [{ event: 'github-app-installed', at: '2026-10-08T10:00:00Z' }, { event: 'github-app-device-flow', at: '2026-10-08T10:00:00Z' }] }),
169
+ exec: () => { throw new Error('must not read the box'); }, provisioning: { recordEvent: async () => { throw new Error('nothing to record'); } },
170
+ };
171
+ assert.deepEqual(await RUN.verifyGithubAppSetups(deps), { checked: 0, installed: 0, deviceFlow: 0 });
172
+ const lines = [];
173
+ await RUN.verifyGithubAppSetups({ ...deps, apply: false, log: (m) => lines.push(m), db: fakeDb({ attempts: [ATTEMPT] }) });
174
+ assert.equal(asked, 0);
175
+ assert.match(lines.join('\n'), /would check the GitHub install \+ device flow for mercury/);
176
+ });
177
+
178
+ test('the window query takes each project\'s LATEST attempt, past placement, still settling or newly reported', async () => {
179
+ let sql = '', params = null;
180
+ await RUN.attemptsToVerify({ query: async (q, p) => { sql = q; params = p; return { rows: [] }; } });
181
+ assert.match(sql, /DISTINCT ON \(instance_id\)[\s\S]*ORDER BY instance_id, created_at DESC/);
182
+ assert.match(sql, /l\.app_id IS NOT NULL AND l\.status IN \('done', 'error'\) AND i\.status = 'active'/);
183
+ assert.match(sql, /LIMIT 20/);
184
+ assert.deepEqual(params, [GS.VERIFY_WINDOW_MS / 1000, 'github-app-install-reported']);
185
+ assert.match(fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'provision.js'), 'utf8'), /require\('\.\/provision-github-setup\.js'\)\.verifyGithubAppSetups\(deps\)/, 'it runs on every runner tick, after the conversions');
186
+ });
187
+
188
+ // ── 3. the real projection into the real renderers ──────────────────────────
189
+
190
+ const RS = SCRIPT.indexOf(' /* The four GitHub steps of part 03');
191
+ const RE = SCRIPT.indexOf(' var STEP_RENDERERS = {', RS);
192
+ const SS = SCRIPT.indexOf(' /* the checklist the server derived (task 1004649)');
193
+ const SE = SCRIPT.indexOf(' function pollManifest(stateToken) {', SS);
194
+ const PS = SCRIPT.indexOf(' function stepsPaint() {');
195
+ const PE = SCRIPT.indexOf(' (function hopInput()', PS);
196
+
197
+ test('the regions are findable (a rename must fail loudly)', () => {
198
+ assert.ok(RS > 0 && RE > RS && SS > 0 && SE > SS && PS > 0 && PE > PS);
199
+ });
200
+
201
+ function page({ manifest, appSlug = null }) {
202
+ const sandbox = {
203
+ live: { manifest, manifestMsg: '', manifestCode: '', manifestFailure: null, appSlug, setup: null, installUrl: null, instStatus: 'active', addrReady: true },
204
+ state: { name: 'Mer<cury', slug: 'mercury', owner: 'acme', repo: 'mercury', step: 2 },
205
+ manifestStale: false, autoOpenArmed: false,
206
+ resolvedDomain: () => 'mercury.example.com', apexBase: () => 'example.com',
207
+ esc: (x) => String(x).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
208
+ GH_ICON: '', manualOauthDetails: () => '', projectPageLink: (t) => t, autoOpenTick: () => '',
209
+ progressMarkup: (a, b, note) => `<prog>${note}</prog>`, encodeURIComponent, JSON, Array,
210
+ };
211
+ vm.createContext(sandbox);
212
+ vm.runInContext(`${SCRIPT.slice(RS, RE)}\n${SCRIPT.slice(SS, SE)}\n${SCRIPT.slice(PS, PE)}`, sandbox);
213
+ return sandbox;
214
+ }
215
+ const ROWS = ['renderStepOauthApp', 'renderStepAppInstall', 'renderStepFirstSignIn', 'renderStepDeviceFlow'];
216
+ function draw(sb) {
217
+ return ROWS.map((f) => vm.runInContext(`${f}()`, sb));
218
+ }
219
+ function cls(li) { return ((li.match(/^<li(?: class="([^"]*)")?/) || [])[1] || '').split(/\s+/).filter(Boolean); }
220
+
221
+ async function serverSays(status, appSlug, eventRows) {
222
+ const db = { query: async () => ({ rows: eventRows }) };
223
+ const row = { instance_id: 42, state: 'S1', created_at: '2026-10-08T09:00:00Z', resolved_at: new Date().toISOString(), app_slug: appSlug };
224
+ return GS.githubSetupRead(db, { target_ref: 'acme/mercury' }, row, status);
225
+ }
226
+
227
+ test('the mockup\'s four steps, in its words and order, the typed name escaped', () => {
228
+ const sb = page({ manifest: null });
229
+ const rows = draw(sb);
230
+ const titles = rows.map((li) => (li.match(/<b>([^<]*)<\/b>/) || [])[1]);
231
+ assert.deepEqual(titles, ['Approve the Mer&lt;cury app on GitHub', 'Install it on your repo', 'Builders can sign in', 'Command-line sign-in is on']);
232
+ assert.match(rows[0], /data-action="manifest-start"[^>]*>Connect GitHub ↗/, 'one click starts the one trip');
233
+ assert.equal(SCRIPT.match(/var STEP_ORDER = \[([^\]]*)\]/)[1], "'github-repo', 'oauth-app', 'app-install', 'first-sign-in', 'device-flow', 'enter-hall'", 'drawn in the mockup\'s order');
234
+ });
235
+
236
+ test('each server state draws the step the founder should see — from the REAL projection', async () => {
237
+ // keys placed, nothing verified on GitHub yet
238
+ let sb = page({ manifest: 'converting', appSlug: 'mercury-app' });
239
+ let d = await serverSays('converting', 'mercury-app', []);
240
+ vm.runInContext(`seedSetup(${JSON.stringify(d.checklist)}, ${JSON.stringify(d.install_url)})`, sb);
241
+ let rows = draw(sb);
242
+ assert.deepEqual(rows.map(cls), [['sdone'], [], ['slocked'], ['slocked', 'soptional']]);
243
+ assert.match(rows[1], /href="https:\/\/github\.com\/apps\/mercury-app\/installations\/new\?state=S1"/, 'the way back to the install, with the attempt');
244
+ assert.equal(vm.runInContext('stepsPaint.toString().length > 0', sb), true);
245
+
246
+ // sign-in verified, install still unconfirmed: NOT connected
247
+ sb = page({ manifest: 'done', appSlug: 'mercury-app' });
248
+ d = await serverSays('done', 'mercury-app', []);
249
+ vm.runInContext(`seedSetup(${JSON.stringify(d.checklist)}, ${JSON.stringify(d.install_url)})`, sb);
250
+ rows = draw(sb);
251
+ assert.deepEqual(rows.map(cls), [['sdone'], [], ['sdone'], ['soptional']]);
252
+ assert.match(rows[3], /Optional — turn it on later\./);
253
+ assert.equal(vm.runInContext('setupOpen()', sb), true, 'the page keeps watching for the install');
254
+
255
+ // GitHub confirmed the install to the runner: connected, device flow still optional
256
+ sb = page({ manifest: 'done', appSlug: 'mercury-app' });
257
+ d = await serverSays('done', 'mercury-app', [{ event: 'github-app-installed', at: '2026-10-08T10:00:00Z' }]);
258
+ vm.runInContext(`seedSetup(${JSON.stringify(d.checklist)}, ${JSON.stringify(d.install_url)})`, sb);
259
+ rows = draw(sb);
260
+ assert.deepEqual(rows.map(cls), [['sdone'], ['sdone'], ['sdone'], ['soptional']]);
261
+ assert.match(rows[1], /The app is on acme\/mercury/);
262
+ assert.equal(vm.runInContext('setupOpen()', sb), false);
263
+ // the part's state line, from these very rows: the optional step does not hold it back
264
+ const items = rows.map((li) => ({ classList: { contains: (c) => cls(li).includes(c) } }));
265
+ const ghState = { textContent: '' };
266
+ sb.$ = (id) => (id === 'humanSteps' ? { children: items } : id === 'ghState' ? ghState : null);
267
+ sb.manifestInFlight = () => false;
268
+ vm.runInContext('stepsPaint()', sb);
269
+ assert.equal(ghState.textContent, 'connected ✓');
270
+
271
+ // and once GitHub has device flow on, the fourth ticks too
272
+ sb = page({ manifest: 'done', appSlug: 'mercury-app' });
273
+ d = await serverSays('done', 'mercury-app', [{ event: 'github-app-installed', at: '2026-10-08T10:00:00Z' }, { event: 'github-app-device-flow', at: '2026-10-08T10:05:00Z' }]);
274
+ vm.runInContext(`seedSetup(${JSON.stringify(d.checklist)}, ${JSON.stringify(d.install_url)})`, sb);
275
+ assert.deepEqual(cls(draw(sb)[3]), ['sdone', 'soptional']);
276
+ });
277
+
278
+ test('seedSetup is change-only, and an install link must be GitHub\'s', () => {
279
+ const sb = page({ manifest: 'done', appSlug: 'a' });
280
+ const c = JSON.stringify(GS.githubChecklist('done', 'a'));
281
+ assert.equal(vm.runInContext(`seedSetup(${c}, 'https://github.com/apps/a/installations/new?state=S')`, sb), true);
282
+ assert.equal(vm.runInContext(`seedSetup(${c}, 'https://github.com/apps/a/installations/new?state=S')`, sb), false, 'nothing moved, nothing redrawn');
283
+ assert.equal(vm.runInContext(`seedSetup(${c}, 'https://github.com/apps/a/installations/new')`, sb), false, 'a stateless link never replaces the one carrying the attempt');
284
+ assert.equal(vm.runInContext('live.installUrl', sb), 'https://github.com/apps/a/installations/new?state=S');
285
+ const sb2 = page({ manifest: 'done', appSlug: 'a' });
286
+ vm.runInContext(`seedSetup(${c}, 'javascript:alert(1)')`, sb2);
287
+ assert.equal(vm.runInContext('live.installUrl', sb2), null);
288
+ assert.equal(vm.runInContext('seedSetup(null, null)', sb2), false);
289
+ });
290
+
291
+ // ── 4. the finding is written down ──────────────────────────────────────────
292
+
293
+ test('the device-flow finding lives in the direction doc, and the code points at it', () => {
294
+ const doc = fs.readFileSync(path.join(ROOT, 'docs', 'design', 'project-startup-direction.md'), 'utf8');
295
+ assert.match(doc, /Device flow cannot be switched on for the founder \(spike, task 1004649\)/);
296
+ assert.match(fs.readFileSync(path.join(ROOT, 'modules', 'provisioning', 'github-setup.js'), 'utf8'), /docs\/design\/project-startup-direction\.md/);
297
+ });
@@ -165,7 +165,8 @@ test('every door to the address on the page asks addressReady', () => {
165
165
  const SCRIPT = HUB;
166
166
  assert.match(SCRIPT, /inst\.status === 'active' && addressReady\(inst\)\) acts\.push\('<a class="btn small ghost" href="https:\/\/' \+ esc\(inst\.domain\) \+ '" target="_blank" rel="noopener">View<\/a>'\)/, 'the row\'s View');
167
167
  assert.match(SCRIPT, /inst\.status === 'active' && addressReady\(inst\)\) acts\.push\('<a class="btn small ghost" href="https:\/\/' \+ esc\(inst\.domain\) \+ '" target="_blank" rel="noopener">Open site →<\/a>'\)/, 'the manage page\'s Open site');
168
- assert.match(SCRIPT, /live\.instStatus !== 'active' \|\| !live\.addrReady \|\| live\.manifest !== 'done'/, 'first sign-in and the hall step stay locked');
168
+ // task 1004649: "Builders can sign in" opens its door only on a resolving address
169
+ assert.match(SCRIPT, /var open = live\.instStatus === 'active' && live\.addrReady;\n\s*return '<li class="sdone" data-onboard-step="first-sign-in">/, 'the sign-in step\'s door waits for the address');
169
170
  assert.match(SCRIPT, /live\.addrReady = addressReady\(inst\); renderSteps\(\);/, 'and unlock on the tick the lookup answers');
170
171
  assert.equal((SCRIPT.match(/inst\.status !== 'active' \|\| !addressReady\(inst\)\) \{ (vis|join)Runs\(''\); return; \}/g) || []).length, 2,
171
172
  'the browser\'s own manifest reads wait for the name too');
@@ -185,14 +185,18 @@ test('the 33-minute hand-off keeps a snag honest and keeps its flow', () => {
185
185
  assert.match(SCRIPT, /progressMarkup\(1, 1, m\.slice\(5\), 'isErr', watchFlow\)/, 'the session-expiry message keeps its flow too');
186
186
  });
187
187
 
188
- test('First sign-in and Enter your builders hall unlock only when the project answers AND sign-in is wired', () => {
188
+ test('"Builders can sign in" ticks on verified sign-in, and its door opens only when the project answers (task 1004649)', () => {
189
+ // the one-page checklist (task 1004649): the step's tick is the server's 'done';
190
+ // the door to the project still waits for a resolving address (task 1004505)
189
191
  const first = fn('renderStepFirstSignIn');
192
+ assert.match(first, /<b>Builders can sign in<\/b>/);
193
+ assert.match(first, /if \(live\.manifest !== 'done'\) \{\n\s*return '<li class="slocked"/, 'locked until the platform has seen sign-in work');
194
+ assert.match(first, /var open = live\.instStatus === 'active' && live\.addrReady;/, 'the door gates on status and a resolving address');
195
+ assert.match(first, /\(open \? '<a class="ghbtn" href="https:\/\/' \+ esc\(domain\)/, 'and only then links the address');
196
+ // the hall is the "is alive" block's way in; the step survives only for the local hall
190
197
  const hall = fn('renderStepEnterHall');
191
- for (const [name, body] of [['first-sign-in', first], ['enter-hall', hall]]) {
192
- assert.match(body, /live\.instStatus !== 'active' \|\| !live\.addrReady \|\| live\.manifest !== 'done'/, `${name} gates on status, a resolving address (task 1004505) and sign-in`);
193
- assert.match(body, /class="slocked"/, `${name} has a locked rendering`);
194
- }
195
- assert.match(first, /Unlocks once your project is answering and sign-in above is wired/);
198
+ assert.match(hall, /if \(domain\) return '';/);
199
+ assert.match(hall, /bongos dev/);
196
200
  });
197
201
 
198
202
  test('the domainless build is standing, not "Live"', () => {
@@ -208,7 +212,7 @@ test('the fallback checklist carries every human step the canonical plan has', (
208
212
  test('the manifest step acknowledges code_received, offers a restart after a stall, and rides the CREATED record', () => {
209
213
  // 'converting' is the same stage — it used to fall through to "waiting for approval" (task 1004572)
210
214
  assert.match(SCRIPT, /if \(live\.manifest === 'code_received' \|\| live\.manifest === 'converting'\)/, 'GitHub’s half is acknowledged');
211
- assert.match(SCRIPT, /GitHub’s part is done/);
215
+ assert.match(SCRIPT, /Approved on GitHub\. The platform is placing the app’s keys on your project/);
212
216
  assert.match(SCRIPT, /manifestStale \? '<button type="button" class="linklike" data-action="manifest-start">/, 'a stalled wait gets a restart door');
213
217
  assert.match(SCRIPT, /\(Date\.now\(\) - manifestStartedAt\) > 90000/, 'after ~90 seconds');
214
218
  assert.match(SCRIPT, /function rememberManifest\(stateToken, status, appSlug\)/);
@@ -610,10 +614,14 @@ test('teardown copy is state-aware: a queued project is told nothing was built',
610
614
 
611
615
  test('founder voice on the Device Flow step and the manual OAuth fallback', () => {
612
616
  const df = fn('renderStepDeviceFlow');
613
- assert.equal((df.match(/<b>Let builders sign in from a command line — one checkbox<\/b>/g) || []).length, 2, 'both branches carry the outcome-first title');
617
+ // the mockup's words (task 1004649): one title, in every branch
618
+ assert.match(df, /var title = '<b>Command-line sign-in is on<\/b>';/, 'the outcome-first title');
614
619
  assert.doesNotMatch(df, /<b>Enable Device Flow — one checkbox<\/b>/);
615
620
  assert.match(df, /the <code style="font-family:var\(--font-mono\)">bongos setup<\/code> command builders run on their own machine/, 'the command is glossed, not bare-coded');
616
- assert.doesNotMatch(df, /Optional/, 'not optional — a manifest-born App has Device Flow off');
621
+ // GitHub has no manifest field and no API for device flow (task 1004649's spike), so the
622
+ // step is OPTIONAL by design and never holds the project back
623
+ assert.equal((df.match(/Optional — turn it on later\./g) || []).length, 2, 'the not-yet branches say it is optional');
624
+ assert.equal((df.match(/class="[^"]*soptional/g) || []).length, 3, 'every branch is marked optional, so "connected" never waits on it');
617
625
  assert.equal((df.match(/<strong>Enable Device Flow<\/strong>/g) || []).length, 2, 'GitHub’s own checkbox label stays verbatim in the instructions');
618
626
  const manual = fn('manualOauthDetails');
619
627
  assert.match(manual, /The platform team places the generated credentials; this step ticks itself once they land\.<\/p><\/details>/);
@@ -1740,13 +1740,54 @@ await ta('drainOAuthManifests (--apply): claims a code_received row, places cred
1740
1740
  };
1741
1741
  const r = await P.drainOAuthManifests(deps);
1742
1742
  assert.deepEqual(r, { converted: 1, errors: 0 });
1743
- assert.equal(resolved.length, 1);
1744
- assert.equal(resolved[0].status, 'done');
1745
- assert.equal(resolved[0].patch.app_slug, 'app-3');
1746
- assert.equal(resolved[0].patch.github_code, null, 'a done row holds no re-convertible code (F23)');
1743
+ // task 1004649: the slug lands the moment the creds are placed (still 'converting'),
1744
+ // so the callback page can send the founder on to the install before sign-in is checked
1745
+ assert.deepEqual(resolved.map((x) => x.status), ['converting', 'done']);
1746
+ assert.deepEqual(resolved[0].patch, { app_id: 3, app_slug: 'app-3' });
1747
+ assert.equal(resolved[1].patch.app_slug, 'app-3');
1748
+ assert.equal(resolved[1].patch.github_code, null, 'a done row holds no re-convertible code (F23)');
1747
1749
  assert.ok(events.includes('oauth-manifest-done'));
1748
1750
  });
1749
1751
 
1752
+ await ta('the sign-in check reads the JSON health answer, never the root /healthz "ok" (task 1004577)', async () => {
1753
+ // The REAL answers of the two endpoints (src/bongos/serve-internal.js answers GET /healthz
1754
+ // with a plain "ok"; src/bongos/routes/healthz.js answers /api/bongos/healthz with JSON).
1755
+ // The old probe hit /healthz, never parsed, and recorded every working setup as FAILED.
1756
+ const real = (cmd) => (/\/api\/bongos\/healthz/.test(cmd) ? '{"ok":true,"auth_configured":true}'
1757
+ : /\/healthz/.test(cmd) ? 'ok' : '');
1758
+ const inst = { ...coTenant, domain: 'mercury.example.com' };
1759
+ const probes = [];
1760
+ const deps = {
1761
+ apply: true, privileged: false, log: () => {}, sleep: async () => {},
1762
+ convertManifestCode: async () => ({ clientId: 'Iv1.abcdef0123456789', clientSecret: 'z'.repeat(40), appId: 7, appSlug: 'mercury-app' }),
1763
+ exec: (cmd) => { if (/healthz/.test(cmd)) probes.push(cmd); return { ok: true, stdout: real(cmd) }; },
1764
+ writeFile: () => ({ ok: true }),
1765
+ };
1766
+ const r = await P.placeManifestCreds(inst, { github_code: 'C', attempts: 1 }, deps);
1767
+ assert.equal(r.ok, true, 'a project whose JSON health says auth_configured is verified');
1768
+ assert.ok(probes.length >= 1 && probes.every((c) => /\/api\/bongos\/healthz/.test(c)), 'only the JSON endpoint is asked');
1769
+ assert.equal(P.authHealthzCmd({ port: 4100 }), 'curl -sS --max-time 5 http://127.0.0.1:4100/api/bongos/healthz');
1770
+ // and a plain "ok" is no verdict either way: it never reads as configured
1771
+ const plain = await P.placeManifestCreds(inst, { github_code: 'C', attempts: 1 }, { ...deps, exec: () => ({ ok: true, stdout: 'ok' }) });
1772
+ assert.equal(plain.ok, false, 'a bare "ok" never confirms sign-in');
1773
+ const oauthSecret = require('../scripts/gds/oauth-secret.js');
1774
+ assert.deepEqual(oauthSecret.parseHealthzAuth('ok'), { ok: false, authConfigured: false, parseError: true });
1775
+ assert.match(readFileSync(new URL('../scripts/gds/oauth-secret.js', import.meta.url), 'utf8'), /127\.0\.0\.1:\$\{port\}\/api\/bongos\/healthz/, 'the manual placement probes the same JSON answer');
1776
+ });
1777
+
1778
+ await ta('the slug is recorded only AFTER the credentials are placed (task 1004649)', async () => {
1779
+ const order = [];
1780
+ const inst = { ...coTenant, domain: 'mercury.example.com' };
1781
+ await P.placeManifestCreds(inst, { github_code: 'C', attempts: 1 }, {
1782
+ apply: true, privileged: false, log: () => {}, sleep: async () => {},
1783
+ convertManifestCode: async () => ({ clientId: 'Iv1.abcdef0123456789', clientSecret: 'z'.repeat(40), appId: 7, appSlug: 'mercury-app' }),
1784
+ exec: (cmd) => { if (/systemctl restart/.test(cmd)) order.push('restart'); return { ok: true, stdout: '{"auth_configured":true}' }; },
1785
+ writeFile: (p) => { if (/web\.env$/.test(p)) order.push('write'); return { ok: true }; },
1786
+ onCredsPlaced: async (c) => { order.push(`slug:${c.appSlug}`); },
1787
+ });
1788
+ assert.deepEqual(order, ['write', 'restart', 'slug:mercury-app'], 'the first tick means the keys are on the project');
1789
+ });
1790
+
1750
1791
  await ta('a SPENT code is never re-converted — terminal error, no code_received reset (F23)', async () => {
1751
1792
  // A stateful one-row store: claimNext honors status, resolve writes it back —
1752
1793
  // exactly the reset loop that used to re-convert a burned code on the next tick.
@@ -1804,11 +1845,12 @@ await ta('a sign-in check that never confirms still records WHICH App GitHub mad
1804
1845
  recordEvent: async () => {},
1805
1846
  },
1806
1847
  });
1807
- assert.equal(resolves.length, 1);
1808
- assert.equal(resolves[0].status, 'error');
1809
- assert.equal(resolves[0].patch.app_slug, 'mercury-cloudbongos-com', 'the error row names the App');
1810
- assert.equal(resolves[0].patch.app_id, 5189633);
1811
- assert.equal(resolves[0].patch.github_code, null, 'the burned code is still scrubbed');
1848
+ // the slug is recorded at placement (task 1004649), then the row settles as an error
1849
+ assert.deepEqual(resolves.map((x) => x.status), ['converting', 'error']);
1850
+ const settled = resolves[1];
1851
+ assert.equal(settled.patch.app_slug, 'mercury-cloudbongos-com', 'the error row names the App');
1852
+ assert.equal(settled.patch.app_id, 5189633);
1853
+ assert.equal(settled.patch.github_code, null, 'the burned code is still scrubbed');
1812
1854
  });
1813
1855
 
1814
1856
  await ta('a failure BEFORE GitHub handed over credentials claims no App (task 1004572)', async () => {
@@ -39,8 +39,18 @@ branding.branding = () => ({ ...realBranding(), identity: { ...(realBranding().i
39
39
  const api = require(`${ROOT}/src/module-api.js`);
40
40
  api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
41
41
 
42
+ // The verified-facts read (task 1004649) is the one DB query the github-setup routes make;
43
+ // stub it on the cached module like the data-access fns above.
44
+ const githubSetup = require(`${ROOT}/modules/provisioning/github-setup.js`);
45
+ let facts = {};
46
+ const reported = [];
47
+ githubSetup.githubSetupFacts = async () => facts;
48
+ const prevRecord = provisioning.recordEvent;
49
+ provisioning.recordEvent = async (_db, e) => { reported.push(e); return prevRecord(); };
50
+
42
51
  const express = require('express');
43
52
  const provisioningRoutes = require(`${ROOT}/modules/provisioning/routes/provisioning.js`);
53
+ const githubSetupRoutes = require(`${ROOT}/modules/provisioning/routes/github-setup.js`);
44
54
  const app = express();
45
55
  app.use(express.json());
46
56
  // res.fail is mounted globally by the core error middleware in production; add a minimal
@@ -53,6 +63,7 @@ app.use((_req, res, next) => {
53
63
  next();
54
64
  });
55
65
  app.use('/api/bongos', provisioningRoutes());
66
+ app.use('/api/bongos', githubSetupRoutes());
56
67
 
57
68
  const server = app.listen(0);
58
69
  await new Promise((r) => server.once('listening', r));
@@ -70,6 +81,7 @@ console.log('POST /provisioning/instances/:id/github-app (mint the one-click lin
70
81
  t('manifest.redirect_url is the MOTHERSHIP callback', j.manifest && j.manifest.redirect_url === 'https://cloudbongos.com/api/bongos/provisioning/github-app/callback', j.manifest && j.manifest.redirect_url);
71
82
  t('manifest.callback_urls are the INSTANCE sign-in (both prefixes)', j.manifest && j.manifest.callback_urls[0] === 'https://mercury.example.com/api/gds/auth/web/callback' && j.manifest.callback_urls[1] === 'https://mercury.example.com/api/bongos/auth/web/callback');
72
83
  t('manifest.name is the instance domain (unique + matches the consent-screen redirect) within 34 chars', j.manifest && j.manifest.name === 'mercury.example.com' && j.manifest.name.length <= 34, j.manifest && j.manifest.name);
84
+ t('manifest.setup_url sends an install back to the MOTHERSHIP setup route (task 1004649)', j.manifest && j.manifest.setup_url === 'https://cloudbongos.com/api/bongos/provisioning/github-app/setup' && j.manifest.setup_on_update === true, j.manifest && j.manifest.setup_url);
73
85
  t('org_url derived from the target_ref owner (acme)', j.org_url === `https://github.com/organizations/acme/settings/apps/new?state=${j.state}`, j.org_url);
74
86
  t('the attempt row was persisted with the minted state', !!(stored && stored.instanceId === 42 && stored.state === j.state && stored.requestedBy === 7));
75
87
  }
@@ -93,15 +105,83 @@ console.log('\nGET /provisioning/github-app/callback (the public GitHub redirect
93
105
  {
94
106
  const res = await fetch(`${base}/provisioning/github-app/callback?code=GHCODE123&state=GOODSTATE`);
95
107
  const html = await res.text();
96
- t('good state → 200 text/html hand-back page', res.status === 200 && /text\/html/.test(res.headers.get('content-type') || '') && /GitHub app created/.test(html) && /__CB_SETUP__/.test(html) && html.includes('GOODSTATE'), `status=${res.status}`);
97
- t('the page names the instance host', /mercury\.example\.com/.test(html));
98
- // task 1004548: the founder no longer waits here — the page hands them back to the
99
- // checklist, which shows the wait. A manual way back is always on the page.
100
- t('carries a plain "back to my checklist" link (works without script)', /<a class="btn" id="back" href="\/projects\?view=new">Back to my checklist/.test(html));
108
+ t('good state → 200 text/html one-trip page', res.status === 200 && /text\/html/.test(res.headers.get('content-type') || '') && /GitHub app created/.test(html) && /__CB_SETUP__/.test(html) && html.includes('GOODSTATE'), `status=${res.status}`);
109
+ // task 1004649: GitHub's half of the approval is done — the SAME tab goes on to the install
110
+ t('says the next step is installing it on the repo', /Next, install it on your repo/.test(html));
111
+ t('carries a plain way back to the project (works without script)', /<a class="btn" id="back" href="\/projects\?view=new">Back to my project/.test(html));
101
112
  t('no progress stepper or countdown left on the page', !/id="steps"/.test(html) && !/Placing credentials/.test(html) && !/location\.href=target/.test(html));
102
113
 
103
- // Run the served script itself (not a copy) against a minimal DOM, once as the
104
- // wizard's pop-up and once as a same-tab return, and watch what it does.
114
+ // Run the served INSTALL script (not a copy): it polls the real status shape and goes on.
115
+ const installScript = (html.match(/<script>\s*(\(function\(\)\{[\s\S]*?)<\/script>\s*<\/body>/) || [])[1];
116
+ t('the one-trip page ships its client script', typeof installScript === 'string' && /installations|install_url/.test(installScript));
117
+ async function runInstall(statuses, repoAnswer) {
118
+ const vm = await import('node:vm');
119
+ const back = { href: '/projects?view=new' }, note = { textContent: '' };
120
+ const timers = [], fetched = [];
121
+ const calls = { replaced: null };
122
+ const sandbox = {
123
+ __CB_SETUP__: { state: 'GOODSTATE', domain: 'mercury.example.com', back: '/projects?view=new' },
124
+ document: { getElementById: (id) => (id === 'back' ? back : id === 'note' ? note : null) },
125
+ location: { replace: (u) => { calls.replaced = u; } },
126
+ setTimeout: (fn) => { timers.push(fn); return timers.length; },
127
+ encodeURIComponent, String,
128
+ fetch: async (url) => {
129
+ fetched.push(url);
130
+ if (/api\.github\.com\/repos\//.test(url)) return repoAnswer ? { ok: true, json: async () => repoAnswer } : { ok: false, json: async () => ({}) };
131
+ const body = statuses.length > 1 ? statuses.shift() : statuses[0];
132
+ return { ok: true, json: async () => body };
133
+ },
134
+ };
135
+ sandbox.window = sandbox;
136
+ vm.runInNewContext(installScript, sandbox);
137
+ for (let i = 0; i < 20 && !calls.replaced; i++) {
138
+ await new Promise((r) => setImmediate(r));
139
+ if (!calls.replaced && timers.length) timers.shift()();
140
+ }
141
+ return { ...calls, fetched, note: note.textContent };
142
+ }
143
+ // the REAL status route's answer for this attempt, before and after the runner named the app
144
+ const statusOf = async () => (await fetch(`${base}/provisioning/github-app/status?state=GOODSTATE`)).json();
145
+ const prevGet = provisioning.getOAuthManifestByState;
146
+ provisioning.getOAuthManifestByState = async () => ({ id: 99, instance_id: 42, state: 'GOODSTATE', status: 'converting', attempts: 1, expires_at: new Date(Date.now() + 600000).toISOString() });
147
+ const waiting = await statusOf();
148
+ provisioning.getOAuthManifestByState = async () => ({ id: 99, instance_id: 42, state: 'GOODSTATE', status: 'converting', attempts: 1, app_id: 5, app_slug: 'mercury-app', expires_at: new Date(Date.now() + 600000).toISOString() });
149
+ const named = await statusOf();
150
+ provisioning.getOAuthManifestByState = prevGet;
151
+ t('the real status: no install link until the app is named, then the one-trip link with the state', waiting.install_url === null && named.install_url === 'https://github.com/apps/mercury-app/installations/new?state=GOODSTATE' && named.repo === 'acme/mercury', JSON.stringify({ waiting: waiting.install_url, named: named.install_url }));
152
+ const narrowed = await runInstall([waiting, named], { id: 777, owner: { id: 42424 } });
153
+ t('waits for the app, then sends the founder to install it on THEIR repo, preselected', narrowed.replaced === 'https://github.com/apps/mercury-app/installations/new/permissions?state=GOODSTATE&suggested_target_id=42424&repository_ids[]=777', JSON.stringify(narrowed));
154
+ const plain = await runInstall([named], null);
155
+ t('a repo GitHub will not describe (private) falls back to the plain install page', plain.replaced === 'https://github.com/apps/mercury-app/installations/new?state=GOODSTATE', JSON.stringify(plain));
156
+ const doneAlready = await runInstall([{ ...named, checklist: { ...named.checklist, install: 'done' } }], null);
157
+ t('already installed → straight back to the checklist', doneAlready.replaced === '/projects?view=new#ghstate=GOODSTATE', JSON.stringify(doneAlready));
158
+ const stopped = await runInstall([{ ...named, status: 'error' }], null);
159
+ t('a stopped attempt → back to the checklist, which says what stopped', stopped.replaced === '/projects?view=new#ghstate=GOODSTATE', JSON.stringify(stopped));
160
+ }
161
+
162
+ console.log('\nGET /provisioning/github-app/setup (the App\'s setup_url — task 1004649):');
163
+ {
164
+ reported.length = 0;
165
+ const prevGet = provisioning.getOAuthManifestByState;
166
+ provisioning.getOAuthManifestByState = async (_db, s) => (s === 'abcdef0123456789abcdef' ? { id: 99, instance_id: 42, requested_by: 7, state: s, status: 'done', app_slug: 'mercury-app' } : null);
167
+ const res = await fetch(`${base}/provisioning/github-app/setup?installation_id=123456&setup_action=install&state=abcdef0123456789abcdef`);
168
+ const html = await res.text();
169
+ t('a known state → 200 hand-back page to the checklist, carrying the attempt', res.status === 200 && /Installed on GitHub/.test(html) && html.includes('abcdef0123456789abcdef') && /Back to my checklist/.test(html), `status=${res.status}`);
170
+ t('records only that an install was REPORTED (the id is unverified), for the runner to check', reported.length === 1 && reported[0].event === 'github-app-install-reported' && /installation 123456, unverified/.test(reported[0].detail), JSON.stringify(reported));
171
+ reported.length = 0;
172
+ const forged = await fetch(`${base}/provisioning/github-app/setup?installation_id=1;rm%20-rf&state=abcdef0123456789abcdef`);
173
+ t('a non-numeric installation id never reaches the record', forged.status === 200 && reported.length === 1 && !/rm/.test(reported[0].detail), JSON.stringify(reported));
174
+ reported.length = 0;
175
+ const unknown = await fetch(`${base}/provisioning/github-app/setup?installation_id=1&state=NOPE`);
176
+ const uh = await unknown.text();
177
+ t('an unknown or missing state → the same way back, nothing recorded, no oracle', unknown.status === 200 && /Back to my projects/.test(uh) && reported.length === 0, `status=${unknown.status}`);
178
+ provisioning.getOAuthManifestByState = prevGet;
179
+ }
180
+ {
181
+ // the plain hand-back page (now the setup_url's) still behaves as before (task 1004548)
182
+ const callbackPage = require(`${ROOT}/modules/provisioning/routes/callback-page.js`).callbackPage;
183
+ const html = callbackPage({ ok: true, title: 'Installed on GitHub', state: 'GOODSTATE', domain: 'mercury.example.com' });
184
+ t('carries a plain "back to my checklist" link (works without script)', /<a class="btn" id="back" href="\/projects\?view=new">Back to my checklist/.test(html));
105
185
  const script = (html.match(/<script>\s*(\(function\(\)\{[\s\S]*?)<\/script>\s*<\/body>/) || [])[1];
106
186
  t('the hand-back page ships the client script', typeof script === 'string' && script.length > 100);
107
187
  async function runCallback({ opener }) {
@@ -155,7 +235,13 @@ console.log('\nGET /provisioning/github-app/status (the page poll → auto-open
155
235
  : null);
156
236
  const res = await fetch(`${base}/provisioning/github-app/status?state=GOODSTATE`);
157
237
  const j = await res.json();
158
- t("status 'done' → ready:true + terminal:true", res.status === 200 && j.ready === true && j.terminal === true, JSON.stringify(j));
238
+ // task 1004649: sign-in verified is not the end of the trip — the install still is ahead
239
+ t("status 'done', install not verified → ready:true, NOT terminal, the install still ahead", res.status === 200 && j.ready === true && j.terminal === false && j.checklist && j.checklist.sign_in === 'done' && j.checklist.install === 'todo', JSON.stringify(j));
240
+ facts = { installedAt: new Date().toISOString() };
241
+ provisioning.getOAuthManifestByState = async () => ({ id: 99, instance_id: 42, state: 'GOODSTATE', status: 'done', app_slug: 'mercury-app', resolved_at: new Date(Date.now() - 3 * 3600 * 1000).toISOString(), expires_at: new Date().toISOString() });
242
+ const j2 = await (await fetch(`${base}/provisioning/github-app/status?state=GOODSTATE`)).json();
243
+ t('installed + sign-in verified, the runner done checking → connected, terminal, no install link', j2.checklist.connected === true && j2.checklist.device_flow === 'later' && j2.terminal === true && j2.install_url === null, JSON.stringify(j2));
244
+ facts = {};
159
245
  provisioning.getOAuthManifestByState = prev;
160
246
  }
161
247
  {