@bongos/core 1.21.94 → 1.21.96

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,378 @@
1
+ // tests/github_org_account.mjs — a personal or an organisation GitHub account for the
2
+ // project's repo and its sign-in app (task 1004651).
3
+ //
4
+ // Before: the page always posted `{}` to mint the app, so the server's org support was
5
+ // dead and every app landed on the founder's personal account; and the new repo could only
6
+ // be made on the personal account, so an organisation-owned project needed the manual
7
+ // "create it yourself" fallback.
8
+ //
9
+ // What this pins:
10
+ // 1. the personal path is UNCHANGED: the page posts `{}` to mint the app and `{name}` to
11
+ // make the repo, and GitHub is asked for /settings/apps/new and POST /user/repos;
12
+ // 2. choosing an organisation builds the org manifest URL and creates the repo with
13
+ // POST /orgs/{org}/repos, and the app's settings link follows the organisation;
14
+ // 3. an organisation the founder does not own is shown with the reason and cannot be
15
+ // picked, and the page refuses to start a trip for it;
16
+ // 4. a bad organisation name is refused by the server before it reaches GitHub;
17
+ // 5. GitHub's org refusals never come back as a 403 (the page reads that as "sign in").
18
+ //
19
+ // The whole path runs for real: the REAL routes (core GitHub router + provisioning mint
20
+ // route), over HTTP, with GitHub faked at the fetch boundary, feeding the REAL page code
21
+ // (sliced out of projects.html and run in a vm). DB-free: the token read and the
22
+ // provisioning data-access fns are stubbed on their cached modules.
23
+ //
24
+ // Run: node --test tests/github_org_account.mjs
25
+
26
+ import assert from 'node:assert/strict';
27
+ import { test, before, after } from 'node:test';
28
+ import fs from 'node:fs';
29
+ import path from 'node:path';
30
+ import vm from 'node:vm';
31
+ import { createRequire } from 'node:module';
32
+ import { fileURLToPath } from 'node:url';
33
+
34
+ process.env.BUILDER_SECRET_KEY = process.env.BUILDER_SECRET_KEY || 'ab'.repeat(32);
35
+
36
+ const require = createRequire(import.meta.url);
37
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
38
+ // CRLF on a Windows checkout — a vm cannot run a slice with stray \r in it
39
+ const HUB = fs.readFileSync(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
40
+ const SCRIPT = HUB.slice(HUB.lastIndexOf('<script>'), HUB.lastIndexOf('</script>'));
41
+
42
+ // ── fake GitHub, at the fetch boundary ──────────────────────────────────────────────
43
+ const realFetch = globalThis.fetch;
44
+ let gh = {}; // path → (init) => [status, body]
45
+ const ghCalls = [];
46
+ function ghAnswer(url, init = {}) {
47
+ const u = new URL(url);
48
+ const key = `${(init.method || 'GET').toUpperCase()} ${u.pathname}`;
49
+ ghCalls.push({ key, url, body: init.body ? JSON.parse(init.body) : null });
50
+ const h = gh[key];
51
+ const [status, body] = h ? h(init) : [404, { message: 'Not Found' }];
52
+ return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
53
+ }
54
+ globalThis.fetch = async (url, init) => (String(url).startsWith('https://api.github.com/') ? ghAnswer(String(url), init) : realFetch(url, init));
55
+
56
+ // ── the signed-in founder and their stored repo token ───────────────────────────────
57
+ const db = require(path.join(ROOT, 'src/bongos/db.js'));
58
+ db.getGithubToken = async () => ({ token: 'tok', scope: 'read:user,repo' });
59
+ db.deleteGithubToken = async () => {};
60
+ const auth = require(path.join(ROOT, 'src/bongos/auth.js'));
61
+ auth.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'founder' }; next(); };
62
+ const api = require(path.join(ROOT, 'src/module-api.js'));
63
+ api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'founder' }; next(); };
64
+
65
+ // ── the provisioning world: one project, its attempts recorded ──────────────────────
66
+ const provisioning = require(path.join(ROOT, 'modules/provisioning/provisioning.js'));
67
+ let targetRef = 'founder/mercury';
68
+ const events = [];
69
+ provisioning.getInstanceById = async () => ({ id: 42, owner_builder_id: 7, slug: 'mercury', domain: 'mercury.example.com', target_ref: targetRef, status: 'requested' });
70
+ provisioning.createOAuthManifest = async (_db, row) => ({ id: 99, ...row });
71
+ provisioning.recordEvent = async (_db, e) => { events.push(e); };
72
+ const branding = require(path.join(ROOT, 'src/branding.js'));
73
+ const realBranding = branding.branding;
74
+ branding.branding = () => ({ ...realBranding(), identity: { ...(realBranding().identity || {}), productName: 'Cloud Bongos' }, domains: { publicOrigin: 'https://cloudbongos.com' } });
75
+ const GS = require(path.join(ROOT, 'modules/provisioning/github-setup.js'));
76
+ const githubRepos = require(path.join(ROOT, 'src/bongos/github-repos.js'));
77
+
78
+ const express = require('express');
79
+ const { attachFail } = require(path.join(ROOT, 'src/bongos/middleware/error-envelope.js'));
80
+ const buildGithubRouter = require(path.join(ROOT, 'src/bongos/routes/github.js'));
81
+ const provisioningRoutes = require(path.join(ROOT, 'modules/provisioning/routes/provisioning.js'));
82
+ let server, origin;
83
+ before(async () => {
84
+ const app = express();
85
+ app.use(express.json());
86
+ app.use(attachFail);
87
+ app.use('/api/bongos', buildGithubRouter());
88
+ app.use('/api/bongos', provisioningRoutes());
89
+ server = app.listen(0);
90
+ await new Promise((r) => server.once('listening', r));
91
+ origin = `http://127.0.0.1:${server.address().port}`;
92
+ });
93
+ after(() => { server.close(); globalThis.fetch = realFetch; });
94
+
95
+ // GitHub's answers for a founder who owns acme-studio and is only a member of bigcorp
96
+ function memberships() {
97
+ return {
98
+ 'GET /user/memberships/orgs': () => [200, [
99
+ { role: 'member', state: 'active', organization: { login: 'bigcorp' } },
100
+ { role: 'admin', state: 'active', organization: { login: 'acme-studio' } },
101
+ ]],
102
+ };
103
+ }
104
+
105
+ // ── the page code, sliced (each boundary asserted below) ────────────────────────────
106
+ function region(startMark, endMark) {
107
+ const s = SCRIPT.indexOf(startMark);
108
+ const e = s < 0 ? -1 : SCRIPT.indexOf(endMark, s);
109
+ return s > 0 && e > s ? SCRIPT.slice(s, e) : null;
110
+ }
111
+ const PICKER = region(" /* ── whose GitHub account: mine or an organisation's (task 1004651) ──", " var repoPick = acctPicker('ra', {");
112
+ const ENSURE_REPO = region(' function ensureFreshRepo() {', " $('doCreate').addEventListener('click'");
113
+ const NOTE_ORG = region(" /* the organisation the server says this attempt's app is on: change-only */", ' function startManifest(btn) {');
114
+ const START = region(' function startManifest(btn) {', ' /* the "start again" offer once a wait has clearly gone wrong');
115
+ const SETTINGS = region(' /* the organisation that owns the app, when the founder made it on one (task 1004651) */', ' /* oauth-app — the ADR 0133 one-click GitHub-App manifest flow');
116
+
117
+ test('the page regions are still findable (a rename must fail loudly, not test nothing)', () => {
118
+ for (const [name, r] of Object.entries({ PICKER, ENSURE_REPO, NOTE_ORG, START, SETTINGS })) assert.ok(r, `${name} region not found`);
119
+ assert.match(PICKER, /function acctPicker\(p, opts\)/);
120
+ assert.doesNotMatch(PICKER, /innerHTML/, 'organisation names are DOM text, never markup');
121
+ });
122
+
123
+ // A miniature DOM: just what the picker, the repo create and the mint touch.
124
+ class El {
125
+ constructor(id, tag = 'div') { this.id = id; this.tagName = tag; this.hidden = false; this.textContent = ''; this.className = ''; this.children = []; this._v = ''; this.listeners = {}; this.disabled = false; }
126
+ addEventListener(ev, fn) { (this.listeners[ev] = this.listeners[ev] || []).push(fn); }
127
+ fire(ev, extra = {}) { (this.listeners[ev] || []).forEach((fn) => fn({ preventDefault() {}, ...extra })); }
128
+ focus() { this.focused = true; }
129
+ appendChild(c) { this.children.push(c); return c; }
130
+ removeChild(c) { this.children = this.children.filter((x) => x !== c); }
131
+ get firstChild() { return this.children[0] || null; }
132
+ get value() { return this._v; }
133
+ set value(v) {
134
+ // a select takes only a value one of its options has, as a browser does
135
+ if (this.tagName === 'select') this._v = this.children.some((o) => o.value === v) ? v : '';
136
+ else this._v = v;
137
+ }
138
+ submit() { this.submitted = true; }
139
+ remove() {}
140
+ }
141
+ function opt(value) { const o = new El(null, 'option'); o.value = value; return o; }
142
+
143
+ function page({ state = {}, live = {} } = {}) {
144
+ const els = {};
145
+ const $ = (id) => {
146
+ if (!els[id]) {
147
+ const tag = /(Sel|Org)$/.test(id) ? 'select' : 'div';
148
+ els[id] = new El(id, tag);
149
+ if (/Sel$/.test(id)) { els[id].children.push(opt('personal'), opt('org')); els[id]._v = 'personal'; }
150
+ }
151
+ return els[id];
152
+ };
153
+ const posted = [];
154
+ const forms = [];
155
+ const sandbox = {
156
+ API: '/api/bongos', $, JSON, String, Promise, encodeURIComponent, Error,
157
+ document: {
158
+ body: { appendChild() {} },
159
+ createElement: (tag) => { const e = new El(null, tag); if (tag === 'form') forms.push(e); return e; },
160
+ },
161
+ window: { open: () => ({ close() {} }), location: { assign() {} } },
162
+ fetch: (p, init) => { posted.push({ p, init }); return realFetch(origin + p, { method: (init && init.method) || 'GET', headers: init && init.headers, body: init && init.body }); },
163
+ state: { repo: 'mercury', mode: 'greenfield', owner: 'founder', ...state },
164
+ live: { manifest: null, manifestMsg: '', manifestFailure: null, appSlug: null, appOrg: null, setup: null, installUrl: null, ...live },
165
+ repoAccess: true, instanceId: 42, me: { github_login: 'founder' },
166
+ hopForRepoAccess: () => false, syncHome() {}, saveDraft() {},
167
+ rememberManifest() {}, renderSteps() {}, pollManifest() {},
168
+ };
169
+ sandbox.manifestStartedAt = 0; sandbox.manifestStale = false;
170
+ vm.createContext(sandbox);
171
+ vm.runInContext([PICKER, ENSURE_REPO, NOTE_ORG, START, SETTINGS, "var appPick = acctPicker('aa', { needsOwner: true });"].join('\n'), sandbox);
172
+ return { sb: sandbox, els, posted, forms, settle: () => new Promise((r) => setTimeout(r, 60)) };
173
+ }
174
+ async function settled(p, n = 6) { for (let i = 0; i < n; i++) await p.settle(); }
175
+
176
+ // ── 1. the personal path is unchanged ───────────────────────────────────────────────
177
+ test('personal: the page mints with an empty body, and GitHub is asked for the personal app page', async () => {
178
+ gh = memberships(); events.length = 0;
179
+ const p = page();
180
+ p.sb.appPick.load(null);
181
+ const btn = new El('btn');
182
+ p.sb.startManifest(btn);
183
+ await settled(p);
184
+ const mint = p.posted.find((x) => /\/github-app$/.test(x.p));
185
+ assert.equal(mint.init.body, '{}', 'byte-for-byte what the page always sent');
186
+ assert.equal(p.forms.length, 1);
187
+ assert.match(p.forms[0].action, /^https:\/\/github\.com\/settings\/apps\/new\?state=[0-9a-f]{64}$/);
188
+ assert.ok(!events.some((e) => e.event === GS.SETUP_EVENTS.appOrg), 'no organisation is recorded for a personal app');
189
+ assert.ok(!p.posted.some((x) => /\/github\/orgs/.test(x.p)), 'GitHub is not even asked for organisations');
190
+ });
191
+
192
+ test('personal: the new repo is made with {name} and POST /user/repos, as before', async () => {
193
+ gh = { 'POST /user/repos': () => [201, { full_name: 'founder/mercury', html_url: 'https://github.com/founder/mercury' }] };
194
+ ghCalls.length = 0;
195
+ const p = page();
196
+ await p.sb.ensureFreshRepo();
197
+ const sent = p.posted.find((x) => x.p === '/api/bongos/github/repos');
198
+ assert.equal(sent.init.body, JSON.stringify({ name: 'mercury' }));
199
+ assert.deepEqual(ghCalls.map((c) => c.key), ['POST /user/repos']);
200
+ assert.deepEqual(ghCalls[0].body, { name: 'mercury', private: true, auto_init: false });
201
+ assert.equal(p.sb.state.owner, 'founder');
202
+ });
203
+
204
+ // ── 2. an organisation ──────────────────────────────────────────────────────────────
205
+ test('organisation: listed from GitHub; one the founder only belongs to is shown with the reason and cannot be picked', async () => {
206
+ gh = memberships();
207
+ const p = page();
208
+ p.sb.appPick.load(null);
209
+ p.els.aaSel.value = 'org'; p.els.aaSel.fire('change');
210
+ await settled(p);
211
+ const opts = p.els.aaOrg.children.map((o) => ({ v: o.value, t: o.textContent, off: o.disabled }));
212
+ assert.deepEqual(opts, [
213
+ { v: 'acme-studio', t: 'acme-studio', off: false },
214
+ { v: 'bigcorp', t: 'bigcorp (you’re a member, not an owner)', off: true },
215
+ { v: '__other', t: 'Another organisation…', off: false },
216
+ ]);
217
+ assert.equal(p.els.aaOrg.value, 'acme-studio', 'the one they own is picked');
218
+ assert.equal(p.els.aaOrgField.hidden, false);
219
+ // a refused one forced in (an old browser ignoring disabled) still cannot go ahead
220
+ p.els.aaOrg._v = 'bigcorp'; p.els.aaOrg.fire('change');
221
+ assert.equal(p.sb.appPick.value().ok, false);
222
+ assert.match(p.els.aaNote.textContent, /member of bigcorp, not an owner/);
223
+ const btn = new El('btn');
224
+ p.sb.startManifest(btn);
225
+ await settled(p, 2);
226
+ assert.ok(!p.posted.some((x) => /\/github-app$/.test(x.p)), 'no link is minted for an organisation they cannot make apps on');
227
+ });
228
+
229
+ test('organisation: the app is made on it — the org manifest URL, the org recorded, its settings linked there', async () => {
230
+ gh = memberships(); events.length = 0;
231
+ const p = page();
232
+ p.sb.appPick.load('acme-studio');
233
+ await settled(p);
234
+ assert.equal(p.sb.appPick.value().org, 'acme-studio');
235
+ p.sb.startManifest(new El('btn'));
236
+ await settled(p);
237
+ const mint = p.posted.find((x) => /\/github-app$/.test(x.p));
238
+ assert.deepEqual(JSON.parse(mint.init.body), { account: 'org', org: 'acme-studio' });
239
+ assert.match(p.forms[0].action, /^https:\/\/github\.com\/organizations\/acme-studio\/settings\/apps\/new\?state=[0-9a-f]{64}$/);
240
+ assert.deepEqual(events.filter((e) => e.event === GS.SETUP_EVENTS.appOrg).map((e) => e.detail), ['acme-studio']);
241
+ // the page now knows whose app it is, and links its settings under the organisation
242
+ p.sb.live.appSlug = 'mercury-app';
243
+ assert.equal(vm.runInContext('appSettingsUrl()', p.sb), 'https://github.com/organizations/acme-studio/settings/apps/mercury-app');
244
+ p.sb.live.appOrg = null;
245
+ assert.equal(vm.runInContext('appSettingsUrl()', p.sb), 'https://github.com/settings/apps/mercury-app', 'and under the founder for a personal app');
246
+ });
247
+
248
+ test('organisation: the new repo is made under it with POST /orgs/{org}/repos, and the project points at it', async () => {
249
+ gh = { 'POST /orgs/acme-studio/repos': () => [201, { full_name: 'acme-studio/mercury', html_url: 'https://github.com/acme-studio/mercury' }] };
250
+ ghCalls.length = 0;
251
+ const p = page({ state: { repoAcct: 'org', repoOrg: 'acme-studio' } });
252
+ await p.sb.ensureFreshRepo();
253
+ assert.deepEqual(ghCalls.map((c) => c.key), ['POST /orgs/acme-studio/repos']);
254
+ assert.deepEqual(ghCalls[0].body, { name: 'mercury', private: true, auto_init: false });
255
+ assert.equal(p.sb.state.owner, 'acme-studio');
256
+ assert.equal(p.sb.state.repoUrl, 'https://github.com/acme-studio/mercury');
257
+ assert.equal(p.sb.state.repoAutoCreated, true);
258
+ });
259
+
260
+ test('organisation: GitHub refusing the org repo comes back with its reason, never as "sign in again"', async () => {
261
+ const cases = [
262
+ [{ message: 'Although you appear to have the correct authorization credentials, the `acme-studio` organization has enabled OAuth App access restrictions' }, 'org_app_restricted', /Third-party access/],
263
+ [{ message: 'You need admin access to the organization before adding a repository to it.' }, 'org_forbidden', /didn't let your account|didn’t let your account/],
264
+ ];
265
+ for (const [body, code, words] of cases) {
266
+ gh = { 'POST /orgs/acme-studio/repos': () => [403, body] };
267
+ const res = await realFetch(`${origin}/api/bongos/github/repos`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'mercury', org: 'acme-studio' }) });
268
+ const j = await res.json();
269
+ assert.equal(res.status, 409, code);
270
+ assert.equal(j.error.code, code);
271
+ assert.match(j.error.message, words);
272
+ // the page takes the founder to the Code row with that reason, not to a sign-in hop
273
+ const p = page({ state: { repoAcct: 'org', repoOrg: 'acme-studio' } });
274
+ let hopped = false; p.sb.hopForRepoAccess = () => { hopped = true; return true; };
275
+ await assert.rejects(() => p.sb.ensureFreshRepo(), (e) => e.gotoHome === true && e.homeMsg === j.error.message);
276
+ assert.equal(hopped, false);
277
+ }
278
+ });
279
+
280
+ // ── 3. when GitHub will not show the founder's memberships ──────────────────────────
281
+ test('typed name: GitHub hides the memberships, so the founder types it and it is checked', async () => {
282
+ gh = {
283
+ 'GET /user/memberships/orgs': () => [403, { message: 'requires read:org' }],
284
+ 'GET /user/repos': () => [403, {}],
285
+ 'GET /user/memberships/orgs/acme-studio': () => [403, { message: 'requires read:org' }],
286
+ 'GET /orgs/acme-studio': () => [200, { login: 'acme-studio', type: 'Organization' }],
287
+ 'GET /orgs/someone': () => [200, { login: 'someone', type: 'User' }],
288
+ };
289
+ const p = page();
290
+ p.sb.appPick.load('');
291
+ await settled(p);
292
+ assert.deepEqual(p.els.aaOrg.children.map((o) => o.value), ['__other']);
293
+ assert.equal(p.els.aaTyped.hidden, false);
294
+ assert.match(p.els.aaNote.textContent, /didn’t show us your organisations/);
295
+ assert.equal(p.sb.appPick.value().ok, false, 'nothing to go ahead with until it is checked');
296
+ p.els.aaName.value = 'acme-studio'; p.els.aaCheck.fire('click');
297
+ await settled(p);
298
+ const v = p.sb.appPick.value();
299
+ assert.equal(v.org, 'acme-studio');
300
+ assert.equal(v.ok, true, 'an organisation GitHub did not give a role for is offered, said as such');
301
+ assert.match(p.els.aaNote.textContent, /didn't tell us your role in acme-studio/);
302
+ // a personal account's login is not an organisation
303
+ p.els.aaName.value = 'someone'; p.els.aaCheck.fire('click');
304
+ await settled(p);
305
+ assert.equal(p.sb.appPick.value().ok, false);
306
+ assert.match(p.els.aaNote.textContent, /an organisation called someone/);
307
+ });
308
+
309
+ // ── 4. bad names never reach GitHub ─────────────────────────────────────────────────
310
+ test('bad organisation names are refused by the server before GitHub is asked', async () => {
311
+ gh = {}; ghCalls.length = 0;
312
+ for (const bad of ['-acme', 'acme-', 'ac--me', 'a/b', 'acme studio', 'x'.repeat(40), '../etc']) {
313
+ const mint = await realFetch(`${origin}/api/bongos/provisioning/instances/42/github-app`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ account: 'org', org: bad }) });
314
+ assert.equal(mint.status, 400, `mint: ${bad}`);
315
+ const repo = await realFetch(`${origin}/api/bongos/github/repos`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'mercury', org: bad }) });
316
+ assert.equal(repo.status, 400, `repo: ${bad}`);
317
+ }
318
+ const check = await realFetch(`${origin}/api/bongos/github/orgs/${encodeURIComponent('ac--me')}`);
319
+ assert.equal(check.status, 400);
320
+ assert.deepEqual(ghCalls, [], 'GitHub was never called with any of them');
321
+ for (const bad of ['-acme', 'ac--me', 'acme_', '']) assert.equal(githubRepos.validGithubLogin(bad), false, bad);
322
+ for (const ok of ['a', 'acme-studio', 'A1-b2', 'x'.repeat(39)]) assert.equal(githubRepos.validGithubLogin(ok), true, ok);
323
+ });
324
+
325
+ test('mint: an explicit organisation choice never falls back to the personal account unseen', async () => {
326
+ targetRef = 'founder_x/mercury'; // an owner that is not a login, and nothing typed
327
+ try {
328
+ const res = await realFetch(`${origin}/api/bongos/provisioning/instances/42/github-app`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ account: 'org' }) });
329
+ assert.equal(res.status, 400);
330
+ assert.equal((await res.json()).error.code, 'org_required');
331
+ } finally { targetRef = 'founder/mercury'; }
332
+ // with no org typed, account 'org' still means the repo's own organisation, as before
333
+ targetRef = 'acme-studio/mercury';
334
+ try {
335
+ const j = await (await realFetch(`${origin}/api/bongos/provisioning/instances/42/github-app`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ account: 'org' }) })).json();
336
+ assert.equal(j.account, 'org');
337
+ assert.match(j.post_url, /^https:\/\/github\.com\/organizations\/acme-studio\/settings\/apps\/new\?state=/);
338
+ } finally { targetRef = 'founder/mercury'; }
339
+ });
340
+
341
+ // ── 5. the helpers, at unit altitude ────────────────────────────────────────────────
342
+ test('listUserOrgs: roles from the memberships; else the organisations the repos sit in, roles unknown', async () => {
343
+ gh = { 'GET /user/memberships/orgs': () => [200, [{ role: 'admin', state: 'pending', organization: { login: 'newco' } }, { role: 'admin', state: 'active', organization: { login: 'bad name' } }]] };
344
+ let out = await githubRepos.listUserOrgs('tok');
345
+ assert.equal(out.listed, 'memberships');
346
+ assert.deepEqual(out.orgs.map((o) => [o.login, o.can_create_apps]), [['newco', false]], 'a malformed login is dropped; a pending invite cannot');
347
+ assert.match(out.orgs[0].reason, /invitation to newco is still waiting/);
348
+ gh = {
349
+ 'GET /user/memberships/orgs': () => [403, {}],
350
+ 'GET /user/repos': () => [200, [
351
+ { owner: { login: 'Zeta', type: 'Organization' } }, { owner: { login: 'founder', type: 'User' } },
352
+ { owner: { login: 'alpha', type: 'Organization' } }, { owner: { login: 'zeta', type: 'Organization' } },
353
+ ]],
354
+ };
355
+ out = await githubRepos.listUserOrgs('tok');
356
+ assert.equal(out.listed, 'repos');
357
+ assert.deepEqual(out.orgs.map((o) => [o.login, o.can_create_apps]), [['alpha', null], ['Zeta', null]]);
358
+ gh = { 'GET /user/memberships/orgs': () => [403, {}], 'GET /user/repos': () => [500, {}] };
359
+ assert.deepEqual(await githubRepos.listUserOrgs('tok'), { orgs: [], listed: 'none' });
360
+ });
361
+
362
+ test('getOrgAccess: the role when GitHub gives it, unknown when it only proves the org exists, missing otherwise', async () => {
363
+ gh = { 'GET /user/memberships/orgs/acme-studio': () => [200, { role: 'admin', state: 'active', organization: { login: 'Acme-Studio' } }] };
364
+ assert.deepEqual(await githubRepos.getOrgAccess('tok', { org: 'acme-studio' }), { login: 'Acme-Studio', role: 'admin', can_create_apps: true, reason: null });
365
+ gh = { 'GET /user/memberships/orgs/ghost': () => [404, {}], 'GET /orgs/ghost': () => [404, {}] };
366
+ await assert.rejects(() => githubRepos.getOrgAccess('tok', { org: 'ghost' }), (e) => e.code === 'org_not_found');
367
+ await assert.rejects(() => githubRepos.getOrgAccess('tok', { org: 'a--b' }), (e) => e.code === 'bad_org');
368
+ });
369
+
370
+ test('github-setup: the organisation recorded at the mint rides the checklist read as app_org', async () => {
371
+ const row = { instance_id: 42, state: 'S1', created_at: '2026-10-08T09:00:00Z', app_slug: 'mercury-app' };
372
+ const said = await GS.githubSetupRead({ query: async () => ({ rows: [{ event: GS.SETUP_EVENTS.appOrg, at: '2026-10-08T09:00:01Z', detail: 'acme-studio' }] }) }, {}, row, 'done');
373
+ assert.equal(said.app_org, 'acme-studio');
374
+ const bad = await GS.githubSetupRead({ query: async () => ({ rows: [{ event: GS.SETUP_EVENTS.appOrg, at: '2026-10-08T09:00:01Z', detail: '<script>' }] }) }, {}, row, 'done');
375
+ assert.equal(bad.app_org, null, 'a recorded value that is not a login never reaches a link');
376
+ const none = await GS.githubSetupRead({ query: async () => ({ rows: [] }) }, {}, row, 'done');
377
+ assert.equal(none.app_org, null, 'nothing recorded = the founder’s own account');
378
+ });
@@ -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\(\);/,
@@ -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', () => {
@@ -108,7 +108,11 @@ console.log('\nGET /provisioning/github-app/callback (the public GitHub redirect
108
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
109
  // task 1004649: GitHub's half of the approval is done — the SAME tab goes on to the install
110
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));
111
+ // task 1004650: the way back is the return route, which names the project on the server
112
+ t('carries a plain way back to the project (works without script)', html.includes('<a class="btn" id="back" href="/api/bongos/provisioning/github-app/return?state=GOODSTATE">Back to my project'));
113
+ // the script's config is the one the page SERVED, not a copy (task 1004650)
114
+ const servedCfg = JSON.parse((html.match(/window\.__CB_SETUP__=(\{[^<]*?\});/) || [])[1] || '{}');
115
+ t('the served config sends the founder to the return route, never to a bare create page', servedCfg.back === '/api/bongos/provisioning/github-app/return?state=GOODSTATE', JSON.stringify(servedCfg));
112
116
  t('no progress stepper or countdown left on the page', !/id="steps"/.test(html) && !/Placing credentials/.test(html) && !/location\.href=target/.test(html));
