@bongos/core 1.21.95 → 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.
@@ -103,6 +103,134 @@ async function createUserRepo(token, { name, fetchImpl = fetch } = {}) {
103
103
  throw new GithubApiError(`GitHub create-repo failed (${res.status})`, { status: res.status, code: 'github_error' });
104
104
  }
105
105
 
106
+ // ---- organisations (task 1004651) ----
107
+ // A founder can put the project under a GitHub organisation instead of their personal
108
+ // account: the empty repo (createOrgRepo) and the project's sign-in app (the manifest
109
+ // flow's /organizations/<org>/settings/apps/new, built by the provisioning module).
110
+ //
111
+ // THE SCOPE LIMIT, said once. The hub's sign-in asks for `read:user repo` (ADR 0155/0364)
112
+ // and not `read:org`, so GitHub may refuse the membership reads below for this token.
113
+ // Widening the sign-in scope is auth-config.js's call, not this file's, so every read
114
+ // here DEGRADES rather than fails: the role-carrying membership list when GitHub allows
115
+ // it, else the organisations the founder's repos sit in (role unknown), and always a
116
+ // typed name the founder can have checked. A role we could not read is said as such,
117
+ // never guessed as "owner", and GitHub's own page still refuses a non-owner.
118
+
119
+ // A GitHub login: 1-39 chars, letters/digits and single inner hyphens. The gate every
120
+ // org name passes before it reaches a GitHub URL (the page's copy is never trusted).
121
+ const GITHUB_LOGIN_RE = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/;
122
+ function validGithubLogin(s) { return typeof s === 'string' && GITHUB_LOGIN_RE.test(s); }
123
+
124
+ // GitHub's refusal when the org restricts which OAuth apps may act for it. Matched on
125
+ // the message because GitHub gives it no code of its own.
126
+ function isAppRestricted(body) {
127
+ return /OAuth App access restrictions|third-party application access/i.test(String((body && body.message) || ''));
128
+ }
129
+ async function readBody(res) {
130
+ try { return await res.json(); } catch (_) { return {}; }
131
+ }
132
+ function unauthorized() {
133
+ return new GithubApiError('GitHub rejected the stored token', { status: 401, code: 'github_unauthorized' });
134
+ }
135
+
136
+ // One membership → what the page shows. Only an ACTIVE admin (an org owner) can make
137
+ // an app on the org; anyone else gets the reason in words. role null = unknown. PURE.
138
+ function orgAccess({ login, role = null, state = null }) {
139
+ if (role === 'admin' && state === 'active') return { login, role, can_create_apps: true, reason: null };
140
+ if (role && state === 'pending') {
141
+ return { login, role, can_create_apps: false, reason: `Your invitation to ${login} is still waiting. Accept it on GitHub first.` };
142
+ }
143
+ if (role) {
144
+ return { login, role, can_create_apps: false, reason: `You're a member of ${login}, not an owner. Only its owners can make apps there.` };
145
+ }
146
+ return {
147
+ login, role: null, can_create_apps: null,
148
+ reason: `GitHub didn't tell us your role in ${login}. Only its owners can make the app there; if you aren't one, GitHub will say so on its own page.`,
149
+ };
150
+ }
151
+
152
+ // The founder's organisations: { orgs: [orgAccess...], listed: 'memberships'|'repos'|'none' }.
153
+ // 'memberships' carries real roles; 'repos' is the fallback for a token GitHub will not
154
+ // show memberships to (the orgs the founder's repos live in, roles unknown); 'none' means
155
+ // GitHub showed neither, and the page offers only the typed name. Sorted by login.
156
+ async function listUserOrgs(token, { fetchImpl = fetch } = {}) {
157
+ const byLogin = (a, b) => a.login.toLowerCase().localeCompare(b.login.toLowerCase());
158
+ const res = await fetchImpl(`${GH_API}/user/memberships/orgs?per_page=${PER_PAGE}`, { headers: ghHeaders(token) });
159
+ if (res.status === 401) throw unauthorized();
160
+ if (res.ok) {
161
+ const rows = await readBody(res);
162
+ const orgs = (Array.isArray(rows) ? rows : [])
163
+ .map((m) => ({ login: m && m.organization && m.organization.login, role: m && m.role, state: m && m.state }))
164
+ .filter((m) => validGithubLogin(m.login))
165
+ .map(orgAccess)
166
+ .sort(byLogin);
167
+ return { orgs, listed: 'memberships' };
168
+ }
169
+ // No membership read for this token: the organisations its repos sit in. One page,
170
+ // a hint list and never a gate; the typed name covers the rest.
171
+ const r2 = await fetchImpl(`${GH_API}/user/repos?per_page=${PER_PAGE}&sort=updated&affiliation=organization_member`, { headers: ghHeaders(token) });
172
+ if (r2.status === 401) throw unauthorized();
173
+ if (!r2.ok) return { orgs: [], listed: 'none' };
174
+ const repos = await readBody(r2);
175
+ const seen = new Map();
176
+ for (const r of Array.isArray(repos) ? repos : []) {
177
+ const o = r && r.owner;
178
+ if (o && o.type === 'Organization' && validGithubLogin(o.login) && !seen.has(o.login.toLowerCase())) {
179
+ seen.set(o.login.toLowerCase(), orgAccess({ login: o.login }));
180
+ }
181
+ }
182
+ return { orgs: [...seen.values()].sort(byLogin), listed: 'repos' };
183
+ }
184
+
185
+ // Check ONE organisation the founder typed: its orgAccess, or a typed org_not_found when
186
+ // GitHub has no organisation by that name (a personal account's login included). The
187
+ // membership read answers with the role when this token may read it; otherwise the
188
+ // public org read proves the org exists and the role stays unknown.
189
+ async function getOrgAccess(token, { org, fetchImpl = fetch } = {}) {
190
+ if (!validGithubLogin(org)) throw new GithubApiError('Not a GitHub organisation name', { status: 400, code: 'bad_org' });
191
+ const res = await fetchImpl(`${GH_API}/user/memberships/orgs/${encodeURIComponent(org)}`, { headers: ghHeaders(token) });
192
+ if (res.status === 401) throw unauthorized();
193
+ if (res.ok) {
194
+ const m = await readBody(res);
195
+ const login = m && m.organization && validGithubLogin(m.organization.login) ? m.organization.login : org;
196
+ return orgAccess({ login, role: (m && m.role) || null, state: (m && m.state) || null });
197
+ }
198
+ const r2 = await fetchImpl(`${GH_API}/orgs/${encodeURIComponent(org)}`, { headers: ghHeaders(token) });
199
+ if (r2.status === 401) throw unauthorized();
200
+ if (r2.status === 404) throw new GithubApiError('GitHub has no organisation by that name', { status: 404, code: 'org_not_found' });
201
+ if (!r2.ok) throw new GithubApiError(`GitHub /orgs read failed (${r2.status})`, { status: r2.status, code: 'github_error' });
202
+ const o = await readBody(r2);
203
+ if (o && o.type && o.type !== 'Organization') throw new GithubApiError('That name is a personal account', { status: 404, code: 'org_not_found' });
204
+ return orgAccess({ login: o && validGithubLogin(o.login) ? o.login : org });
205
+ }
206
+
207
+ // Create an EMPTY repo under an organisation: the org twin of createUserRepo, with the
208
+ // same fixed body (private, no first commit; ADR 0370). GitHub decides who may, and a
209
+ // refusal is typed so the page can say WHY:
210
+ // org_app_restricted - the org limits which OAuth apps may act for it
211
+ // org_forbidden - this member may not create repos there
212
+ // org_not_found - no such org, or this account cannot see it
213
+ async function createOrgRepo(token, { org, name, fetchImpl = fetch } = {}) {
214
+ if (!validGithubLogin(org)) throw new GithubApiError('Not a GitHub organisation name', { status: 400, code: 'bad_org' });
215
+ const res = await fetchImpl(`${GH_API}/orgs/${encodeURIComponent(org)}/repos`, {
216
+ method: 'POST',
217
+ headers: ghHeaders(token, { 'Content-Type': 'application/json' }),
218
+ body: JSON.stringify({ name, private: true, auto_init: false }),
219
+ });
220
+ if (res.status === 201) {
221
+ const r = await res.json();
222
+ return { full_name: r.full_name, html_url: r.html_url };
223
+ }
224
+ if (res.status === 401) throw unauthorized();
225
+ if (res.status === 422) throw new GithubApiError('A repository with that name already exists in the organisation', { status: 422, code: 'name_taken' });
226
+ if (res.status === 403) {
227
+ const body = await readBody(res);
228
+ throw new GithubApiError('GitHub refused the org repo', { status: 403, code: isAppRestricted(body) ? 'org_app_restricted' : 'org_forbidden' });
229
+ }
230
+ if (res.status === 404) throw new GithubApiError('GitHub answered 404 for that organisation', { status: 404, code: 'org_not_found' });
231
+ throw new GithubApiError(`GitHub create-org-repo failed (${res.status})`, { status: res.status, code: 'github_error' });
232
+ }
233
+
106
234
  // Read one repo as GitHub answers it FOR THIS TOKEN — the manage page's
