@bongos/core 1.19.204 → 1.19.206
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 +29 -29
- package/.claude/skills/new-project/SKILL.md +3 -1
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0055-server-mediated-branch-publish.md +2 -0
- package/docs/adr/0133-github-app-manifest-onboarding-signin.md +2 -2
- package/docs/api/openapi.json +4 -2
- package/docs/recipes/ops-gotchas.md +8 -1
- package/modules/lifecycle/github-push.js +130 -18
- package/modules/lifecycle/publish-reconciler.js +44 -1
- package/modules/lifecycle/routes/tasks.js +21 -4
- package/modules/provisioning/onboard-plan.js +1 -0
- package/modules/provisioning/provisioning.js +18 -8
- package/modules/provisioning/routes/provisioning.js +4 -0
- package/modules/provisioning/tests/provisioning.mjs +5 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/projects.html +19 -2
- package/scripts/gds/onboard.js +11 -3
- package/scripts/gds/provision.js +27 -4
- package/src/module-api.js +5 -1
- package/tests/onboard.mjs +5 -1
- package/tests/provision.mjs +30 -2
- package/tests/publish_branch_route.mjs +153 -2
- package/tests/publish_reconciler.mjs +63 -0
- package/tests/tool_scan_engine.mjs +13 -0
|
@@ -1552,6 +1552,21 @@ module.exports = function buildTasksRouter() {
|
|
|
1552
1552
|
}
|
|
1553
1553
|
});
|
|
1554
1554
|
|
|
1555
|
+
// Plain-sentence publish-credential refusals (task 1003049 / gap-list W7). A
|
|
1556
|
+
// bare 'push_unconfigured' left fresh-instance owners with no idea what was
|
|
1557
|
+
// missing while every ship silently piled at 'confirmed' — the refusal must
|
|
1558
|
+
// say what to set up and where.
|
|
1559
|
+
const PUSH_UNCONFIGURED_MSG =
|
|
1560
|
+
'This project has no publish credential, so the server cannot push or merge task branches and shipped work waits at confirmed. ' +
|
|
1561
|
+
"To fix it, install the project's GitHub App on the repo (the onboarding “Install the GitHub App” step) with GITHUB_APP_ID + GITHUB_APP_PRIVATE_KEY set in the server env, " +
|
|
1562
|
+
'or set a fine-grained GITHUB_PUSH_TOKEN PAT there instead (ADR 0055).';
|
|
1563
|
+
const appNotInstalledMessage = (e) => {
|
|
1564
|
+
const repo = e && e.owner && e.repo ? `${e.owner}/${e.repo}` : 'the project repository';
|
|
1565
|
+
const where = e && e.install_url ? `at ${e.install_url}` : "from the App's page in GitHub settings (Install App)";
|
|
1566
|
+
return `The project's GitHub App is configured but not installed on ${repo}, so the server cannot push or merge task branches. ` +
|
|
1567
|
+
`Install it ${where}, then retry — no other setup is needed.`;
|
|
1568
|
+
};
|
|
1569
|
+
|
|
1555
1570
|
// ==========================================================================
|
|
1556
1571
|
// Server-mediated branch publish (ADR 0055 / task 1025)
|
|
1557
1572
|
// --------------------------------------------------------------------------
|
|
@@ -1584,8 +1599,8 @@ module.exports = function buildTasksRouter() {
|
|
|
1584
1599
|
// (or an Archon) may publish this task's branch.
|
|
1585
1600
|
if (!(await gateTaskOwnership(req, res, id))) return;
|
|
1586
1601
|
if (!githubPush.isConfigured()) {
|
|
1587
|
-
return res.fail('push_unconfigured', { status: 503, message:
|
|
1588
|
-
'
|
|
1602
|
+
return res.fail('push_unconfigured', { status: 503, message: PUSH_UNCONFIGURED_MSG +
|
|
1603
|
+
' Meanwhile you can ship from a machine with its own git push credentials.' });
|
|
1589
1604
|
}
|
|
1590
1605
|
const { branch, head_sha: headSha, value_summary: valueSummary } = req.body;
|
|
1591
1606
|
if (!githubPush.isSafeBranch(branch)) return res.fail('bad_branch', 400);
|
|
@@ -1647,7 +1662,8 @@ module.exports = function buildTasksRouter() {
|
|
|
1647
1662
|
if (e.code === 'BAD_BRANCH') return res.fail('bad_branch', 400);
|
|
1648
1663
|
if (e.code === 'BAD_SHA') return res.fail('bad_head_sha', 400);
|
|
1649
1664
|
if (e.code === 'NO_REPO_INFO') return res.fail('no_repo_info', 500);
|
|
1650
|
-
if (e.code === 'PUSH_UNCONFIGURED') return res.fail('push_unconfigured', 503);
|
|
1665
|
+
if (e.code === 'PUSH_UNCONFIGURED') return res.fail('push_unconfigured', { status: 503, message: PUSH_UNCONFIGURED_MSG });
|
|
1666
|
+
if (e.code === 'APP_NOT_INSTALLED') return res.fail('push_unconfigured', { status: 503, message: appNotInstalledMessage(e) });
|
|
1651
1667
|
if (e.code === 'PR_CREATE_FAILED') return res.fail('pr_create_failed', 502, { detail: e.detail });
|
|
1652
1668
|
// task 1547: GIT_FAILED now carries WHICH git step failed + the credential-
|
|
1653
1669
|
// scrubbed stderr (github-push.defaultExec). Log both so the next push outage
|
|
@@ -1672,7 +1688,7 @@ module.exports = function buildTasksRouter() {
|
|
|
1672
1688
|
const id = parseId(req, res, { code: 'bad_task_id' });
|
|
1673
1689
|
if (id === null) return;
|
|
1674
1690
|
if (!(await gateTaskOwnership(req, res, id))) return;
|
|
1675
|
-
if (!githubPush.isConfigured()) return res.fail('push_unconfigured', 503);
|
|
1691
|
+
if (!githubPush.isConfigured()) return res.fail('push_unconfigured', { status: 503, message: PUSH_UNCONFIGURED_MSG });
|
|
1676
1692
|
const branch = req.query.branch;
|
|
1677
1693
|
if (!githubPush.isSafeBranch(branch)) return res.fail('bad_branch', 400);
|
|
1678
1694
|
try {
|
|
@@ -1739,6 +1755,7 @@ module.exports = function buildTasksRouter() {
|
|
|
1739
1755
|
return res.json({ ok: true, ...status, reconciled, reconcile_blocked, merge_driver });
|
|
1740
1756
|
} catch (e) {
|
|
1741
1757
|
if (e.code === 'NO_REPO_INFO') return res.fail('no_repo_info', 500);
|
|
1758
|
+
if (e.code === 'APP_NOT_INSTALLED') return res.fail('push_unconfigured', { status: 503, message: appNotInstalledMessage(e) });
|
|
1742
1759
|
console.error('[gds] GET /tasks/:id/publish-status', e.code || e.message);
|
|
1743
1760
|
return res.fail('status_failed', 502);
|
|
1744
1761
|
}
|
|
@@ -44,6 +44,7 @@ function onboardPlan(mode = 'greenfield', opts = {}) {
|
|
|
44
44
|
{ key: 'seed-version', kind: 'auto', label: 'Seed the first version (V1) so the instance can take tasks immediately (task 2119)' },
|
|
45
45
|
{ key: 'oauth-app', kind: 'human', label: 'Wire GitHub sign-in — one-click GitHub App (task 2080), or a manual OAuth app' },
|
|
46
46
|
{ key: 'oauth-place', kind: 'auto', shapes: CONTROL_PLANE_SHAPES, label: 'Place the OAuth secret — auto via the Manifest flow, or verified manual (task 1979)' },
|
|
47
|
+
{ key: 'app-install', kind: 'human', label: 'Install the GitHub App on the repo — the publish credential that lets shipped work land itself (task 1003049, ADR 0055)' },
|
|
47
48
|
{ key: 'first-sign-in', kind: 'human', label: 'First sign-in (seats the founding Archon)' },
|
|
48
49
|
{ key: 'verify', kind: 'auto', label: 'Verify /healthz auth_configured' },
|
|
49
50
|
{ key: 'brand', kind: 'auto', label: 'Brand the instance look' },
|
|
@@ -265,16 +265,21 @@ function githubAppName({ domain, brand, project } = {}) {
|
|
|
265
265
|
// • callback_urls — where "Login with GitHub" on the INSTANCE redirects during real
|
|
266
266
|
// sign-in. Both /api/gds/... (what auth.js actually builds) and the /api/bongos/...
|
|
267
267
|
// alias are registered so sign-in works regardless of which prefix a build emits.
|
|
268
|
-
//
|
|
268
|
+
// No webhook (active:false). PURE.
|
|
269
269
|
function buildGithubAppManifest({ instanceName, slug, instanceDomain, mothershipOrigin, brand }) {
|
|
270
270
|
const instOrigin = `https://${instanceDomain}`;
|
|
271
271
|
const redirectUrl = `${String(mothershipOrigin || '').replace(/\/+$/, '')}/api/bongos/provisioning/github-app/callback`;
|
|
272
272
|
return {
|
|
273
273
|
name: githubAppName({ domain: instanceDomain, brand, project: instanceName || slug }),
|
|
274
274
|
url: instOrigin,
|
|
275
|
-
// Sign-in
|
|
276
|
-
//
|
|
277
|
-
|
|
275
|
+
// Sign-in itself needs no permissions (GET /user with a user-to-server token),
|
|
276
|
+
// but this App DOUBLES as the instance's ADR 0055 publish credential (task
|
|
277
|
+
// 1003049 / gap-list W7): once installed on the repo, the server mints
|
|
278
|
+
// installation tokens to push task branches, open/merge PRs, and read deploy
|
|
279
|
+
// runs — without them every ship strands at 'confirmed' behind manual archon
|
|
280
|
+
// merges. Exactly the ADR 0055 scopes, nothing more; the App still cannot
|
|
281
|
+
// push to branch-protected main nor bypass required checks.
|
|
282
|
+
default_permissions: { contents: 'write', pull_requests: 'write', actions: 'read' },
|
|
278
283
|
default_events: [],
|
|
279
284
|
// No webhook: active:false. A url is still supplied (GitHub validates the shape) but
|
|
280
285
|
// is never called while inactive; point it at the mothership so it's a real host.
|
|
@@ -286,10 +291,15 @@ function buildGithubAppManifest({ instanceName, slug, instanceDomain, mothership
|
|
|
286
291
|
],
|
|
287
292
|
// public: GitHub answers a PRIVATE App's web authorize URL for any non-owner
|
|
288
293
|
// account with a bare 404 (before the instance is ever involved), so a private
|
|
289
|
-
// App makes the project structurally single-builder on the web
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
294
|
+
// App makes the project structurally single-builder on the web (gap-list W6,
|
|
295
|
+
// task 1003048). TRADE-OFF now that the App carries write scopes: public means
|
|
296
|
+
// any third party can install it on THEIR repos, and a leaked App private key's
|
|
297
|
+
// blast radius includes those installs — the server itself only ever uses its
|
|
298
|
+
// own repo's installation, but the credential could reach further if compromised.
|
|
299
|
+
// Accepted to keep W6; mitigated by the publish-reconciler's foreign-install
|
|
300
|
+
// watch (github-push.foreignInstallations), which warns whenever the App is
|
|
301
|
+
// installed anywhere beyond the repo owner. Apps minted before the W6 fix stay
|
|
302
|
+
// private until flipped by hand — see docs/recipes/ops-gotchas.md.
|
|
293
303
|
public: true,
|
|
294
304
|
};
|
|
295
305
|
}
|
|
@@ -892,6 +892,10 @@ module.exports = function provisioningRoutes() {
|
|
|
892
892
|
terminal: status === 'done' || status === 'error' || status === 'expired',
|
|
893
893
|
domain: inst && inst.domain ? inst.domain : null,
|
|
894
894
|
instance_status: inst ? inst.status : null,
|
|
895
|
+
// The App's public slug (task 1003049): lets the wizard render the one-click
|
|
896
|
+
// install link (github.com/apps/<slug>/installations/new) for the app-install
|
|
897
|
+
// step. Public info — the App's page is public by design (task 1003048).
|
|
898
|
+
app_slug: row.app_slug || null,
|
|
895
899
|
});
|
|
896
900
|
} catch (err) {
|
|
897
901
|
console.error('[provisioning] GET /provisioning/github-app/status', err);
|
|
@@ -165,7 +165,7 @@ t('githubAppName falls back to brand-qualified project when there is no usable d
|
|
|
165
165
|
assert.ok(long.length <= 34 && long.startsWith('Cloud Bongos - '), `capped + brand kept: ${long}`);
|
|
166
166
|
});
|
|
167
167
|
|
|
168
|
-
t('buildGithubAppManifest points redirect at the MOTHERSHIP, callbacks at the INSTANCE,
|
|
168
|
+
t('buildGithubAppManifest points redirect at the MOTHERSHIP, callbacks at the INSTANCE, ADR 0055 publish scopes, public, domain-named', () => {
|
|
169
169
|
const m = P.buildGithubAppManifest({
|
|
170
170
|
instanceName: 'mercury', slug: 'mercury', brand: 'Cloud Bongos',
|
|
171
171
|
instanceDomain: 'mercury.example.com', mothershipOrigin: 'https://cloudbongos.com',
|
|
@@ -179,8 +179,10 @@ t('buildGithubAppManifest points redirect at the MOTHERSHIP, callbacks at the IN
|
|
|
179
179
|
'https://mercury.example.com/api/gds/auth/web/callback',
|
|
180
180
|
'https://mercury.example.com/api/bongos/auth/web/callback',
|
|
181
181
|
]);
|
|
182
|
-
//
|
|
183
|
-
|
|
182
|
+
// The App doubles as the ADR 0055 publish credential (task 1003049): exactly the
|
|
183
|
+
// three publish scopes — Contents rw (push branches), PR rw (open/merge), Actions
|
|
184
|
+
// read (deploy probe) — and nothing more. No active webhook.
|
|
185
|
+
assert.deepEqual(m.default_permissions, { contents: 'write', pull_requests: 'write', actions: 'read' });
|
|
184
186
|
assert.equal(m.hook_attributes.active, false);
|
|
185
187
|
// public: a PRIVATE App answers the web authorize URL for any non-owner with a bare
|
|
186
188
|
// GitHub 404, making the project single-builder on the web (gap-list W6, task 1003048)
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.206",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.206",
|
|
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.19.
|
|
3
|
+
"version": "1.19.206",
|
|
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",
|
|
@@ -2768,7 +2768,7 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
2768
2768
|
var GH_ICON = '<svg class="gh" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>';
|
|
2769
2769
|
|
|
2770
2770
|
var instanceId = null; /* set when a create lands / a resumed watch starts */
|
|
2771
|
-
var live = { instStatus: null, manifest: null, manifestMsg: '' };
|
|
2771
|
+
var live = { instStatus: null, manifest: null, manifestMsg: '', appSlug: null };
|
|
2772
2772
|
var planPromises = {}; /* mode|domain → Promise<steps[]> */
|
|
2773
2773
|
var lastStepsHtml = '';
|
|
2774
2774
|
var FALLBACK_HUMAN_KEYS = ['github-repo', 'oauth-app', 'first-sign-in', 'enter-hall'];
|
|
@@ -2861,6 +2861,21 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
2861
2861
|
'<button type="button" class="ghbtn" data-action="manifest-start">' + GH_ICON + 'Set up GitHub sign-in</button>' + manualOauthDetails() + '</li>';
|
|
2862
2862
|
}
|
|
2863
2863
|
|
|
2864
|
+
/* app-install — the ADR 0055 publish credential (task 1003049): the sign-in App
|
|
2865
|
+
doubles as it, but only an INSTALLED App can mint push tokens; without this
|
|
2866
|
+
one click every finished task waits at confirmed for a manual merge. */
|
|
2867
|
+
function renderStepAppInstall() {
|
|
2868
|
+
if (live.manifest !== 'done') {
|
|
2869
|
+
return '<li class="slocked" data-onboard-step="app-install"><b>Install the GitHub App on your repo</b><p>Unlocks once GitHub sign-in above is wired — the same app, installed on your repo, is what lets finished work merge and ship itself.</p></li>';
|
|
2870
|
+
}
|
|
2871
|
+
var installUrl = live.appSlug ? 'https://github.com/apps/' + esc(live.appSlug) + '/installations/new' : null;
|
|
2872
|
+
return '<li data-onboard-step="app-install"><b>Install the GitHub App on your repo</b><p>One click: install the app you just approved on this project’s repo. Without it, finished work waits for a manual merge instead of shipping itself.</p>' +
|
|
2873
|
+
(installUrl
|
|
2874
|
+
? '<a class="ghbtn" href="' + installUrl + '" target="_blank" rel="noopener">' + GH_ICON + 'Install the app on your repo</a>'
|
|
2875
|
+
: '<p>GitHub → Settings → Developer settings → GitHub Apps → your app → <strong>Install App</strong> → pick this project’s repo.</p>') +
|
|
2876
|
+
'</li>';
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2864
2879
|
function renderStepFirstSignIn() {
|
|
2865
2880
|
var domain = resolvedDomain();
|
|
2866
2881
|
if (!domain) {
|
|
@@ -2879,6 +2894,7 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
2879
2894
|
var STEP_RENDERERS = {
|
|
2880
2895
|
'github-repo': renderStepGithubRepo,
|
|
2881
2896
|
'oauth-app': renderStepOauthApp,
|
|
2897
|
+
'app-install': renderStepAppInstall,
|
|
2882
2898
|
'first-sign-in': renderStepFirstSignIn,
|
|
2883
2899
|
'enter-hall': renderStepEnterHall
|
|
2884
2900
|
};
|
|
@@ -3007,6 +3023,7 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
3007
3023
|
.then(function (res) { if (!res.ok) throw new Error('soft'); return res.json(); })
|
|
3008
3024
|
.then(function (d) {
|
|
3009
3025
|
if (myGen !== manifestGen) return;
|
|
3026
|
+
if (d.app_slug && d.app_slug !== live.appSlug) { live.appSlug = d.app_slug; renderSteps(); }
|
|
3010
3027
|
var next = d.ready ? 'done' : (d.status || 'pending');
|
|
3011
3028
|
if (next !== live.manifest) { live.manifest = next; renderSteps(); }
|
|
3012
3029
|
if (!d.terminal) manifestPollTimer = setTimeout(tick, 4000);
|
|
@@ -3187,7 +3204,7 @@ h1[tabindex="-1"]:focus{outline:none;}
|
|
|
3187
3204
|
stopManifestPoll();
|
|
3188
3205
|
instanceId = null;
|
|
3189
3206
|
watchPending = false; manifestPending = false; manifestState = null;
|
|
3190
|
-
live = { instStatus: null, manifest: null, manifestMsg: '' };
|
|
3207
|
+
live = { instStatus: null, manifest: null, manifestMsg: '', appSlug: null };
|
|
3191
3208
|
lastStepsHtml = '';
|
|
3192
3209
|
try { sessionStorage.removeItem(CREATED_KEY); sessionStorage.removeItem(DRAFT_KEY); } catch (e) {}
|
|
3193
3210
|
state = { step: 1, name: '', projType: null, mode: 'greenfield', owner: '', ownerTouched: false, repo: '', repoTouched: false, slug: '', slugTouched: false, domain: '', noAddress: false };
|
package/scripts/gds/onboard.js
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
// provision → OAuth → verify → brand legs. Standalone model only (ADR 0108).
|
|
10
10
|
//
|
|
11
11
|
// It automates what it can (DNS token preflight, OAuth-cred validation, /healthz
|
|
12
|
-
// verify) and renders the
|
|
13
|
-
// OAuth app + secret, own the domain, first sign-in)
|
|
14
|
-
// prompts. It does NOT hold cloud tokens: the actual provision runs on the control
|
|
12
|
+
// verify) and renders the irreducibly-human steps (create the GitHub repo, the
|
|
13
|
+
// OAuth app + secret, install the App on the repo, own the domain, first sign-in)
|
|
14
|
+
// as precise, copy-paste-safe prompts. It does NOT hold cloud tokens: the actual provision runs on the control
|
|
15
15
|
// plane (it prints the exact `provision.js` command), preserving the trust boundary.
|
|
16
16
|
|
|
17
17
|
const readline = require('node:readline');
|
|
@@ -189,6 +189,14 @@ async function run() {
|
|
|
189
189
|
log(' (validates + rewrites web.env via Node + restarts + probes /healthz auth_configured — never hand-edit web.env.)');
|
|
190
190
|
await ask(rl, 'Press Enter once oauth-secret reports auth_configured=true…');
|
|
191
191
|
|
|
192
|
+
// The publish credential (task 1003049, ADR 0055): the one-click App carries the
|
|
193
|
+
// repo scopes, but only an INSTALLED App can mint push tokens — skip this click
|
|
194
|
+
// and every ship strands at 'confirmed' behind a manual merge.
|
|
195
|
+
log('\n Install the GitHub App on the repo — the publish credential that lets shipped work land itself:');
|
|
196
|
+
log(' github.com → Settings → Developer settings → GitHub Apps → your app → Install App → pick this project’s repo.');
|
|
197
|
+
log(' (On the manual OAuth-app path instead? Set a fine-grained GITHUB_PUSH_TOKEN in web.env — ADR 0055.)');
|
|
198
|
+
await ask(rl, 'Press Enter once the App is installed on the repo…');
|
|
199
|
+
|
|
192
200
|
// Step 8/9 — first sign-in + verify
|
|
193
201
|
log('\n⑦ First sign-in (seats the founding Archon):');
|
|
194
202
|
log(` Open ${oauthAppInstructions(a.domain).homepageUrl} and sign in with GitHub once.`);
|
package/scripts/gds/provision.js
CHANGED
|
@@ -1021,7 +1021,7 @@ function provisionedEnvPrefix() {
|
|
|
1021
1021
|
// the hall on this host + OAuth uses the real callback origin — without them the pack
|
|
1022
1022
|
// stays at neutral localhost and the hall never loads (task 1972). No domain yet
|
|
1023
1023
|
// (bring-your-own deferred, ADR 0111 §4) ⇒ origins stay neutral until one is set.
|
|
1024
|
-
function webEnvBody(inst, { clientId = '', clientSecret = '', federation = null } = {}) {
|
|
1024
|
+
function webEnvBody(inst, { clientId = '', clientSecret = '', appId = null, appPem = null, federation = null } = {}) {
|
|
1025
1025
|
const prefix = provisionedEnvPrefix();
|
|
1026
1026
|
const origin = inst.domain ? `https://${inst.domain}` : '';
|
|
1027
1027
|
const originLines = origin
|
|
@@ -1077,6 +1077,19 @@ ${prefix}_HUB_ORIGIN=${federation.hubOrigin}
|
|
|
1077
1077
|
${prefix}_HUB_CLIENT_ID=${federation.clientId}
|
|
1078
1078
|
${prefix}_HUB_CLIENT_SECRET=${federation.clientSecret}
|
|
1079
1079
|
${prefix}_HUB_PUBLIC_KEY=${federation.hubPublicKey}
|
|
1080
|
+
`
|
|
1081
|
+
: '';
|
|
1082
|
+
// The publish credential (task 1003049, ADR 0055): the manifest-born App's id +
|
|
1083
|
+
// private key, read BARE (no prefix) by modules/lifecycle/github-push.js. The
|
|
1084
|
+
// pem is env-file-flattened to a single line with literal \n (the ADR 0055
|
|
1085
|
+
// rotation convention; normalizePem restores it). The installation id is NOT
|
|
1086
|
+
// written — the server discovers it from the repo once the owner installs the
|
|
1087
|
+
// App (the onboarding app-install step).
|
|
1088
|
+
const appLines = appId && appPem
|
|
1089
|
+
? `# Publish credential (ADR 0055): the sign-in GitHub App doubles as it. The server
|
|
1090
|
+
# mints short-lived installation tokens once the App is INSTALLED on the repo.
|
|
1091
|
+
GITHUB_APP_ID=${appId}
|
|
1092
|
+
GITHUB_APP_PRIVATE_KEY=${String(appPem).replace(/\r?\n/g, '\\n')}
|
|
1080
1093
|
`
|
|
1081
1094
|
: '';
|
|
1082
1095
|
return `# /etc/${inst.slug}/web.env — per-instance secrets (chmod 600, OUTSIDE the repo).
|
|
@@ -1084,7 +1097,7 @@ ${credComment}
|
|
|
1084
1097
|
${prefix}_GITHUB_CLIENT_ID=${clientId || ''}
|
|
1085
1098
|
${prefix}_GITHUB_CLIENT_SECRET=${clientSecret || ''}
|
|
1086
1099
|
${firstAdminLine}GDS_BRANDING_FILE=${PROVISIONED_BRANDING_FILE}
|
|
1087
|
-
${originLines}PORT=${inst.port}
|
|
1100
|
+
${originLines}${appLines}PORT=${inst.port}
|
|
1088
1101
|
PGDATABASE=${dbName(inst)}
|
|
1089
1102
|
${fedLines}`;
|
|
1090
1103
|
}
|
|
@@ -1948,6 +1961,10 @@ async function convertManifestCode(code, { fetchImpl = globalThis.fetch, userAge
|
|
|
1948
1961
|
clientSecret: String(data.client_secret),
|
|
1949
1962
|
appId: data.id != null ? Number(data.id) : null,
|
|
1950
1963
|
appSlug: data.slug || null,
|
|
1964
|
+
// The App private key (task 1003049): with the manifest now requesting the
|
|
1965
|
+
// ADR 0055 repo scopes, this pem + the app id ARE the instance's publish
|
|
1966
|
+
// credential — placed into web.env alongside the OAuth creds, never logged.
|
|
1967
|
+
appPem: data.pem ? String(data.pem) : null,
|
|
1951
1968
|
};
|
|
1952
1969
|
}
|
|
1953
1970
|
|
|
@@ -1973,7 +1990,7 @@ async function placeManifestCreds(inst, mrow, deps) {
|
|
|
1973
1990
|
creds = await (deps.convertManifestCode || convertManifestCode)(mrow.github_code, {});
|
|
1974
1991
|
} else {
|
|
1975
1992
|
log(` [oauth-manifest] would POST api.github.com/app-manifests/<code>/conversions for ${inst.slug}`);
|
|
1976
|
-
creds = { clientId: '0'.repeat(20), clientSecret: '0'.repeat(40), appId: null, appSlug: null };
|
|
1993
|
+
creds = { clientId: '0'.repeat(20), clientSecret: '0'.repeat(40), appId: null, appSlug: null, appPem: null };
|
|
1977
1994
|
}
|
|
1978
1995
|
|
|
1979
1996
|
// 2) validate the returned creds BEFORE writing (catch a malformed GitHub response —
|
|
@@ -1991,7 +2008,13 @@ async function placeManifestCreds(inst, mrow, deps) {
|
|
|
1991
2008
|
// 3) rewrite web.env with the creds + restart the unit (never logs the secret — the
|
|
1992
2009
|
// writeFile logs only a byte count; the value is base64/temp-file placed).
|
|
1993
2010
|
log(` [oauth-manifest] place creds in ${webEnvPath(inst)} + restart ${inst.slug}`);
|
|
1994
|
-
boxWriteFile(webEnvPath(inst), webEnvBody(inst, {
|
|
2011
|
+
boxWriteFile(webEnvPath(inst), webEnvBody(inst, {
|
|
2012
|
+
clientId: creds.clientId, clientSecret: creds.clientSecret,
|
|
2013
|
+
// The App id + private key double as the ADR 0055 publish credential (task
|
|
2014
|
+
// 1003049); older Apps (pre-permission manifests) return a pem too — placing
|
|
2015
|
+
// it is harmless and heals once the owner grants the permissions + installs.
|
|
2016
|
+
appId: creds.appId, appPem: creds.appPem,
|
|
2017
|
+
}), { mode: 0o600, sudo: true });
|
|
1995
2018
|
boxExec(`${sudoP}systemctl restart ${inst.slug}`);
|
|
1996
2019
|
|
|
1997
2020
|
// 4) verify /healthz reports auth_configured=true (only meaningful under --apply).
|
package/src/module-api.js
CHANGED
|
@@ -835,8 +835,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
835
835
|
// landed since 1.19.202 with no explicit bump. run 32059705946. (task 1002620)
|
|
836
836
|
// 1.19.204 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
837
837
|
// landed since 1.19.203 with no explicit bump. run 32060607072. (task 1002620)
|
|
838
|
+
// 1.19.205 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
839
|
+
// landed since 1.19.204 with no explicit bump. run 32061149280. (task 1002620)
|
|
840
|
+
// 1.19.206 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
841
|
+
// landed since 1.19.205 with no explicit bump. run 32064728346. (task 1002620)
|
|
838
842
|
// ---------------------------------------------------------------------------
|
|
839
|
-
const CORE_VERSION = '1.19.
|
|
843
|
+
const CORE_VERSION = '1.19.206'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
840
844
|
|
|
841
845
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
842
846
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/onboard.mjs
CHANGED
|
@@ -15,12 +15,16 @@ test('onboardPlan is the canonical sequence: name+address first, the builders ha
|
|
|
15
15
|
assert.equal(keys[keys.length - 2], 'brand');
|
|
16
16
|
// the irreducibly-human steps (must be UI-first prompts)
|
|
17
17
|
const human = plan.filter((s) => s.kind === 'human').map((s) => s.key);
|
|
18
|
-
assert.deepEqual(human, ['github-repo', 'oauth-app', 'first-sign-in', 'enter-hall']);
|
|
18
|
+
assert.deepEqual(human, ['github-repo', 'oauth-app', 'app-install', 'first-sign-in', 'enter-hall']);
|
|
19
19
|
// the DNS preflight comes BEFORE provision (task 1980: before the address step)
|
|
20
20
|
assert.ok(keys.indexOf('dns-preflight') < keys.indexOf('provision'), 'dns preflight precedes provision');
|
|
21
21
|
// OAuth secret placement comes after the OAuth app + before first sign-in
|
|
22
22
|
assert.ok(keys.indexOf('oauth-app') < keys.indexOf('oauth-place'), 'app before place');
|
|
23
23
|
assert.ok(keys.indexOf('oauth-place') < keys.indexOf('first-sign-in'), 'place before sign-in');
|
|
24
|
+
// The publish-credential install (task 1003049) needs the App to exist first and
|
|
25
|
+
// must be prompted before the owner walks away into the hall.
|
|
26
|
+
assert.ok(keys.indexOf('oauth-place') < keys.indexOf('app-install'), 'the App exists before it can be installed');
|
|
27
|
+
assert.ok(keys.indexOf('app-install') < keys.indexOf('first-sign-in'), 'install prompted before sign-in');
|
|
24
28
|
});
|
|
25
29
|
|
|
26
30
|
test('onboardPlan(adopt): same sequence + human steps, but the scaffold-leg labels branch (task 2046)', () => {
|
package/tests/provision.mjs
CHANGED
|
@@ -1044,11 +1044,27 @@ t('webEnvBody fills the OAuth cred lines when creds are given; blank placeholder
|
|
|
1044
1044
|
const inst = { ...coTenant, domain: 'mercury.example.com' };
|
|
1045
1045
|
const blank = P.webEnvBody(inst);
|
|
1046
1046
|
assert.match(blank, new RegExp(`${reEsc(NEUTRAL_PREFIX)}_GITHUB_CLIENT_ID=\\n`), 'blank id line by default');
|
|
1047
|
+
assert.doesNotMatch(blank, /GITHUB_APP_ID=/, 'no App credential lines without an App');
|
|
1047
1048
|
const filled = P.webEnvBody(inst, { clientId: 'Iv1.abc123def456ghi7', clientSecret: 's'.repeat(40) });
|
|
1048
1049
|
assert.match(filled, new RegExp(`${reEsc(NEUTRAL_PREFIX)}_GITHUB_CLIENT_ID=Iv1\\.abc123def456ghi7`));
|
|
1049
1050
|
assert.match(filled, new RegExp(`${reEsc(NEUTRAL_PREFIX)}_GITHUB_CLIENT_SECRET=s{40}`));
|
|
1050
1051
|
});
|
|
1051
1052
|
|
|
1053
|
+
t('webEnvBody writes the BARE publish-credential vars, pem flattened to one env line (task 1003049)', () => {
|
|
1054
|
+
const inst = { ...coTenant, domain: 'mercury.example.com' };
|
|
1055
|
+
const body = P.webEnvBody(inst, {
|
|
1056
|
+
clientId: 'Iv1.abc123def456ghi7', clientSecret: 's'.repeat(40),
|
|
1057
|
+
appId: 42, appPem: '-----BEGIN KEY-----\nAAA\nBBB\n-----END KEY-----\n',
|
|
1058
|
+
});
|
|
1059
|
+
// BARE names (github-push.js reads process.env.GITHUB_APP_ID, no instance prefix)
|
|
1060
|
+
assert.match(body, /^GITHUB_APP_ID=42$/m);
|
|
1061
|
+
assert.match(body, /^GITHUB_APP_PRIVATE_KEY=-----BEGIN KEY-----\\nAAA\\nBBB\\n-----END KEY-----\\n$/m);
|
|
1062
|
+
assert.doesNotMatch(body, /GITHUB_APP_INSTALLATION_ID/, 'installation id is discovered at runtime, never placed');
|
|
1063
|
+
// pem missing (older Apps) → no half-written credential
|
|
1064
|
+
const noPem = P.webEnvBody(inst, { clientId: 'Iv1.abc123def456ghi7', clientSecret: 's'.repeat(40), appId: 42, appPem: null });
|
|
1065
|
+
assert.doesNotMatch(noPem, /GITHUB_APP_ID=/);
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1052
1068
|
await ta('convertManifestCode POSTs the unauthenticated conversion endpoint + maps the response', async () => {
|
|
1053
1069
|
let calledUrl = '', calledInit = null;
|
|
1054
1070
|
const fetchImpl = async (url, init) => {
|
|
@@ -1060,7 +1076,16 @@ await ta('convertManifestCode POSTs the unauthenticated conversion endpoint + ma
|
|
|
1060
1076
|
assert.equal(calledInit.method, 'POST');
|
|
1061
1077
|
assert.ok(calledInit.headers['User-Agent'], 'stamps a User-Agent (GitHub requires one)');
|
|
1062
1078
|
assert.ok(!('Authorization' in calledInit.headers), 'unauthenticated — the code IS the auth');
|
|
1063
|
-
assert.deepEqual(creds, { clientId: 'Iv1.abcdef0123456789', clientSecret: 'x'.repeat(40), appId: 42, appSlug: 'mercury-signin' });
|
|
1079
|
+
assert.deepEqual(creds, { clientId: 'Iv1.abcdef0123456789', clientSecret: 'x'.repeat(40), appId: 42, appSlug: 'mercury-signin', appPem: null });
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
await ta('convertManifestCode maps the App private key (pem) — the ADR 0055 publish credential (task 1003049)', async () => {
|
|
1083
|
+
const pem = '-----BEGIN RSA PRIVATE KEY-----\nMIIkey\n-----END RSA PRIVATE KEY-----\n';
|
|
1084
|
+
const fetchImpl = async () => ({ ok: true, text: async () => JSON.stringify({
|
|
1085
|
+
id: 7, slug: 's', client_id: 'Iv1.abcdef0123456789', client_secret: 'x'.repeat(40), pem,
|
|
1086
|
+
}) });
|
|
1087
|
+
const creds = await P.convertManifestCode('C', { fetchImpl });
|
|
1088
|
+
assert.equal(creds.appPem, pem, 'the pem rides the creds to web.env placement');
|
|
1064
1089
|
});
|
|
1065
1090
|
|
|
1066
1091
|
await ta('convertManifestCode throws on a non-2xx or a creds-less response', async () => {
|
|
@@ -1075,7 +1100,7 @@ await ta('placeManifestCreds (--apply): converts, rewrites web.env with the cred
|
|
|
1075
1100
|
const writes = []; const cmds = [];
|
|
1076
1101
|
const deps = {
|
|
1077
1102
|
apply: true, privileged: false, log: () => {},
|
|
1078
|
-
convertManifestCode: async () => ({ clientId: 'Iv1.abcdef0123456789', clientSecret: 'z'.repeat(40), appId: 9, appSlug: 'm-signin' }),
|
|
1103
|
+
convertManifestCode: async () => ({ clientId: 'Iv1.abcdef0123456789', clientSecret: 'z'.repeat(40), appId: 9, appSlug: 'm-signin', appPem: '-----BEGIN KEY-----\nAAA\n-----END KEY-----\n' }),
|
|
1079
1104
|
exec: (cmd) => { cmds.push(cmd); return { ok: true, stdout: '{"ok":true,"auth_configured":true}' }; },
|
|
1080
1105
|
writeFile: (p, body) => { writes.push({ p, body }); return { ok: true }; },
|
|
1081
1106
|
};
|
|
@@ -1086,6 +1111,9 @@ await ta('placeManifestCreds (--apply): converts, rewrites web.env with the cred
|
|
|
1086
1111
|
const env = writes.find((w) => /web\.env$/.test(w.p));
|
|
1087
1112
|
assert.ok(env, 'web.env was written');
|
|
1088
1113
|
assert.match(env.body, /_GITHUB_CLIENT_SECRET=z{40}/);
|
|
1114
|
+
// …and the publish credential (task 1003049): App id + one-line pem, bare names
|
|
1115
|
+
assert.match(env.body, /^GITHUB_APP_ID=9$/m, 'the App id is placed as the publish credential');
|
|
1116
|
+
assert.match(env.body, /^GITHUB_APP_PRIVATE_KEY=-----BEGIN KEY-----\\nAAA\\n-----END KEY-----\\n$/m, 'the pem is placed env-flattened');
|
|
1089
1117
|
// the unit was restarted, and /healthz was probed
|
|
1090
1118
|
assert.ok(cmds.some((c) => /systemctl restart mercury/.test(c)), 'restarts the unit');
|
|
1091
1119
|
assert.ok(cmds.some((c) => /\/healthz/.test(c)), 'probes healthz');
|
|
@@ -476,7 +476,7 @@ async function withEnv(vars, fn) {
|
|
|
476
476
|
const APP_ENV = { GITHUB_APP_ID: '123', GITHUB_APP_INSTALLATION_ID: '456', GITHUB_APP_PRIVATE_KEY: APP_PRIVKEY };
|
|
477
477
|
const NO_APP = { GITHUB_APP_ID: undefined, GITHUB_APP_INSTALLATION_ID: undefined, GITHUB_APP_PRIVATE_KEY: undefined };
|
|
478
478
|
|
|
479
|
-
await t('appConfigured / isConfigured: true
|
|
479
|
+
await t('appConfigured / isConfigured: true with App (id + key; installation id optional) or PAT', async () => {
|
|
480
480
|
await withEnv({ ...NO_APP, GITHUB_PUSH_TOKEN: undefined }, async () => {
|
|
481
481
|
assert.equal(gp.appConfigured(), false);
|
|
482
482
|
assert.equal(gp.isConfigured(), false);
|
|
@@ -490,7 +490,11 @@ await t('appConfigured / isConfigured: true only with App (all 3 vars) or PAT',
|
|
|
490
490
|
assert.equal(gp.isConfigured(), true, 'App alone configures it');
|
|
491
491
|
});
|
|
492
492
|
await withEnv({ GITHUB_APP_ID: '123', GITHUB_APP_INSTALLATION_ID: undefined, GITHUB_APP_PRIVATE_KEY: APP_PRIVKEY, GITHUB_PUSH_TOKEN: undefined }, async () => {
|
|
493
|
-
assert.equal(gp.appConfigured(),
|
|
493
|
+
assert.equal(gp.appConfigured(), true, 'id + key ARE enough — the installation id is discovered from the repo (task 1003049)');
|
|
494
|
+
assert.equal(gp.isConfigured(), true);
|
|
495
|
+
});
|
|
496
|
+
await withEnv({ GITHUB_APP_ID: '123', GITHUB_APP_INSTALLATION_ID: '456', GITHUB_APP_PRIVATE_KEY: undefined, GITHUB_PUSH_TOKEN: undefined }, async () => {
|
|
497
|
+
assert.equal(gp.appConfigured(), false, 'no private key → not configured');
|
|
494
498
|
assert.equal(gp.isConfigured(), false);
|
|
495
499
|
});
|
|
496
500
|
});
|
|
@@ -548,6 +552,153 @@ await t('getInstallationToken: App configured but the exchange fails → APP_TOK
|
|
|
548
552
|
});
|
|
549
553
|
});
|
|
550
554
|
|
|
555
|
+
// task 1003049 — a manifest-born instance knows its App id + key at cred-placement
|
|
556
|
+
// time but never the installation id: that only exists after the owner's one-click
|
|
557
|
+
// install. The server derives it live instead of demanding a third env var.
|
|
558
|
+
const APP_ENV_NO_INST = { GITHUB_APP_ID: '123', GITHUB_APP_INSTALLATION_ID: undefined, GITHUB_APP_PRIVATE_KEY: APP_PRIVKEY };
|
|
559
|
+
|
|
560
|
+
await t('getInstallationToken: no installation id in env → discovers it from the repo, then mints (task 1003049)', async () => {
|
|
561
|
+
await withEnv({ ...APP_ENV_NO_INST, GITHUB_PUSH_TOKEN: undefined }, async () => {
|
|
562
|
+
const urls = [];
|
|
563
|
+
const fetchImpl = async (url, opts = {}) => {
|
|
564
|
+
urls.push(url);
|
|
565
|
+
if (url.endsWith('/repos/someowner/somerepo/installation')) {
|
|
566
|
+
const authz = opts.headers.Authorization;
|
|
567
|
+
assert.ok(/^Bearer /.test(authz) && jwtSignatureVerifies(authz.slice(7), APP_PUBKEY), 'discovery authenticates with a valid App JWT');
|
|
568
|
+
return jsonRes(200, { id: 789 });
|
|
569
|
+
}
|
|
570
|
+
if (url.includes('/app/installations/789/access_tokens')) {
|
|
571
|
+
return jsonRes(201, { token: 'ghs_discovered', expires_at: '2999-01-01T00:00:00Z' });
|
|
572
|
+
}
|
|
573
|
+
throw new Error('unexpected ' + url);
|
|
574
|
+
};
|
|
575
|
+
const now = () => 1_700_000_000 * 1000;
|
|
576
|
+
const tok = await gp.getInstallationToken({ fetchImpl, now, repoInfo: REPO });
|
|
577
|
+
assert.equal(tok, 'ghs_discovered');
|
|
578
|
+
assert.ok(urls.some((u) => u.endsWith('/repos/someowner/somerepo/installation')), 'looked the installation up on the repo');
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
await t('getInstallationToken: env GITHUB_APP_INSTALLATION_ID still wins — no discovery call (the ADR 0055 v1 shape)', async () => {
|
|
583
|
+
await withEnv({ ...APP_ENV, GITHUB_PUSH_TOKEN: undefined }, async () => {
|
|
584
|
+
const fetchImpl = async (url) => {
|
|
585
|
+
if (url.endsWith('/installation')) throw new Error('must not discover when the env id is set');
|
|
586
|
+
return jsonRes(201, { token: 'ghs_env', expires_at: '2999-01-01T00:00:00Z' });
|
|
587
|
+
};
|
|
588
|
+
assert.equal(await gp.getInstallationToken({ fetchImpl, now: () => 1_700_000_000 * 1000, repoInfo: REPO }), 'ghs_env');
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
await t('getInstallationToken: App not installed on the repo → APP_NOT_INSTALLED carrying the install URL (task 1003049)', async () => {
|
|
593
|
+
await withEnv({ ...APP_ENV_NO_INST, GITHUB_PUSH_TOKEN: undefined }, async () => {
|
|
594
|
+
const fetchImpl = async (url) => {
|
|
595
|
+
if (url.endsWith('/repos/someowner/somerepo/installation')) return jsonRes(404, { message: 'Not Found' });
|
|
596
|
+
if (url.endsWith('api.github.com/app')) return jsonRes(200, { html_url: 'https://github.com/apps/mercury-signin' });
|
|
597
|
+
throw new Error('unexpected ' + url);
|
|
598
|
+
};
|
|
599
|
+
await assert.rejects(
|
|
600
|
+
() => gp.getInstallationToken({ fetchImpl, now: () => 1_700_000_000 * 1000, repoInfo: REPO }),
|
|
601
|
+
(e) => e.code === 'APP_NOT_INSTALLED'
|
|
602
|
+
&& e.owner === 'someowner' && e.repo === 'somerepo'
|
|
603
|
+
&& e.install_url === 'https://github.com/apps/mercury-signin/installations/new',
|
|
604
|
+
'names the repo + the one-click install URL so the refusal is self-serve'
|
|
605
|
+
);
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
await t('discoverInstallationId: APP_NOT_INSTALLED is negative-cached for a short TTL (no per-poll re-probe)', async () => {
|
|
610
|
+
await withEnv({ ...APP_ENV_NO_INST, GITHUB_PUSH_TOKEN: undefined }, async () => {
|
|
611
|
+
let probes = 0;
|
|
612
|
+
const fetchImpl = async (url) => {
|
|
613
|
+
if (url.endsWith('/repos/someowner/somerepo/installation')) { probes++; return jsonRes(404, {}); }
|
|
614
|
+
if (url.endsWith('api.github.com/app')) return jsonRes(200, { html_url: 'https://github.com/apps/x' });
|
|
615
|
+
throw new Error('unexpected ' + url);
|
|
616
|
+
};
|
|
617
|
+
let nowMs = 1_700_000_000 * 1000;
|
|
618
|
+
const now = () => nowMs;
|
|
619
|
+
const call = () => gp.getInstallationToken({ fetchImpl, now, repoInfo: REPO });
|
|
620
|
+
await assert.rejects(call, (e) => e.code === 'APP_NOT_INSTALLED');
|
|
621
|
+
await assert.rejects(call, (e) => e.code === 'APP_NOT_INSTALLED');
|
|
622
|
+
assert.equal(probes, 1, 'the second poll inside the TTL reuses the cached refusal');
|
|
623
|
+
nowMs += 61_000;
|
|
624
|
+
await assert.rejects(call, (e) => e.code === 'APP_NOT_INSTALLED');
|
|
625
|
+
assert.equal(probes, 2, 'after the TTL the state is re-probed (an install must be noticed)');
|
|
626
|
+
});
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
await t('getInstallationToken: a stale DISCOVERED id is dropped on exchange failure — next call re-discovers (no restart needed)', async () => {
|
|
630
|
+
await withEnv({ ...APP_ENV_NO_INST, GITHUB_PUSH_TOKEN: undefined }, async () => {
|
|
631
|
+
let discoveries = 0, exchanges = 0;
|
|
632
|
+
const fetchImpl = async (url) => {
|
|
633
|
+
if (url.endsWith('/repos/someowner/somerepo/installation')) {
|
|
634
|
+
discoveries++;
|
|
635
|
+
return jsonRes(200, { id: discoveries === 1 ? 111 : 222 });
|
|
636
|
+
}
|
|
637
|
+
if (url.includes('/access_tokens')) {
|
|
638
|
+
exchanges++;
|
|
639
|
+
// the first (stale) id fails the exchange; the re-discovered one works
|
|
640
|
+
if (url.includes('/111/')) return jsonRes(404, { message: 'gone (uninstalled+reinstalled)' });
|
|
641
|
+
return jsonRes(201, { token: 'ghs_fresh', expires_at: '2999-01-01T00:00:00Z' });
|
|
642
|
+
}
|
|
643
|
+
throw new Error('unexpected ' + url);
|
|
644
|
+
};
|
|
645
|
+
const now = () => 1_700_000_000 * 1000;
|
|
646
|
+
await assert.rejects(
|
|
647
|
+
() => gp.getInstallationToken({ fetchImpl, now, repoInfo: REPO }),
|
|
648
|
+
(e) => e.code === 'APP_TOKEN_FAILED'
|
|
649
|
+
);
|
|
650
|
+
assert.equal(await gp.getInstallationToken({ fetchImpl, now, repoInfo: REPO }), 'ghs_fresh',
|
|
651
|
+
'the stale id was dropped and re-discovery healed the exchange');
|
|
652
|
+
assert.equal(discoveries, 2);
|
|
653
|
+
assert.equal(exchanges, 2);
|
|
654
|
+
});
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
await t('foreignInstallations: installs beyond the repo owner are surfaced; own install is not; API miss → null (task 1003049 grade)', async () => {
|
|
658
|
+
await withEnv({ ...APP_ENV_NO_INST, GITHUB_PUSH_TOKEN: undefined }, async () => {
|
|
659
|
+
const roster = [
|
|
660
|
+
{ id: 1, account: { login: 'SomeOwner' } }, // own (case-insensitive)
|
|
661
|
+
{ id: 2, account: { login: 'stranger' } },
|
|
662
|
+
{ id: 3, account: { login: 'another-org' } },
|
|
663
|
+
];
|
|
664
|
+
const fetchImpl = async (url) => {
|
|
665
|
+
if (url.includes('/app/installations?')) return jsonRes(200, roster);
|
|
666
|
+
throw new Error('unexpected ' + url);
|
|
667
|
+
};
|
|
668
|
+
const foreign = await gp.foreignInstallations({ fetchImpl, now: () => 1_700_000_000 * 1000, repoInfo: REPO });
|
|
669
|
+
assert.deepEqual(foreign.map((f) => f.account).sort(), ['another-org', 'stranger']);
|
|
670
|
+
const miss = await gp.foreignInstallations({ fetchImpl: async () => jsonRes(500, {}), now: () => 1_700_000_000 * 1000, repoInfo: REPO });
|
|
671
|
+
assert.equal(miss, null, 'a watch that cannot tell says null, never a fake all-clear');
|
|
672
|
+
});
|
|
673
|
+
await withEnv({ ...NO_APP, GITHUB_PUSH_TOKEN: undefined }, async () => {
|
|
674
|
+
assert.equal(await gp.foreignInstallations({ fetchImpl: async () => { throw new Error('no'); } }), null,
|
|
675
|
+
'unconfigured App → null');
|
|
676
|
+
});
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
await t('GET /tasks/:id/publish-status with no credential → a plain-sentence 503, not a bare code (task 1003049)', async () => {
|
|
680
|
+
await withEnv({ ...NO_APP, GITHUB_PUSH_TOKEN: undefined }, async () => {
|
|
681
|
+
const router = buildTasksRouter();
|
|
682
|
+
const layer = findRouteLayer(router, '/tasks/:id/publish-status', 'get');
|
|
683
|
+
const handler = layer.route.stack[layer.route.stack.length - 1].handle;
|
|
684
|
+
const out = { failCode: null, failOpts: null };
|
|
685
|
+
const res = {
|
|
686
|
+
status() { return this; },
|
|
687
|
+
json() { return this; },
|
|
688
|
+
fail(code, opts) { out.failCode = code; out.failOpts = opts; return this; },
|
|
689
|
+
};
|
|
690
|
+
// rank archon → gateTaskOwnership passes with no DB lookup, keeping the test hermetic.
|
|
691
|
+
const req = { params: { id: '77' }, query: { branch: 'claude/x' }, builder: { id: 95, rank: 'archon' } };
|
|
692
|
+
await handler(req, res);
|
|
693
|
+
assert.equal(out.failCode, 'push_unconfigured');
|
|
694
|
+
assert.equal(out.failOpts && out.failOpts.status, 503);
|
|
695
|
+
const msg = String((out.failOpts && out.failOpts.message) || '');
|
|
696
|
+
assert.match(msg, /publish credential/i, 'says WHAT is missing in a sentence');
|
|
697
|
+
assert.match(msg, /GitHub App/, 'says what to set up');
|
|
698
|
+
assert.match(msg, /GITHUB_PUSH_TOKEN/, 'names the PAT alternative');
|
|
699
|
+
});
|
|
700
|
+
});
|
|
701
|
+
|
|
551
702
|
await t('resolveToken precedence: deps.token > App installation token > PAT > null', async () => {
|
|
552
703
|
await withEnv({ ...APP_ENV, GITHUB_PUSH_TOKEN: 'pat' }, async () => {
|
|
553
704
|
assert.equal(
|