@bongos/core 1.21.95 → 1.21.97

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.95",
3
+ "version": "1.21.97",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.95",
9
+ "version": "1.21.97",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.95",
3
+ "version": "1.21.97",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -9400,5 +9400,17 @@
9400
9400
  "id": "1004650",
9401
9401
  "text": "Connecting GitHub no longer depends on staying in one browser tab. After you approve on GitHub, even on your phone or in another window, you land back on your own project's GitHub checklist. Our server remembers which project"
9402
9402
  }
9403
+ ],
9404
+ "1.21.96": [
9405
+ {
9406
+ "id": "1004651",
9407
+ "text": "Founders can now put a new project under a GitHub organisation, not only their own account. When connecting GitHub, they choose \"On my account\" or \"On an organisation\". Their organisations are listed; one they only belong to ("
9408
+ }
9409
+ ],
9410
+ "1.21.97": [
9411
+ {
9412
+ "id": "1004652",
9413
+ "text": "Connecting GitHub no longer makes a second app when something goes wrong. If a step stops, the checklist shows which one, says in one plain sentence that nothing is lost, and gives a \"Pick up where it stopped\" button that carr"
9414
+ }
9403
9415
  ]
9404
9416
  }
@@ -19,14 +19,19 @@
19
19
  // A verified fact is recorded once as a provisioning_events row; the checklist reads it.
20
20
  //
21
21
  // BOUNDED. Only attempts that settled within VERIFY_WINDOW_MS, or whose install was
22
- // reported within it, are checked, at most SWEEP_LIMIT per tick — so a finished or
23
- // abandoned project costs nothing, and a founder who ticks device flow later re-opens the
24
- // window by reinstalling or from the hall (task 1004652 owns "check again").
22
+ // reported or a check requested within it, are checked, at most SWEEP_LIMIT per tick — so
23
+ // a finished or abandoned project costs nothing, and "Pick up where it stopped" / "Check
24
+ // again" on the project page re-opens the window (task 1004652).
25
+ // SIGN-IN, TOO (task 1004652). An attempt that stopped because the project did not confirm
26
+ // sign-in in time ('error' with the app's keys placed) is re-asked on each tick in the
27
+ // window, with the keys already placed: when the project now answers auth_configured the
28
+ // attempt becomes 'done' — the same app, never a second one.
25
29
  // Secrets: web.env is read on the box, parsed in memory, and only NAMES ever reach a log.
26
30
 
27
31
  const crypto = require('node:crypto');
28
32
  const { webEnvPath } = require('./provision-units.js');
29
33
  const { SETUP_EVENTS, VERIFY_WINDOW_MS, githubSetupFacts, targetRepo } = require('../../modules/provisioning/github-setup.js');
34
+ const { parseHealthzAuth } = require('./oauth-secret.js');
30
35
 
31
36
  const SWEEP_LIMIT = 20;
32
37
  const UA = 'cloud-bongos-provisioner';
@@ -95,22 +100,29 @@ async function attemptsToVerify(db) {
95
100
  const { rows } = await db.query(
96
101
  `WITH latest AS (
97
102
  SELECT DISTINCT ON (instance_id) * FROM provisioning_oauth_manifests ORDER BY instance_id, created_at DESC)
98
- SELECT l.id, l.instance_id, l.created_at, l.requested_by, i.slug, i.target_ref, i.hosting_shape, i.status AS instance_status
103
+ SELECT l.id, l.instance_id, l.created_at, l.requested_by, l.status, i.slug, i.port, i.target_ref, i.hosting_shape, i.status AS instance_status
99
104
  FROM latest l JOIN provisioning_instances i ON i.id = l.instance_id
100
105
  WHERE l.app_id IS NOT NULL AND l.status IN ('done', 'error') AND i.status = 'active'
101
106
  AND (l.updated_at > now() - ($1 * interval '1 second')
102
107
  OR EXISTS (SELECT 1 FROM provisioning_events e WHERE e.instance_id = l.instance_id
103
- AND e.event = $2 AND e.created_at > now() - ($1 * interval '1 second')))
108
+ AND e.event = ANY($2) AND e.created_at > now() - ($1 * interval '1 second')))
104
109
  ORDER BY l.updated_at DESC LIMIT ${SWEEP_LIMIT}`,
105
- [windowSec, SETUP_EVENTS.installReported]);
110
+ [windowSec, [SETUP_EVENTS.installReported, SETUP_EVENTS.checkRequested]]);
106
111
  return rows;
107
112
  }
108
113
 
114
+ // Does the project answer that sign-in is configured? The project's JSON health read
115
+ // (the same probe as provision.js authHealthzCmd — the root /healthz says a plain "ok").
116
+ function signInAnswers(r, boxExec) {
117
+ const out = boxExec(`curl -sS --max-time 5 http://127.0.0.1:${r.port}/api/bongos/healthz`, { allowFail: true });
118
+ return parseHealthzAuth(out && out.stdout).authConfigured === true;
119
+ }
120
+
109
121
  // One tick: for every attempt in the window, check whatever is not verified yet and record