113
117
 
114
118
  // Run the served INSTALL script (not a copy): it polls the real status shape and goes on.
@@ -116,11 +120,11 @@ console.log('\nGET /provisioning/github-app/callback (the public GitHub redirect
116
120
  t('the one-trip page ships its client script', typeof installScript === 'string' && /installations|install_url/.test(installScript));
117
121
  async function runInstall(statuses, repoAnswer) {
118
122
  const vm = await import('node:vm');
119
- const back = { href: '/projects?view=new' }, note = { textContent: '' };
123
+ const back = { href: '' }, note = { textContent: '' };
120
124
  const timers = [], fetched = [];
121
125
  const calls = { replaced: null };
122
126
  const sandbox = {
123
- __CB_SETUP__: { state: 'GOODSTATE', domain: 'mercury.example.com', back: '/projects?view=new' },
127
+ __CB_SETUP__: servedCfg,
124
128
  document: { getElementById: (id) => (id === 'back' ? back : id === 'note' ? note : null) },
125
129
  location: { replace: (u) => { calls.replaced = u; } },
126
130
  setTimeout: (fn) => { timers.push(fn); return timers.length; },
@@ -154,9 +158,9 @@ console.log('\nGET /provisioning/github-app/callback (the public GitHub redirect
154
158
  const plain = await runInstall([named], null);
155
159
  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
160
  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));
161
+ t('already installed → straight back to the checklist', doneAlready.replaced === '/api/bongos/provisioning/github-app/return?state=GOODSTATE', JSON.stringify(doneAlready));
158
162
  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));
