@bongos/core 1.21.93 → 1.21.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.bongos-core.json +63 -38
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +9 -2
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/api/openapi.json +112 -9
  8. package/docs/api-reference.md +6 -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 +15 -0
  12. package/docs/module-api-changelog.md +4 -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 +83 -8
  18. package/modules/provisioning/routes/github-setup.js +221 -0
  19. package/modules/provisioning/routes/provisioning.js +11 -45
  20. package/modules/public-landing/public/projects.html +207 -66
  21. package/package-lock.json +2 -2
  22. package/package.json +1 -1
  23. package/release-notes.json +12 -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_handback_any_device.mjs +340 -0
  29. package/tests/github_one_trip.mjs +297 -0
  30. package/tests/projects_hub.mjs +6 -2
  31. package/tests/projects_hub_dns_ready.mjs +2 -1
  32. package/tests/projects_hub_pre_uat.mjs +17 -9
  33. package/tests/provision.mjs +51 -9
  34. package/tests/provisioning_callback_page.mjs +3 -2
  35. package/tests/provisioning_github_app_routes.mjs +103 -12
  36. package/tests/wizard_draft_resume.mjs +4 -0
  37. package/tests/wizard_github_feedback.mjs +9 -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
+ });
@@ -355,8 +355,12 @@ test('C8 — the wizard polls stop dead off the new view, like the sky off the m
355
355
  // guard — merged unguarded they would poll a hidden view for up to 33
356
356
  // minutes. Both ticks must consult the same gate the sky consults.
357
357
  assert.match(SCRIPT, /function wizPollActive\(\)/, 'the wizard needs its own stage guard');
358
- assert.match(SCRIPT, /!document\.hidden && currentView === 'new'/,
359
- 'the guard is hidden-tab aware and view-scoped');
358
+ assert.match(SCRIPT, /function wizPollActive\(\) \{\s*return currentView === 'new';\s*\}/,
359
+ 'the guard is view-scoped');
360
+ // a HIDDEN tab slows the wizard's polls instead of stopping them (task 1004650): that
361
+ // is exactly when the founder is on GitHub. tests/github_handback_any_device.mjs runs it.
362
+ assert.match(SCRIPT, /return document\.hidden \? Math\.max\(ms, HIDDEN_POLL_MS\) : ms;/,
363
+ 'a hidden tab keeps checking at the slow pace');
360
364
  const gatedTicks = (SCRIPT.match(/if \(!wizPollActive\(\)\)/g) || []).length;
361
365
  assert.ok(gatedTicks >= 2, `both wizard ticks (progress + manifest) must check the guard, found ${gatedTicks}`);
362
366
  assert.match(SCRIPT, /if \(v === 'new'\) wizard\.enter\(focus\); else wizard\.leave\(\);/,
@@ -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 () => {
@@ -50,8 +50,9 @@ test('the hand-back variant escapes the title + domain it interpolates', () => {
50
50
  assert.doesNotMatch(html, /Setting up <your> instance/, 'no raw title anywhere on the page');
51
51
  assert.doesNotMatch(html, /https:\/\/evil"\.example\.com/, 'no unescaped domain in an href');
52
52
  // it IS the hand-back variant (task 1004548): the way back + the config block the client script reads
53
- assert.match(html, /id="back" href="\/projects\?view=new"/);
54
- assert.match(html, /__CB_SETUP__=\{"state":"GOODSTATE","domain":"evil\\"\.example\.com","back":"\/projects\?view=new"\}/);
53
+ // the way back is the return route, which names the project on the server (task 1004650)
54
+ assert.ok(html.includes('id="back" href="/api/bongos/provisioning/github-app/return?state=GOODSTATE"'));
55
+ assert.match(html, /__CB_SETUP__=\{"state":"GOODSTATE","domain":"evil\\"\.example\.com","back":"\/api\/bongos\/provisioning\/github-app\/return\?state=GOODSTATE"\}/);
55
56
  });
56
57
 
57
58
  test('the message variant escapes the title but passes server-authored body HTML through', () => {