@bongos/core 1.21.93 → 1.21.95
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +63 -38
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +9 -2
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +112 -9
- package/docs/api-reference.md +6 -3
- package/docs/copy-inventory.md +354 -354
- package/docs/copy-registry.json +420 -420
- package/docs/design/project-startup-direction.md +15 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +963 -992
- package/modules/provisioning/github-setup.js +129 -0
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/provisioning.js +3 -0
- package/modules/provisioning/routes/callback-page.js +83 -8
- package/modules/provisioning/routes/github-setup.js +221 -0
- package/modules/provisioning/routes/provisioning.js +11 -45
- package/modules/public-landing/public/projects.html +207 -66
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/oauth-secret.js +4 -2
- package/scripts/gds/provision-github-setup.js +148 -0
- package/scripts/gds/provision.js +19 -5
- package/src/module-api.js +1 -1
- package/tests/github_handback_any_device.mjs +340 -0
- package/tests/github_one_trip.mjs +297 -0
- package/tests/projects_hub.mjs +6 -2
- package/tests/projects_hub_dns_ready.mjs +2 -1
- package/tests/projects_hub_pre_uat.mjs +17 -9
- package/tests/provision.mjs +51 -9
- package/tests/provisioning_callback_page.mjs +3 -2
- package/tests/provisioning_github_app_routes.mjs +103 -12
- package/tests/wizard_draft_resume.mjs +4 -0
- package/tests/wizard_github_feedback.mjs +9 -5
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// scripts/gds/provision-github-setup.js — the control-plane runner's check of the two
|
|
3
|
+
// GitHub steps a founder finishes ON GITHUB after the app exists: "Install it on your repo"
|
|
4
|
+
// and "Command-line sign-in is on" (task 1004649; the checklist is derived in
|
|
5
|
+
// modules/provisioning/github-setup.js).
|
|
6
|
+
//
|
|
7
|
+
// WHY THE RUNNER. Only the runner holds the app's credentials (it placed them in the
|
|
8
|
+
// project's chmod-600 web.env; ADR 0111 §2 / ADR 0133 — the web tier never sees a secret).
|
|
9
|
+
// The setup_url redirect GitHub sends after an install carries an installation_id, but
|
|
10
|
+
// GitHub's own docs warn it can be spoofed, so a tick needs GitHub's answer to a question
|
|
11
|
+
// asked with the app's own key:
|
|
12
|
+
// install — GET /repos/{owner}/{repo}/installation, authenticated as the app (a JWT
|
|
13
|
+
// signed with its private key). 200 with an id = installed on THIS repo.
|
|
14
|
+
// device — POST github.com/login/device/code with the app's client id. GitHub answers a
|
|
15
|
+
// device code when device flow is on and `device_flow_disabled` when it is off.
|
|
16
|
+
// The unused code simply expires; nothing is signed in. GitHub has NO manifest
|
|
17
|
+
// field and NO API that turns device flow on (spike, task 1004649), so this
|
|
18
|
+
// only ever OBSERVES the founder's own tick on GitHub.
|
|
19
|
+
// A verified fact is recorded once as a provisioning_events row; the checklist reads it.
|
|
20
|
+
//
|
|
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").
|
|
25
|
+
// Secrets: web.env is read on the box, parsed in memory, and only NAMES ever reach a log.
|
|
26
|
+
|
|
27
|
+
const crypto = require('node:crypto');
|
|
28
|
+
const { webEnvPath } = require('./provision-units.js');
|
|
29
|
+
const { SETUP_EVENTS, VERIFY_WINDOW_MS, githubSetupFacts, targetRepo } = require('../../modules/provisioning/github-setup.js');
|
|
30
|
+
|
|
31
|
+
const SWEEP_LIMIT = 20;
|
|
32
|
+
const UA = 'cloud-bongos-provisioner';
|
|
33
|
+
|
|
34
|
+
function b64url(input) {
|
|
35
|
+
return Buffer.from(input).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// An app JWT (RS256, 9 minutes, iat backdated for skew) — GitHub's app authentication.
|
|
39
|
+
function appJwt({ appId, pem, nowSec = Math.floor(Date.now() / 1000) }) {
|
|
40
|
+
const head = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
|
|
41
|
+
const body = b64url(JSON.stringify({ iat: nowSec - 60, exp: nowSec + 9 * 60, iss: String(appId) }));
|
|
42
|
+
const signer = crypto.createSign('RSA-SHA256');
|
|
43
|
+
signer.update(`${head}.${body}`);
|
|
44
|
+
signer.end();
|
|
45
|
+
return `${head}.${body}.${b64url(signer.sign(pem))}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The app's id, private key and client id out of a web.env body. The pem is stored as one
|
|
49
|
+
// line with literal \n (provision-units.js webEnvBody); the client id under the pack's
|
|
50
|
+
// prefix, which this does not need to know. Missing pieces are null. PURE.
|
|
51
|
+
function appCredsFromEnv(body) {
|
|
52
|
+
const val = (re) => { const m = String(body || '').match(re); return m ? m[1].trim() : null; };
|
|
53
|
+
const pemLine = val(/^GITHUB_APP_PRIVATE_KEY=(.+)$/m);
|
|
54
|
+
return {
|
|
55
|
+
appId: val(/^GITHUB_APP_ID=(\d+)\s*$/m),
|
|
56
|
+
pem: pemLine ? pemLine.replace(/\\n/g, '\n') : null,
|
|
57
|
+
clientId: val(/^[A-Z0-9_]+_GITHUB_CLIENT_ID=(\S+)\s*$/m),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Is the app installed on owner/repo? true · false (404: the app is real, not installed
|
|
62
|
+
// there) · null (cannot tell — any other answer, or no network).
|
|
63
|
+
async function installedOnRepo({ appId, pem, repo, fetchImpl = globalThis.fetch }) {
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetchImpl(`https://api.github.com/repos/${repo}/installation`, {
|
|
66
|
+
headers: { Authorization: `Bearer ${appJwt({ appId, pem })}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', 'User-Agent': UA },
|
|
67
|
+
});
|
|
68
|
+
if (res.status === 404) return false;
|
|
69
|
+
if (!res.ok) return null;
|
|
70
|
+
const j = await res.json().catch(() => null);
|
|
71
|
+
return !!(j && j.id != null);
|
|
72
|
+
} catch (_) { return null; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Does GitHub accept a device-flow start for this client id? true · false
|
|
76
|
+
// (device_flow_disabled) · null (any other answer — never read as "off").
|
|
77
|
+
async function deviceFlowOn({ clientId, fetchImpl = globalThis.fetch }) {
|
|
78
|
+
try {
|
|
79
|
+
const res = await fetchImpl('https://github.com/login/device/code', {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', 'User-Agent': UA },
|
|
82
|
+
body: `client_id=${encodeURIComponent(clientId)}`,
|
|
83
|
+
});
|
|
84
|
+
const j = await res.json().catch(() => null);
|
|
85
|
+
if (j && j.device_code) return true;
|
|
86
|
+
if (j && /device_flow_disabled/.test(String(j.error || ''))) return false;
|
|
87
|
+
return null;
|
|
88
|
+
} catch (_) { return null; }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The attempts worth checking this tick: each instance's LATEST attempt, past the
|
|
92
|
+
// credential placement (an app id recorded), settled or reported within the window.
|
|
93
|
+
async function attemptsToVerify(db) {
|
|
94
|
+
const windowSec = Math.round(VERIFY_WINDOW_MS / 1000);
|
|
95
|
+
const { rows } = await db.query(
|
|
96
|
+
`WITH latest AS (
|
|
97
|
+
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
|
|
99
|
+
FROM latest l JOIN provisioning_instances i ON i.id = l.instance_id
|
|
100
|
+
WHERE l.app_id IS NOT NULL AND l.status IN ('done', 'error') AND i.status = 'active'
|
|
101
|
+
AND (l.updated_at > now() - ($1 * interval '1 second')
|
|
102
|
+
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')))
|
|
104
|
+
ORDER BY l.updated_at DESC LIMIT ${SWEEP_LIMIT}`,
|
|
105
|
+
[windowSec, SETUP_EVENTS.installReported]);
|
|
106
|
+
return rows;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// One tick: for every attempt in the window, check whatever is not verified yet and record
|
|
110
|
+
// what GitHub confirmed. Returns { checked, installed, deviceFlow }.
|
|
111
|
+
async function verifyGithubAppSetups(deps) {
|
|
112
|
+
const { db, provisioning, log, apply } = deps;
|
|
113
|
+
const out = { checked: 0, installed: 0, deviceFlow: 0 };
|
|
114
|
+
const rows = await attemptsToVerify(db);
|
|
115
|
+
if (!apply) {
|
|
116
|
+
for (const r of rows) log(` would check the GitHub install + device flow for ${r.slug} (manifest #${r.id})`);
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
for (const r of rows) {
|
|
120
|
+
const facts = await githubSetupFacts(db, r.instance_id, r.created_at);
|
|
121
|
+
if (facts.installedAt && facts.deviceFlowAt) continue;
|
|
122
|
+
const onControlBox = r.hosting_shape === 'cloud-host' || r.hosting_shape === 'control-plane';
|
|
123
|
+
const boxExec = onControlBox ? (deps.controlExec || deps.exec) : deps.exec;
|
|
124
|
+
const read = boxExec(`${deps.privileged ? 'sudo ' : ''}cat ${webEnvPath(r)}`, { allowFail: true });
|
|
125
|
+
const creds = appCredsFromEnv(read && read.stdout);
|
|
126
|
+
out.checked++;
|
|
127
|
+
const repo = targetRepo(r);
|
|
128
|
+
const fetchImpl = deps.fetchImpl;
|
|
129
|
+
if (!facts.installedAt && creds.appId && creds.pem && repo) {
|
|
130
|
+
if (await installedOnRepo({ appId: creds.appId, pem: creds.pem, repo, fetchImpl })) {
|
|
131
|
+
await provisioning.recordEvent(db, { instanceId: r.instance_id, ownerBuilderId: r.requested_by, event: SETUP_EVENTS.installed,
|
|
132
|
+
detail: `the GitHub app is installed on ${repo} (checked with the app's own key)`, actor: 'runner' });
|
|
133
|
+
out.installed++; log(` ✓ ${r.slug}: GitHub app installed on ${repo}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (!facts.deviceFlowAt && creds.clientId) {
|
|
137
|
+
if (await deviceFlowOn({ clientId: creds.clientId, fetchImpl })) {
|
|
138
|
+
await provisioning.recordEvent(db, { instanceId: r.instance_id, ownerBuilderId: r.requested_by, event: SETUP_EVENTS.deviceFlow,
|
|
139
|
+
detail: 'command-line sign-in (device flow) is on for the GitHub app', actor: 'runner' });
|
|
140
|
+
out.deviceFlow++; log(` ✓ ${r.slug}: command-line sign-in is on`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (out.checked) log(`github-setup — checked=${out.checked} installed=${out.installed} device-flow=${out.deviceFlow}`);
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = { appJwt, appCredsFromEnv, installedOnRepo, deviceFlowOn, attemptsToVerify, verifyGithubAppSetups, SWEEP_LIMIT };
|
package/scripts/gds/provision.js
CHANGED
|
@@ -1039,6 +1039,13 @@ async function convertManifestCode(code, { fetchImpl = globalThis.fetch, userAge
|
|
|
1039
1039
|
};
|
|
1040
1040
|
}
|
|
1041
1041
|
|
|
1042
|
+
// The sign-in probe: the project's JSON health answer, which carries auth_configured
|
|
1043
|
+
// (src/bongos/routes/healthz.js). NOT healthzCmd — the root /healthz answers a plain "ok",
|
|
1044
|
+
// which never parses, so every setup that worked was recorded FAILED (task 1004577).
|
|
1045
|
+
function authHealthzCmd(inst) {
|
|
1046
|
+
return `curl -sS --max-time 5 http://127.0.0.1:${inst.port}/api/bongos/healthz`;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1042
1049
|
// Convert one manifest row's code → creds and PLACE them (rewrite web.env + restart +
|
|
1043
1050
|
// verify /healthz). Uses the SAME executors provisionInstance does, so co-tenant (local
|
|
1044
1051
|
// or remote-cohost) and standalone (control-plane-local) are handled identically. Returns { ok, appId, appSlug } | { ok:false, error }.
|
|
@@ -1082,15 +1089,19 @@ async function placeManifestCreds(inst, mrow, deps) {
|
|
|
1082
1089
|
appId: creds.appId, appPem: creds.appPem,
|
|
1083
1090
|
}), { mode: 0o600, sudo: true });
|
|
1084
1091
|
pmFor(deps).restartService(inst, { exec: boxExec, sudoP });
|
|
1092
|
+
// The app now exists AND its credentials are placed — the checklist's first tick, and the
|
|
1093
|
+
// slug the callback page needs to send the founder on to installing it (task 1004649).
|
|
1094
|
+
if (apply && deps.onCredsPlaced) await Promise.resolve().then(() => deps.onCredsPlaced({ appId: creds.appId, appSlug: creds.appSlug })).catch(() => {});
|
|
1085
1095
|
|
|
1086
|
-
// 4) verify
|
|
1096
|
+
// 4) verify the project reports auth_configured=true (only meaningful under --apply).
|
|
1087
1097
|
// PATIENT (F23): the child must boot before it can answer; losing this race
|
|
1088
|
-
// used to record a failure for a setup that had succeeded.
|
|
1098
|
+
// used to record a failure for a setup that had succeeded. The JSON answer lives
|
|
1099
|
+
// at /api/bongos/healthz — the root /healthz says a plain "ok" (task 1004577).
|
|
1089
1100
|
if (apply) {
|
|
1090
1101
|
const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1091
1102
|
let authConfigured = false;
|
|
1092
1103
|
for (let i = 0; i < MANIFEST_VERIFY_TRIES; i++) {
|
|
1093
|
-
const out = boxExec(
|
|
1104
|
+
const out = boxExec(authHealthzCmd(inst), { allowFail: true });
|
|
1094
1105
|
if (oauthSecret.parseHealthzAuth(out && out.stdout).authConfigured) { authConfigured = true; break; }
|
|
1095
1106
|
await sleep(MANIFEST_VERIFY_INTERVAL_MS);
|
|
1096
1107
|
}
|
|
@@ -1149,7 +1160,9 @@ async function drainOAuthManifests(deps) {
|
|
|
1149
1160
|
try {
|
|
1150
1161
|
const inst = await provisioning.getInstanceById(db, mrow.instance_id);
|
|
1151
1162
|
if (!inst) { await provisioning.resolveOAuthManifest(db, mrow.id, 'error', { last_error: 'instance row vanished' }); errors++; continue; }
|
|
1152
|
-
|
|
1163
|
+
// the slug lands on the row the moment the credentials are placed (task 1004649)
|
|
1164
|
+
const onCredsPlaced = (c) => provisioning.resolveOAuthManifest(db, mrow.id, 'converting', { app_id: c.appId, app_slug: c.appSlug });
|
|
1165
|
+
const result = await placeManifestCreds(inst, mrow, { ...deps, onCredsPlaced });
|
|
1153
1166
|
if (result && result.ok === false) {
|
|
1154
1167
|
// A SPENT code is terminal, always (F23): GitHub's manifest code is
|
|
1155
1168
|
// single-use, so resetting the row to 'code_received' guarantees the
|
|
@@ -1257,6 +1270,7 @@ async function cmdRunIntents(deps) {
|
|
|
1257
1270
|
}
|
|
1258
1271
|
await snapshotAllCoreVersions(deps); // refresh what the deploy control reads, AFTER the drain (ADR 0293 D3/D6)
|
|
1259
1272
|
const m = await drainOAuthManifests(deps); // same tick: convert + place any GitHub-App-Manifest creds awaiting the control plane
|
|
1273
|
+
await require('./provision-github-setup.js').verifyGithubAppSetups(deps).catch((e) => log(` github-setup check failed (non-blocking): ${e.message}`)); // install + device flow, asked of GitHub with the app's key (task 1004649)
|
|
1260
1274
|
log(`done — ran=${ran} ok=${ok} errors=${errors}; oauth-manifest converted=${m.converted} errors=${m.errors}`);
|
|
1261
1275
|
return { ran, ok, errors, manifests: m };
|
|
1262
1276
|
}
|
|
@@ -1438,7 +1452,7 @@ module.exports = {
|
|
|
1438
1452
|
// restart an unreachable ACTIVE instance (task 1003238) — the settings leg minus the env patch
|
|
1439
1453
|
restartInstance,
|
|
1440
1454
|
// GitHub App Manifest flow (task 2080, ADR 0133)
|
|
1441
|
-
convertManifestCode, placeManifestCreds, drainOAuthManifests, MANIFEST_UA,
|
|
1455
|
+
convertManifestCode, placeManifestCreds, drainOAuthManifests, authHealthzCmd, MANIFEST_UA,
|
|
1442
1456
|
// cross-site identity federation (ADR 0141 §4, task #1002109)
|
|
1443
1457
|
federationHubOrigin, shouldFederate, federateInstance,
|
|
1444
1458
|
};
|
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.
|
|
78
|
+
const CORE_VERSION = '1.21.95'; // 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,340 @@
|
|
|
1
|
+
// tests/github_handback_any_device.mjs — coming back from GitHub works from any tab or
|
|
2
|
+
// device, and the page keeps checking while you are away (task 1004650).
|
|
3
|
+
//
|
|
4
|
+
// Before: the GitHub hand-back sent the founder to /projects?view=new#ghstate=…, and the
|
|
5
|
+
// page knew WHICH project only from that tab's sessionStorage (cb-created-instance). Approve
|
|
6
|
+
// on a phone, or in another window, and you landed on a blank create page. And both wizard
|
|
7
|
+
// polls parked themselves while the tab was hidden — exactly while the founder was on GitHub.
|
|
8
|
+
//
|
|
9
|
+
// Now the callback and setup pages send the founder to the return route, which resolves the
|
|
10
|
+
// one-time state to the project the SERVER remembers for it and lands its signed-in owner on
|
|
11
|
+
// /projects?view=new&id=<project>; the page asks the server's resume read for that project
|
|
12
|
+
// and opens part 03 with nothing in browser storage. The polls keep going while hidden, at
|
|
13
|
+
// a slower pace, and a waiting tick runs at once when the tab is seen again.
|
|
14
|
+
//
|
|
15
|
+
// The whole path runs for real at least once: the REAL setup route's page → its REAL hand-
|
|
16
|
+
// back script → the REAL return route (302) → the REAL page code (sliced out of
|
|
17
|
+
// projects.html and run in a vm, the vm-slice idiom of tests/wizard_intent_resume.mjs) with
|
|
18
|
+
// EMPTY sessionStorage, whose fetches go to the REAL resume route. DB-free: the provisioning
|
|
19
|
+
// data-access fns and the session resolver are stubbed on the cached modules.
|
|
20
|
+
//
|
|
21
|
+
// Run: node --test --test-reporter=tap tests/github_handback_any_device.mjs
|
|
22
|
+
|
|
23
|
+
import assert from 'node:assert/strict';
|
|
24
|
+
import { test, before, after } from 'node:test';
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import vm from 'node:vm';
|
|
28
|
+
import { createRequire } from 'node:module';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
|
|
31
|
+
const require = createRequire(import.meta.url);
|
|
32
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
33
|
+
const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
|
|
34
|
+
// CRLF on a Windows checkout — a vm cannot run a slice with stray \r in it
|
|
35
|
+
const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
|
|
36
|
+
const SCRIPT = HUB.slice(HUB.lastIndexOf('<script>'), HUB.lastIndexOf('</script>'));
|
|
37
|
+
|
|
38
|
+
// ── the world: two builders, a project each, one attempt in flight ───────────────────
|
|
39
|
+
const STATE = 'ab12'.repeat(8);
|
|
40
|
+
const NOW = new Date().toISOString();
|
|
41
|
+
const MINE = { id: 42, owner_builder_id: 7, slug: 'mercury', domain: 'mercury.example.com', target_ref: 'acme/mercury', status: 'active', onboard_mode: 'greenfield', created_at: NOW };
|
|
42
|
+
const THEIRS = { id: 43, owner_builder_id: 8, slug: 'venus', domain: 'venus.example.com', target_ref: 'zed/venus', status: 'active', onboard_mode: 'greenfield', created_at: NOW };
|
|
43
|
+
const ATTEMPT = { id: 99, instance_id: 42, requested_by: 7, state: STATE, status: 'converting', app_slug: 'mercury-app', created_at: NOW, updated_at: NOW, expires_at: new Date(Date.now() + 600000).toISOString() };
|
|
44
|
+
|
|
45
|
+
const provisioning = require(`${ROOT}/modules/provisioning/provisioning.js`);
|
|
46
|
+
provisioning.getInstanceById = async (_db, id) => [MINE, THEIRS].find((i) => i.id === Number(id)) || null;
|
|
47
|
+
provisioning.getOAuthManifestByState = async (_db, s) => (s === STATE ? ATTEMPT : null);
|
|
48
|
+
provisioning.getLatestOAuthManifestForInstance = async (_db, id) => (Number(id) === 42 ? ATTEMPT : null);
|
|
49
|
+
provisioning.listInstancesForOwner = async (_db, who) => [MINE, THEIRS].filter((i) => i.owner_builder_id === who);
|
|
50
|
+
provisioning.recordEvent = async () => {};
|
|
51
|
+
const githubSetup = require(`${ROOT}/modules/provisioning/github-setup.js`);
|
|
52
|
+
githubSetup.githubSetupFacts = async () => ({}); // nothing verified yet: the install is still to do
|
|
53
|
+
|
|
54
|
+
// The session is a cookie naming the builder; no cookie is nobody. Both auth doors read it.
|
|
55
|
+
const api = require(`${ROOT}/src/module-api.js`);
|
|
56
|
+
const whoOf = (req) => { const m = /(?:^|;\s*)who=(\d+)/.exec(String(req.headers.cookie || '')); return m ? Number(m[1]) : null; };
|
|
57
|
+
api.resolveSession = async (req) => (whoOf(req) ? { builder_id: whoOf(req), status: 'active', source: 'web' } : null);
|
|
58
|
+
api.requireBuilder = (req, res, next) => {
|
|
59
|
+
const who = whoOf(req);
|
|
60
|
+
if (!who) return res.status(401).json({ error: { code: 'unauthenticated' } });
|
|
61
|
+
req.builder = { id: who };
|
|
62
|
+
return next();
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const express = require('express');
|
|
66
|
+
const githubSetupRoutes = require(`${ROOT}/modules/provisioning/routes/github-setup.js`);
|
|
67
|
+
let server, origin;
|
|
68
|
+
before(async () => {
|
|
69
|
+
const app = express();
|
|
70
|
+
app.use('/api/bongos', githubSetupRoutes());
|
|
71
|
+
server = app.listen(0);
|
|
72
|
+
await new Promise((r) => server.once('listening', r));
|
|
73
|
+
origin = `http://127.0.0.1:${server.address().port}`;
|
|
74
|
+
});
|
|
75
|
+
after(() => server && server.close());
|
|
76
|
+
|
|
77
|
+
const get = (p, who, extra = {}) => fetch(origin + p, { redirect: 'manual', headers: who ? { cookie: `who=${who}` } : {}, ...extra });
|
|
78
|
+
|
|
79
|
+
// ── the page code, sliced (each boundary asserted below) ──────────────────────────────
|
|
80
|
+
function region(startMark, endMark) {
|
|
81
|
+
const s = SCRIPT.indexOf(startMark);
|
|
82
|
+
const e = s < 0 ? -1 : SCRIPT.indexOf(endMark, s);
|
|
83
|
+
return s > 0 && e > s ? SCRIPT.slice(s, e) : null;
|
|
84
|
+
}
|
|
85
|
+
const POLLS = region(" /* ── C8: the wizard's polls run only while the create view is on stage", ' /* ── live build progress (task 2083)');
|
|
86
|
+
const WATCH_VARS = region(' var pollTimer = null;', " /* The done panel's own flow vocabularies");
|
|
87
|
+
const START_PROGRESS = (SCRIPT.match(/ {4}function startProgress\(id\) \{[\s\S]*?\n {4}\}/) || [null])[0];
|
|
88
|
+
const DOORS = region(" /* ── the hub's three doors ── */", ' return { enter: enter, leave: leave, onAuth: onAuth };');
|
|
89
|
+
|
|
90
|
+
test('the page regions are still findable (a rename must fail loudly, not test nothing)', () => {
|
|
91
|
+
assert.ok(POLLS, 'the wizard poll region (C8 → the manifest poll) was not found');
|
|
92
|
+
assert.ok(WATCH_VARS, 'the progress watch vars were not found');
|
|
93
|
+
assert.ok(START_PROGRESS, 'startProgress() was not found');
|
|
94
|
+
assert.ok(DOORS, "the hub's three doors (createdRecord → enter) were not found");
|
|
95
|
+
assert.match(POLLS, /var handbackProject = /, 'the hand-back names its project in the poll region');
|
|
96
|
+
assert.match(DOORS, /function askServerResume\(focus\)/, 'the server resume ask lives with enter()');
|
|
97
|
+
assert.doesNotMatch(POLLS + DOORS, /innerHTML/, 'no live DOM from strings');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// A miniature browser for the sliced page: empty storage (or storage that throws), the URL
|
|
101
|
+
// the founder landed on, a document whose `hidden` the test flips, recorded timers, and a
|
|
102
|
+
// fetch that goes to the REAL routes as the given builder.
|
|
103
|
+
function page({ url, who, storage = 'empty', name = '', hidden = false, instanceRead = null }) {
|
|
104
|
+
const u = new URL(url, 'https://hub.example');
|
|
105
|
+
const store = new Map();
|
|
106
|
+
const log = { show: [], progress: [], offers: [], fetched: [], timers: [], cleared: 0, resets: 0, rendered: 0 };
|
|
107
|
+
const listeners = {};
|
|
108
|
+
const inflight = [];
|
|
109
|
+
log.inflight = inflight;
|
|
110
|
+
const sessionStorage = storage === 'throws'
|
|
111
|
+
? { getItem() { throw new Error('denied'); }, setItem() { throw new Error('denied'); }, removeItem() { throw new Error('denied'); } }
|
|
112
|
+
: { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)), removeItem: (k) => store.delete(k) };
|
|
113
|
+
const el = () => ({ addEventListener() {}, focus() {}, hidden: true, textContent: '' });
|
|
114
|
+
const sandbox = {
|
|
115
|
+
API: '/api/bongos', DRAFT_KEY: 'cb-create-draft-v10', CREATED_KEY: 'cb-created-instance', DECLINED_KEY: 'cb-resume-declined',
|
|
116
|
+
location: { search: u.search, hash: u.hash },
|
|
117
|
+
URLSearchParams, JSON, String, Number, Math, Object, Date, encodeURIComponent, Promise,
|
|
118
|
+
document: { hidden, addEventListener: (ev, fn) => { (listeners[ev] = listeners[ev] || []).push(fn); } },
|
|
119
|
+
sessionStorage,
|
|
120
|
+
setTimeout: (fn, ms) => { log.timers.push({ fn, ms }); return log.timers.length; },
|
|
121
|
+
clearTimeout: () => { log.cleared += 1; },
|
|
122
|
+
fetch: (p, opts) => {
|
|
123
|
+
log.fetched.push(p);
|
|
124
|
+
const answer = (instanceRead && /\/provisioning\/instances\/\d+$/.test(p))
|
|
125
|
+
? Promise.resolve({ ok: true, status: 200, json: async () => instanceRead() })
|
|
126
|
+
: get(p, who, { method: (opts && opts.method) || 'GET' });
|
|
127
|
+
inflight.push(answer.then((r) => r.clone().text()).catch(() => null));
|
|
128
|
+
return answer;
|
|
129
|
+
},
|
|
130
|
+
$: el,
|
|
131
|
+
currentView: 'new', authKnown: true, me: { id: who, github_login: 'founder' },
|
|
132
|
+
state: { step: 0, name, slug: '', domain: '', mode: 'greenfield', owner: 'founder', repo: '', noAddress: false },
|
|
133
|
+
live: { instStatus: null, addrReady: false, manifest: null, manifestFailure: null, appSlug: null, setup: null, installUrl: null },
|
|
134
|
+
instanceId: null, watchPending: false,
|
|
135
|
+
show(step) { sandbox.state.step = step; log.show.push(step); },
|
|
136
|
+
renderDone() { log.rendered += 1; }, renderSteps() { log.rendered += 1; },
|
|
137
|
+
resetForNewProject() { log.resets += 1; },
|
|
138
|
+
saveDraft() {},
|
|
139
|
+
setProgress() {}, progressMarkup: () => '', setMoment() {}, renderProgress: () => false, addressReady: () => true,
|
|
140
|
+
};
|
|
141
|
+
sandbox.globalThis = sandbox;
|
|
142
|
+
vm.createContext(sandbox);
|
|
143
|
+
vm.runInContext([POLLS, WATCH_VARS, START_PROGRESS, DOORS].join('\n'), sandbox);
|
|
144
|
+
// the real startProgress runs too; record which project it watches
|
|
145
|
+
const realStart = sandbox.startProgress;
|
|
146
|
+
// the resume line is real markup (#wizResume); record what it is told to name
|
|
147
|
+
sandbox.paintResumeOffer = (rec) => { log.offers.push(rec ? rec.id : null); };
|
|
148
|
+
sandbox.startProgress = (id) => { log.progress.push(id); return realStart(id); };
|
|
149
|
+
return { sandbox, log, store, listeners, fire: (ev) => (listeners[ev] || []).forEach((fn) => fn()) };
|
|
150
|
+
}
|
|
151
|
+
// wait until every request the page made has answered and its handlers have run
|
|
152
|
+
async function settle(log) {
|
|
153
|
+
for (let round = 0; round < 20; round++) {
|
|
154
|
+
const seen = log.inflight.length;
|
|
155
|
+
await Promise.all(log.inflight.slice());
|
|
156
|
+
for (let i = 0; i < 6; i++) await new Promise((r) => setImmediate(r));
|
|
157
|
+
if (log.inflight.length === seen) return;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// The served hand-back page, run for real: its own config and script, no opener (a phone
|
|
162
|
+
// has no wizard tab behind it), and where it sends the browser.
|
|
163
|
+
async function handbackFromSetupRoute() {
|
|
164
|
+
const res = await get(`/api/bongos/provisioning/github-app/setup?installation_id=5&setup_action=install&state=${STATE}`);
|
|
165
|
+
const html = await res.text();
|
|
166
|
+
const cfg = JSON.parse((html.match(/window\.__CB_SETUP__=(\{[^<]*?\});/) || [])[1] || 'null');
|
|
167
|
+
const script = (html.match(/<script>\s*(\(function\(\)\{[\s\S]*?)<\/script>\s*<\/body>/) || [])[1];
|
|
168
|
+
assert.ok(cfg && script, 'the setup route serves the hand-back page with its config and script');
|
|
169
|
+
let replaced = null;
|
|
170
|
+
const timers = [];
|
|
171
|
+
const back = { href: '' };
|
|
172
|
+
const sb = { __CB_SETUP__: cfg, opener: null, close() {}, document: { getElementById: (id) => (id === 'back' ? back : null) }, location: { replace: (u) => { replaced = u; } }, setTimeout: (fn) => timers.push(fn), encodeURIComponent };
|
|
173
|
+
sb.window = sb;
|
|
174
|
+
vm.runInNewContext(script, sb);
|
|
175
|
+
while (timers.length) timers.shift()();
|
|
176
|
+
return { replaced, html };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
test('approved on a phone, empty storage: the real hand-back lands on the right project at part 03', async () => {
|
|
180
|
+
const { replaced, html } = await handbackFromSetupRoute();
|
|
181
|
+
assert.equal(replaced, `/api/bongos/provisioning/github-app/return?state=${STATE}`, 'the hand-back goes to the return route, not a bare create page');
|
|
182
|
+
assert.ok(html.includes(`href="/api/bongos/provisioning/github-app/return?state=${STATE}"`), 'the no-script link goes there too');
|
|
183
|
+
|
|
184
|
+
const ret = await get(replaced, 7);
|
|
185
|
+
assert.equal(ret.status, 302, 'the owner is redirected');
|
|
186
|
+
const landing = ret.headers.get('location');
|
|
187
|
+
assert.equal(landing, `/projects?view=new&id=42#ghstate=${STATE}`, 'to the project the SERVER remembers for the state');
|
|
188
|
+
assert.equal(ret.headers.get('cache-control'), 'no-store');
|
|
189
|
+
|
|
190
|
+
// the page, with NOTHING in this browser's storage
|
|
191
|
+
const { sandbox, log, store } = page({ url: landing, who: 7 });
|
|
192
|
+
assert.equal(store.size, 0, 'the browser starts with empty storage');
|
|
193
|
+
sandbox.enter(false); // what onAuth() does once /me answers
|
|
194
|
+
assert.deepEqual(log.show, [], 'a named hand-back waits for the server instead of painting another project');
|
|
195
|
+
await settle(log);
|
|
196
|
+
assert.ok(log.fetched.includes('/api/bongos/provisioning/github-app/resume?id=42'), JSON.stringify(log.fetched));
|
|
197
|
+
assert.deepEqual(log.show, [2], 'part 03 — the created page — is shown');
|
|
198
|
+
assert.equal(log.progress[0], 42, 'and it watches project 42');
|
|
199
|
+
assert.equal(sandbox.state.slug, 'mercury');
|
|
200
|
+
assert.equal(sandbox.state.repo, 'mercury', 'the install step names the repo the server knows');
|
|
201
|
+
assert.equal(sandbox.manifestState, STATE, 'the attempt from the fragment keeps being watched');
|
|
202
|
+
assert.ok(log.fetched.some((p) => p === `/api/bongos/provisioning/github-app/status?state=${STATE}`), 'the state-keyed checklist poll runs');
|
|
203
|
+
assert.equal(JSON.parse(store.get('cb-created-instance')).id, 42, 'a reload of this tab keeps the project');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('the same hand-back works where the browser refuses storage outright', async () => {
|
|
207
|
+
const { sandbox, log } = page({ url: `/projects?view=new&id=42#ghstate=${STATE}`, who: 7, storage: 'throws' });
|
|
208
|
+
sandbox.enter(false);
|
|
209
|
+
await settle(log);
|
|
210
|
+
assert.deepEqual(log.show, [2]);
|
|
211
|
+
assert.equal(log.progress[0], 42);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('a different signed-in builder is refused — never shown or landed on the project', async () => {
|
|
215
|
+
const ret = await get(`/api/bongos/provisioning/github-app/return?state=${STATE}`, 8);
|
|
216
|
+
const html = await ret.text();
|
|
217
|
+
assert.equal(ret.status, 404, 'a refusal, not a redirect');
|
|
218
|
+
assert.equal(ret.headers.get('location'), null);
|
|
219
|
+
assert.match(html, /This isn’t your project/);
|
|
220
|
+
assert.doesNotMatch(html, /mercury|view=new&id=|42/, 'the page names no project');
|
|
221
|
+
const unknown = await (await get(`/api/bongos/provisioning/github-app/return?state=${'cd34'.repeat(8)}`, 8)).text();
|
|
222
|
+
assert.equal(unknown, html, 'an unknown state reads the same (no oracle about which)');
|
|
223
|
+
const malformed = await get('/api/bongos/provisioning/github-app/return?state=../../etc', 7);
|
|
224
|
+
assert.equal(malformed.status, 404);
|
|
225
|
+
|
|
226
|
+
// and the resume read gives them nothing either, even for a crafted ?id=42
|
|
227
|
+
const j = await (await get('/api/bongos/provisioning/github-app/resume?id=42', 8)).json();
|
|
228
|
+
assert.equal(j.project, null);
|
|
229
|
+
const { sandbox, log } = page({ url: '/projects?view=new&id=42', who: 8 });
|
|
230
|
+
sandbox.enter(false);
|
|
231
|
+
await settle(log);
|
|
232
|
+
assert.ok(!log.progress.includes(42), 'the page never watches a project that is not theirs');
|
|
233
|
+
assert.ok(!log.show.includes(2), 'and never opens its part 03');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('not signed in → through sign-in, then back to the same return', async () => {
|
|
237
|
+
const ret = await get(`/api/bongos/provisioning/github-app/return?state=${STATE}`, null);
|
|
238
|
+
assert.equal(ret.status, 302);
|
|
239
|
+
const loc = ret.headers.get('location');
|
|
240
|
+
const u = new URL(loc, 'https://hub.example');
|
|
241
|
+
assert.equal(u.pathname, '/api/bongos/auth/web/start');
|
|
242
|
+
assert.equal(u.searchParams.get('return'), `/api/bongos/provisioning/github-app/return?state=${STATE}`, 'the return target is the return route itself');
|
|
243
|
+
assert.equal(u.searchParams.get('repos'), '1', 'with the hub sign-in door’s repo scope');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('the state stays single-use where it is used: the return route changes nothing', async () => {
|
|
247
|
+
let writes = 0;
|
|
248
|
+
const prev = provisioning.attachManifestCode;
|
|
249
|
+
provisioning.attachManifestCode = async () => { writes += 1; return null; };
|
|
250
|
+
const a = await get(`/api/bongos/provisioning/github-app/return?state=${STATE}`, 7);
|
|
251
|
+
const b = await get(`/api/bongos/provisioning/github-app/return?state=${STATE}`, 7);
|
|
252
|
+
provisioning.attachManifestCode = prev;
|
|
253
|
+
assert.equal(writes, 0, 'no code exchange, no status change');
|
|
254
|
+
assert.equal(a.headers.get('location'), b.headers.get('location'), 'the owner can come back again; nobody else can at all');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('opening the create page later, from any device, resumes part 03 from the server’s view', async () => {
|
|
258
|
+
const later = page({ url: '/projects?view=new', who: 7 });
|
|
259
|
+
later.sandbox.enter(false);
|
|
260
|
+
await settle(later.log);
|
|
261
|
+
assert.ok(later.log.fetched.includes('/api/bongos/provisioning/github-app/resume'));
|
|
262
|
+
assert.deepEqual(later.log.show.slice(-1), [2], 'the unfinished project comes back');
|
|
263
|
+
assert.equal(later.log.progress[0], 42);
|
|
264
|
+
|
|
265
|
+
// someone typing a new project keeps their page; the project waits on the resume line
|
|
266
|
+
const typing = page({ url: '/projects?view=new', who: 7, name: 'Second idea' });
|
|
267
|
+
typing.sandbox.enter(false);
|
|
268
|
+
await settle(typing.log);
|
|
269
|
+
assert.ok(!typing.log.progress.includes(42));
|
|
270
|
+
assert.deepEqual(typing.log.offers.filter(Boolean), [42]);
|
|
271
|
+
|
|
272
|
+
// a deliberate "New project" only offers it
|
|
273
|
+
const nav = page({ url: '/projects?view=new', who: 7 });
|
|
274
|
+
nav.sandbox.enter(true);
|
|
275
|
+
await settle(nav.log);
|
|
276
|
+
assert.ok(!nav.log.progress.includes(42));
|
|
277
|
+
assert.deepEqual(nav.log.offers.filter(Boolean), [42]);
|
|
278
|
+
|
|
279
|
+
// "Start another project" said no to it in this tab
|
|
280
|
+
const declined = page({ url: '/projects?view=new', who: 7 });
|
|
281
|
+
declined.store.set('cb-resume-declined', '42');
|
|
282
|
+
declined.sandbox.enter(false);
|
|
283
|
+
await settle(declined.log);
|
|
284
|
+
assert.ok(!declined.log.progress.includes(42));
|
|
285
|
+
|
|
286
|
+
// a connected project is not "in progress"; nor is anything for a builder with none
|
|
287
|
+
githubSetup.githubSetupFacts = async () => ({ installedAt: NOW });
|
|
288
|
+
const prevAttempt = { ...ATTEMPT };
|
|
289
|
+
Object.assign(ATTEMPT, { status: 'done' });
|
|
290
|
+
const done = await (await get('/api/bongos/provisioning/github-app/resume', 7)).json();
|
|
291
|
+
Object.assign(ATTEMPT, prevAttempt);
|
|
292
|
+
githubSetup.githubSetupFacts = async () => ({});
|
|
293
|
+
assert.equal(done.project, null, 'a project whose GitHub is connected is not resumed');
|
|
294
|
+
const none = await (await get('/api/bongos/provisioning/github-app/resume', 9)).json();
|
|
295
|
+
assert.equal(none.project, null);
|
|
296
|
+
const anon = await get('/api/bongos/provisioning/github-app/resume', null);
|
|
297
|
+
assert.equal(anon.status, 401, 'signed-in only');
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test('a hidden tab keeps checking at the slow pace, and checks at once when seen again', async () => {
|
|
301
|
+
const { sandbox, log, fire } = page({ url: `/projects?view=new&id=42#ghstate=${STATE}`, who: 7, hidden: true });
|
|
302
|
+
sandbox.pollManifest(STATE);
|
|
303
|
+
await settle(log);
|
|
304
|
+
assert.equal(log.fetched.filter((p) => /github-app\/status/.test(p)).length, 1, 'the hidden tab still asks — it does not park');
|
|
305
|
+
assert.equal(log.timers.length, 1);
|
|
306
|
+
assert.equal(log.timers[0].ms, 20000, 'and asks again at the slow pace');
|
|
307
|
+
assert.equal(sandbox.manifestPending, false, 'nothing is parked');
|
|
308
|
+
|
|
309
|
+
// the founder comes back to the laptop: the waiting tick runs now, not in 20s
|
|
310
|
+
sandbox.document.hidden = false;
|
|
311
|
+
fire('visibilitychange');
|
|
312
|
+
await settle(log);
|
|
313
|
+
assert.equal(log.cleared, 1, 'the slow timer is dropped');
|
|
314
|
+
assert.equal(log.fetched.filter((p) => /github-app\/status/.test(p)).length, 2, 'and the check runs immediately');
|
|
315
|
+
assert.equal(log.timers[log.timers.length - 1].ms, 4000, 'then the normal pace');
|
|
316
|
+
|
|
317
|
+
// off the create view the poll still parks (C8)
|
|
318
|
+
sandbox.currentView = 'map';
|
|
319
|
+
log.timers[log.timers.length - 1].fn();
|
|
320
|
+
assert.equal(sandbox.manifestPending, true);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('the laptop left waiting ticks on its own while the phone approves (the owner read, hidden tab)', async () => {
|
|
324
|
+
let installed = false;
|
|
325
|
+
const instanceRead = () => ({
|
|
326
|
+
instance: { ...MINE },
|
|
327
|
+
github_app: { status: 'done', app_slug: 'mercury-app' },
|
|
328
|
+
github_setup: { checklist: { approve: 'done', install: installed ? 'done' : 'now', sign_in: 'done', device_flow: 'later', connected: installed }, install_url: null },
|
|
329
|
+
});
|
|
330
|
+
const { sandbox, log } = page({ url: '/projects?view=new', who: 7, hidden: true, instanceRead });
|
|
331
|
+
sandbox.startProgress(42);
|
|
332
|
+
await settle(log);
|
|
333
|
+
assert.equal(sandbox.live.setup.install, 'now');
|
|
334
|
+
assert.equal(log.timers.length, 1, 'still watching while hidden');
|
|
335
|
+
assert.equal(log.timers[0].ms, 20000, 'at the slow pace');
|
|
336
|
+
installed = true; // the phone installed the app; the runner verified it
|
|
337
|
+
log.timers[0].fn();
|
|
338
|
+
await settle(log);
|
|
339
|
+
assert.equal(sandbox.live.setup.install, 'done', 'the checklist ticked without anyone touching the laptop');
|
|
340
|
+
});
|