163
+ t('a stopped attempt → back to the checklist, which says what stopped', stopped.replaced === '/api/bongos/provisioning/github-app/return?state=GOODSTATE', JSON.stringify(stopped));
160
164
  }
161
165
 
162
166
  console.log('\nGET /provisioning/github-app/setup (the App\'s setup_url — task 1004649):');
@@ -181,16 +185,17 @@ console.log('\nGET /provisioning/github-app/setup (the App\'s setup_url — task
181
185
  // the plain hand-back page (now the setup_url's) still behaves as before (task 1004548)
182
186
  const callbackPage = require(`${ROOT}/modules/provisioning/routes/callback-page.js`).callbackPage;
183
187
  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));
188
+ t('carries a plain "back to my checklist" link (works without script)', html.includes('<a class="btn" id="back" href="/api/bongos/provisioning/github-app/return?state=GOODSTATE">Back to my checklist'));
189
+ const servedCfg = JSON.parse((html.match(/window\.__CB_SETUP__=(\{[^<]*?\});/) || [])[1] || '{}');
185
190
  const script = (html.match(/<script>\s*(\(function\(\)\{[\s\S]*?)<\/script>\s*<\/body>/) || [])[1];
186
191
  t('the hand-back page ships the client script', typeof script === 'string' && script.length > 100);
187
192
  async function runCallback({ opener }) {
188
193
  const vm = await import('node:vm');
189
- const back = { href: '/projects?view=new' };
194
+ const back = { href: '' };
190
195
  const timers = [];
191
196
  const calls = { close: 0, focusOpener: 0, replaced: null };
192
197
  const sandbox = {
193
- __CB_SETUP__: { state: 'GOODSTATE', domain: 'mercury.example.com', back: '/projects?view=new' },
198
+ __CB_SETUP__: servedCfg,
194
199
  opener: opener ? { closed: false, focus: () => { calls.focusOpener++; } } : null,
195
200
  close: () => { calls.close++; },
196
201
  document: { getElementById: (id) => (id === 'back' ? back : null) },
@@ -204,12 +209,12 @@ console.log('\nGET /provisioning/github-app/setup (the App\'s setup_url — task
204
209
  while (timers.length) timers.shift()();
205
210
  return { ...calls, replacedBeforeTimer, backHref: back.href };
206
211
  }
207
- const want = '/projects?view=new#ghstate=GOODSTATE';
212
+ const want = '/api/bongos/provisioning/github-app/return?state=GOODSTATE';
208
213
  const popup = await runCallback({ opener: true });
209
214
  t('as the wizard’s pop-up it focuses the checklist tab and closes itself', popup.focusOpener === 1 && popup.close === 1 && popup.replacedBeforeTimer === null, JSON.stringify(popup));
210
215
  t('…and if the browser refuses the close, the pop-up becomes the checklist', popup.replaced === want, JSON.stringify(popup));
211
216
  const sameTab = await runCallback({ opener: false });
212
- t('as a same-tab return it goes straight back to the checklist, carrying the attempt in the fragment', sameTab.close === 0 && sameTab.replaced === want && sameTab.backHref === want, JSON.stringify(sameTab));
217
+ t('as a same-tab return it goes straight back through the return route, carrying the attempt', sameTab.close === 0 && sameTab.replaced === want && sameTab.backHref === want, JSON.stringify(sameTab));
213
218
  }
214
219
  {
215
220
  const res = await fetch(`${base}/provisioning/github-app/callback?code=X&state=NOPE`);
@@ -247,6 +247,10 @@ function runEnter({ draftStep, focus = false, resumeIntent = false, created = {
247
247
  const log = { shown: [], created: [], offer: undefined };
248
248
  const sandbox = {
249
249
  authKnown: true, me: { github_login: 'probe' }, resumeIntent,
250
+ // the server's resume read (task 1004650) already answered for this page load —
251
+ // this slice is about the record the tab stored (tests/github_handback_any_device.mjs
252
+ // holds the server's half)
253
+ resumeAsked: true, handbackProject: null, askServerResume() { throw new Error('not asked twice'); },
250
254
  state: { step: draftStep },
251
255
  createdRecord: () => created,
252
256
  enterCreated: (rec, f) => log.created.push({ id: rec.id, focus: f }),
@@ -164,7 +164,8 @@ const SEED = SCRIPT.slice(SS, SE);
164
164
  test('the reload regions are still findable (a rename must fail loudly)', () => {
165
165
  assert.ok(WS > 0 && WE > WS, 'wait-timer boundaries found');
166
166
  assert.ok(SS > 0 && SE > SS, 'instance-read seed boundaries found');
167
- assert.match(SCRIPT, /if \(!terminal \|\| manifestInFlight\(\) \|\| setupOpen\(\)\) pollTimer = setTimeout\(tick, 5000\);/,
167
+ // the pace slows while the tab is hidden, never stops (task 1004650)
168
+ assert.match(SCRIPT, /if \(!terminal \|\| manifestInFlight\(\) \|\| setupOpen\(\)\) pollTimer = setTimeout\(tick, wizPollDelay\(5000\)\);/,
168
169
  'a GitHub leg in flight — or an install not yet verified (task 1004649) — keeps the instance read alive once the project is up');
169
170
  });
170
171