107
235
  // visibility read (task 1003045). GitHub's own 404 covers both "no such repo"
108
236
  // and "no access", which is exactly the no-leak shape the caller wants to
@@ -252,6 +380,7 @@ async function deleteDeployKey(token, { owner, name, keyId, fetchImpl = fetch }
252
380
 
253
381
  module.exports = {
254
382
  listUserRepos, createUserRepo, getUserRepo, makeRepoPublic, makeRepoPrivate,
383
+ listUserOrgs, getOrgAccess, createOrgRepo, validGithubLogin,
255
384
  listDeployKeys, addDeployKey, deleteDeployKey,
256
385
  GithubApiError, MAX_PAGES,
257
386
  };
@@ -19,12 +19,13 @@ const express = require('express');
19
19
  const auth = require('../auth');
20
20
  const db = require('../db');
21
21
  const secretBox = require('../secret-box');
22
- const { listUserRepos, createUserRepo } = require('../github-repos');
22
+ const { listUserRepos, createUserRepo, listUserOrgs, getOrgAccess, createOrgRepo, validGithubLogin } = require('../github-repos');
23
23
  // Namespace require for the visibility helpers (task 1003045): the route tests
24
24
  // stub these on the cached module object, which a load-time destructure would
25
25
  // pin to the real implementations.
26
26
  const githubRepos = require('../github-repos');
27
27
  const { validateOrRespond } = require('./_helpers');
28
+ const log = require('../logger').logger.child({ src: 'github' });
28
29
 
29
30
  // GitHub repo names: 1–100 chars of [A-Za-z0-9._-]. We validate strictly for a clean
30
31
  // 400; anything GitHub still rejects surfaces as the 422→409 name_taken path.
@@ -33,6 +34,14 @@ const REPO_NAME_RE = /^[A-Za-z0-9._-]{1,100}$/;
33
34
  // hyphens) over a repo name. Strict for a clean 400; GitHub still owns the truth.
34
35
  const REPO_REF_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})?\/[A-Za-z0-9._-]{1,100}$/;