110
122
  // what GitHub confirmed. Returns { checked, installed, deviceFlow }.
111
123
  async function verifyGithubAppSetups(deps) {
112
124
  const { db, provisioning, log, apply } = deps;
113
- const out = { checked: 0, installed: 0, deviceFlow: 0 };
125
+ const out = { checked: 0, installed: 0, deviceFlow: 0, signIn: 0 };
114
126
  const rows = await attemptsToVerify(db);
115
127
  if (!apply) {
116
128
  for (const r of rows) log(` would check the GitHub install + device flow for ${r.slug} (manifest #${r.id})`);
@@ -118,9 +130,17 @@ async function verifyGithubAppSetups(deps) {
118
130
  }
119
131
  for (const r of rows) {
120
132
  const facts = await githubSetupFacts(db, r.instance_id, r.created_at);
121
- if (facts.installedAt && facts.deviceFlowAt) continue;
133
+ const signInStopped = r.status === 'error';
134
+ if (facts.installedAt && facts.deviceFlowAt && !signInStopped) continue;
122
135
  const onControlBox = r.hosting_shape === 'cloud-host' || r.hosting_shape === 'control-plane';
123
136
  const boxExec = onControlBox ? (deps.controlExec || deps.exec) : deps.exec;
137
+ if (signInStopped && signInAnswers(r, boxExec)) {
138
+ await provisioning.resolveOAuthManifest(db, r.id, 'done', { github_code: null });
139
+ await provisioning.recordEvent(db, { instanceId: r.instance_id, ownerBuilderId: r.requested_by, event: 'oauth-manifest-done',
140
+ detail: 'sign-in confirmed on a later check, with the app already made', actor: 'runner' }).catch(() => {});
141
+ out.signIn++; log(` ✓ ${r.slug}: sign-in now confirmed`);
142
+ }
143
+ if (facts.installedAt && facts.deviceFlowAt) { out.checked++; continue; }
124
144
  const read = boxExec(`${deps.privileged ? 'sudo ' : ''}cat ${webEnvPath(r)}`, { allowFail: true });
125
145
  const creds = appCredsFromEnv(read && read.stdout);
126
146
  out.checked++;
@@ -141,7 +161,7 @@ async function verifyGithubAppSetups(deps) {
141
161
  }
142
162
  }
143
163
  }
144
- if (out.checked) log(`github-setup — checked=${out.checked} installed=${out.installed} device-flow=${out.deviceFlow}`);
164
+ if (out.checked) log(`github-setup — checked=${out.checked} installed=${out.installed} device-flow=${out.deviceFlow} sign-in=${out.signIn}`);
145
165
  return out;
146
166
  }
147
167
 
@@ -1081,14 +1081,22 @@ async function placeManifestCreds(inst, mrow, deps) {
1081
1081
  // 3) rewrite web.env with the creds + restart the unit (never logs the secret — the
1082
1082
  // writeFile logs only a byte count; the value is base64/temp-file placed).
1083
1083
  log(` [oauth-manifest] place creds in ${webEnvPath(inst)} + restart ${inst.slug}`);
1084
- boxWriteFile(webEnvPath(inst), webEnvBody(inst, {
1085
- clientId: creds.clientId, clientSecret: creds.clientSecret, ownerLogin: await ownerLoginOf(inst, deps.db),
1086
- // The App id + private key double as the ADR 0055 publish credential (task
1087
- // 1003049); older Apps (pre-permission manifests) return a pem too — placing
1088
- // it is harmless and heals once the owner grants the permissions + installs.
1089
- appId: creds.appId, appPem: creds.appPem,
1090
- }), { mode: 0o600, sudo: true });
1091
- pmFor(deps).restartService(inst, { exec: boxExec, sudoP });
1084
+ try {
1085
+ boxWriteFile(webEnvPath(inst), webEnvBody(inst, {
1086
+ clientId: creds.clientId, clientSecret: creds.clientSecret, ownerLogin: await ownerLoginOf(inst, deps.db),
1087
+ // The App id + private key double as the ADR 0055 publish credential (task
1088
+ // 1003049); older Apps (pre-permission manifests) return a pem too — placing
1089
+ // it is harmless and heals once the owner grants the permissions + installs.
1090
+ appId: creds.appId, appPem: creds.appPem,
1091
+ }), { mode: 0o600, sudo: true });
1092
+ pmFor(deps).restartService(inst, { exec: boxExec, sudoP });
1093
+ } catch (e) {
1094
+ // The code is spent and the keys are not placed: a retry would re-convert a burned
1095
+ // code, so say so (codeSpent) and name the app GitHub made for the platform team
1096
+ // (task 1004652 — the wizard offers to send it to them; the slug stays OFF the row,
1097
+ // because a recorded slug reads as "keys placed").
1098
+ return { ok: false, codeSpent: apply, error: `GitHub made the app ${creds.appSlug || creds.appId || '?'}, but placing its keys failed: ${e.message}` };
1099
+ }
1092
1100
  // The app now exists AND its credentials are placed — the checklist's first tick, and the
1093
1101
  // slug the callback page needs to send the founder on to installing it (task 1004649).
1094
1102
  if (apply && deps.onCredsPlaced) await Promise.resolve().then(() => deps.onCredsPlaced({ appId: creds.appId, appSlug: creds.appSlug })).catch(() => {});
@@ -1182,7 +1190,10 @@ async function drainOAuthManifests(deps) {
1182
1190
  converted++;
1183
1191
  }
1184
1192
  } catch (e) {
1185
- await provisioning.resolveOAuthManifest(db, mrow.id, 'error', { last_error: e.message }).catch(() => {});
1193
+ // GitHub's 404 means the code is used or past its hour: drop it, so "pick up where it
1194
+ // stopped" (task 1004652) never re-queues a dead code. Any other failure keeps it.
1195
+ const dead = /conversion failed: (Not Found|HTTP 404)/.test(String(e.message));
1196
+ await provisioning.resolveOAuthManifest(db, mrow.id, 'error', { last_error: e.message, ...(dead ? { github_code: null } : {}) }).catch(() => {});
1186
1197
  errors++; log(` ✖ oauth-manifest #${mrow.id}: ${e.message}`);
1187
1198
  }