35
36
 
37
+ // The two org refusals the org routes share (task 1004651).
38
+ function badOrg(res) {
39
+ return res.fail('bad_org', { status: 400, message: 'That isn’t a GitHub organisation name: letters, numbers and single dashes, up to 39.' });
40
+ }
41
+ function orgNotFound(res, org) {
42
+ return res.fail('org_not_found', { status: 404, message: `GitHub didn’t show your account an organisation called ${org}.` });
43
+ }
44
+
36
45
  // Parse + validate the owner/name ref, or send a 400 and return null.
37
46
  function parseRepoRef(raw, res) {
38
47
  const ref = String(raw || '').trim();
@@ -92,22 +101,79 @@ module.exports = function buildGithubRouter() {
92
101
  }
93
102
  });
94
103
 
95
- // rank: any-builder — creates a repo in the caller's OWN account.
104
+ // rank: any-builder — the caller's OWN GitHub organisations (task 1004651), for the
105
+ // project page's "on an organisation" choice. Each row says whether the caller can make
106
+ // an app there and, when not, why. `listed` says how much GitHub showed this token
107
+ // (github-repos.listUserOrgs): the page always offers a typed name beside the list.
108
+ router.get('/github/orgs', auth.requireBuilder, async (req, res) => {
109
+ const token = await resolveRepoToken(req, res);
110
+ if (!token) return;
111
+ try {
112
+ res.json(await listUserOrgs(token));
113
+ } catch (err) {
114
+ if (err && err.status === 401) {
115
+ await db.deleteGithubToken({ builderId: req.builder.id }).catch(() => {});
116
+ return res.fail('repo_scope_required', { status: 403, message: 'Your GitHub authorization expired. Reconnect to list your organisations.' });
117
+ }
118
+ log.error('[gds] GET /github/orgs', err && err.message);
119
+ res.fail('github_error', { status: 502, message: 'GitHub could not be reached. Try again in a moment.' });
120
+ }
121
+ });
122
+
123
+ // rank: any-builder — checks ONE organisation name the caller typed, as their own
124
+ // GitHub credential sees it (task 1004651). The name is checked against GitHub's login
125
+ // rules before it reaches a URL.
126
+ router.get('/github/orgs/:org', auth.requireBuilder, async (req, res) => {
127
+ const org = String(req.params.org || '').trim();
128
+ if (!validGithubLogin(org)) return badOrg(res);
129
+ const token = await resolveRepoToken(req, res);
130
+ if (!token) return;
131
+ try {
132
+ res.json({ org: await getOrgAccess(token, { org }) });
133
+ } catch (err) {
134
+ if (err && err.status === 401) {
135
+ await db.deleteGithubToken({ builderId: req.builder.id }).catch(() => {});
136
+ return res.fail('repo_scope_required', { status: 403, message: 'Your GitHub authorization expired. Reconnect to check the organisation.' });
137
+ }
138
+ if (err && err.code === 'org_not_found') return orgNotFound(res, org);
139
+ log.error('[gds] GET /github/orgs/:org', err && err.message);
140
+ res.fail('github_error', { status: 502, message: 'GitHub could not be reached. Try again in a moment.' });
141
+ }
142
+ });
143
+
144
+ // rank: any-builder — creates a repo in the caller's OWN account, or (task 1004651)
145
+ // under an organisation they name in `org`. GitHub decides whether they may; its
146
+ // refusals come back as 409s with the reason, never as a 403 (the page reads a 403 as
147
+ // "sign in again").
96
148
  router.post('/github/repos', auth.requireBuilder, async (req, res) => {
97
- if (validateOrRespond(req, res, { name: { required: true, type: 'string', maxLength: 100 } })) return;
149
+ if (validateOrRespond(req, res, {
150
+ name: { required: true, type: 'string', maxLength: 100 },
151
+ org: { type: 'string', maxLength: 39 },
152
+ })) return;
98
153
  const name = String((req.body && req.body.name) || '').trim();
99
154
  if (!REPO_NAME_RE.test(name)) {
100
155
  return res.fail('bad_repo_name', { status: 400, message: 'Repository names use letters, numbers, and . _ - (1–100 chars).' });
101
156
  }
157
+ const org = req.body && req.body.org != null ? String(req.body.org).trim() : '';
158
+ if (org && !validGithubLogin(org)) return badOrg(res);
102
159
  const token = await resolveRepoToken(req, res);
103
160
  if (!token) return;
104
161
  try {
105
- const repo = await createUserRepo(token, { name });
162
+ const repo = org ? await createOrgRepo(token, { org, name }) : await createUserRepo(token, { name });
106
163
  res.status(201).json({ repo });
107
164
  } catch (err) {
108
165
  if (err && err.code === 'name_taken') {
109
- return res.fail('name_taken', { status: 409, message: 'You already have a repository with that name. Pick another, or adopt the existing one.' });
166
+ return res.fail('name_taken', { status: 409, message: org
167
+ ? `${org} already has a repository with that name. Pick another, or adopt the existing one.`
168
+ : 'You already have a repository with that name. Pick another, or adopt the existing one.' });
169
+ }
170
+ if (org && err && err.code === 'org_app_restricted') {
171
+ return res.fail('org_app_restricted', { status: 409, message: `${org} only lets apps its owners have approved act for it. An owner can approve this platform in the organisation's settings (Third-party access), or create the repo yourself.` });
172
+ }
173
+ if (org && err && err.code === 'org_forbidden') {
174
+ return res.fail('org_forbidden', { status: 409, message: `GitHub didn't let your account create a repository in ${org}. Ask one of its owners, or put the project on your own account.` });
110
175
  }
176
+ if (org && err && err.code === 'org_not_found') return orgNotFound(res, org);
111
177
  if (err && err.status === 401) {
112
178
  await db.deleteGithubToken({ builderId: req.builder.id }).catch(() => {});
113
179
  return res.fail('repo_scope_required', { status: 403, message: 'Your GitHub authorization expired. Reconnect to create the repo.' });
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.95'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.96'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -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
+ });