1188
1199
  }
@@ -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.97'; // 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');
@@ -153,7 +153,7 @@ test('the runner records what GitHub confirmed — and only that — reading the
153
153
  exec: (cmd) => { cmds.push(cmd); return { ok: true, stdout: ENV }; },
154
154
  provisioning: { recordEvent: async (_db, e) => { events.push(e); } },
155
155
  });
156
- assert.deepEqual(out, { checked: 1, installed: 1, deviceFlow: 0 });
156
+ assert.deepEqual(out, { checked: 1, installed: 1, deviceFlow: 0, signIn: 0 });
157
157
  assert.deepEqual(cmds, ['sudo cat /etc/mercury/web.env'], 'the creds are read where the runner placed them');
158
158
  assert.deepEqual(events.map((e) => e.event), ['github-app-installed'], 'device flow off records nothing');
159
159
  assert.match(events[0].detail, /acme\/mercury/);
@@ -168,7 +168,7 @@ test('an attempt already fully verified is skipped; dry-run asks GitHub nothing'
168
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
169
  exec: () => { throw new Error('must not read the box'); }, provisioning: { recordEvent: async () => { throw new Error('nothing to record'); } },
170
170
  };
171
- assert.deepEqual(await RUN.verifyGithubAppSetups(deps), { checked: 0, installed: 0, deviceFlow: 0 });
171
+ assert.deepEqual(await RUN.verifyGithubAppSetups(deps), { checked: 0, installed: 0, deviceFlow: 0, signIn: 0 });
172
172
  const lines = [];
173
173
  await RUN.verifyGithubAppSetups({ ...deps, apply: false, log: (m) => lines.push(m), db: fakeDb({ attempts: [ATTEMPT] }) });
174
174
  assert.equal(asked, 0);
@@ -181,7 +181,7 @@ test('the window query takes each project\'s LATEST attempt, past placement, sti
181
181
  assert.match(sql, /DISTINCT ON \(instance_id\)[\s\S]*ORDER BY instance_id, created_at DESC/);
182
182
  assert.match(sql, /l\.app_id IS NOT NULL AND l\.status IN \('done', 'error'\) AND i\.status = 'active'/);
183
183
  assert.match(sql, /LIMIT 20/);
184
- assert.deepEqual(params, [GS.VERIFY_WINDOW_MS / 1000, 'github-app-install-reported']);
184
+ assert.deepEqual(params, [GS.VERIFY_WINDOW_MS / 1000, ['github-app-install-reported', 'github-app-check-requested']], 'an install report or a check request (task 1004652) re-opens it');
185
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
186
  });
187
187
 
@@ -202,7 +202,7 @@ function page({ manifest, appSlug = null }) {
202
202
  const sandbox = {
203
203
  live: { manifest, manifestMsg: '', manifestCode: '', manifestFailure: null, appSlug, setup: null, installUrl: null, instStatus: 'active', addrReady: true },
204
204
  state: { name: 'Mer<cury', slug: 'mercury', owner: 'acme', repo: 'mercury', step: 2 },
205
- manifestStale: false, autoOpenArmed: false,
205
+ autoOpenArmed: false,
206
206
  resolvedDomain: () => 'mercury.example.com', apexBase: () => 'example.com',
207
207
  esc: (x) => String(x).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
208
208
  GH_ICON: '', manualOauthDetails: () => '', projectPageLink: (t) => t, autoOpenTick: () => '',