@bongos/core 1.19.205 → 1.19.207
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 +46 -36
- package/.claude/skills/new-project/SKILL.md +3 -1
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +8 -1
- package/clients/bongos-client/index.mjs +4 -0
- 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 +160 -5
- package/docs/api-reference.md +4 -2
- package/docs/recipes/ops-gotchas.md +8 -1
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- 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/migrations/provisioning_009_project_settings.sql +19 -0
- package/modules/provisioning/onboard-plan.js +1 -0
- package/modules/provisioning/provisioning.js +59 -10
- package/modules/provisioning/routes/provisioning.js +67 -0
- package/modules/provisioning/tests/provisioning.mjs +43 -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/provisioning_settings.mjs +200 -0
- package/tests/publish_branch_route.mjs +153 -2
- package/tests/publish_reconciler.mjs +63 -0
|
@@ -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)
|
|
@@ -258,5 +260,43 @@ await ta('recordDnsNote / clearErrorNote are NARROW single-column updates — no
|
|
|
258
260
|
assert.doesNotMatch(sql, /status\s*=|updated_at/, 'releasing the latch must not touch the lifecycle');
|
|
259
261
|
});
|
|
260
262
|
|
|
263
|
+
console.log('\nPer-project settings (task 1003040):');
|
|
264
|
+
|
|
265
|
+
t('effectiveSettings: defaults for a bare/empty row = today\'s behavior; stored keys overlay; junk falls back', () => {
|
|
266
|
+
assert.deepEqual(P.effectiveSettings(null), { platform_visibility: 'public', joinability: 'apply' });
|
|
267
|
+
assert.deepEqual(P.effectiveSettings({}), { platform_visibility: 'public', joinability: 'apply' });
|
|
268
|
+
assert.deepEqual(P.effectiveSettings({ settings: {} }), { platform_visibility: 'public', joinability: 'apply' });
|
|
269
|
+
assert.deepEqual(
|
|
270
|
+
P.effectiveSettings({ settings: { platform_visibility: 'gated' } }),
|
|
271
|
+
{ platform_visibility: 'gated', joinability: 'apply' },
|
|
272
|
+
'a stored key overlays only itself'
|
|
273
|
+
);
|
|
274
|
+
assert.deepEqual(
|
|
275
|
+
P.effectiveSettings({ settings: { platform_visibility: 'bogus', joinability: 'invite_only', future_key: 'x' } }),
|
|
276
|
+
{ platform_visibility: 'public', joinability: 'invite_only' },
|
|
277
|
+
'off-vocabulary values fall back to the default; unknown keys never leak out'
|
|
278
|
+
);
|
|
279
|
+
assert.deepEqual(P.effectiveSettings({ settings: ['not', 'an', 'object'] }),
|
|
280
|
+
{ platform_visibility: 'public', joinability: 'apply' }, 'a non-object settings value reads as defaults');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
t('SETTINGS_VOCAB / DEFAULT_SETTINGS: every default is on its own vocabulary (lockstep guard)', () => {
|
|
284
|
+
for (const [key, dflt] of Object.entries(P.DEFAULT_SETTINGS)) {
|
|
285
|
+
assert.ok(P.SETTINGS_VOCAB[key], `default key '${key}' has a vocabulary`);
|
|
286
|
+
assert.ok(P.SETTINGS_VOCAB[key].includes(dflt), `default '${dflt}' is a legal '${key}' value`);
|
|
287
|
+
}
|
|
288
|
+
assert.deepEqual(Object.keys(P.SETTINGS_VOCAB).sort(), Object.keys(P.DEFAULT_SETTINGS).sort(),
|
|
289
|
+
'every known key has a default (an undefaulted key would read as undefined)');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
await ta('updateInstanceSettings: jsonb MERGE (||) + updated_at, parameterized, returns the row', async () => {
|
|
293
|
+
let sql = '', params = null;
|
|
294
|
+
const db = { query: async (q, p) => { sql = q; params = p; return { rows: [{ id: 18, settings: { joinability: 'open' } }] }; } };
|
|
295
|
+
const row = await P.updateInstanceSettings(db, 18, { joinability: 'open' });
|
|
296
|
+
assert.equal(row.settings.joinability, 'open');
|
|
297
|
+
assert.match(sql, /SET settings = settings \|\| \$2::jsonb, updated_at = now\(\)/, 'merges — never clobbers other keys');
|
|
298
|
+
assert.deepEqual(params, [18, JSON.stringify({ joinability: 'open' })]);
|
|
299
|
+
});
|
|
300
|
+
|
|
261
301
|
console.log(`\nprovisioning module: ${passed} passed, ${failed} failed`);
|
|
262
302
|
process.exit(failed ? 1 : 0);
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.207",
|
|
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.207",
|
|
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.207",
|
|
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
|
@@ -837,8 +837,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
837
837
|
// landed since 1.19.203 with no explicit bump. run 32060607072. (task 1002620)
|
|
838
838
|
// 1.19.205 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
839
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)
|
|
842
|
+
// 1.19.207 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
843
|
+
// landed since 1.19.206 with no explicit bump. run 32065995444. (task 1002620)
|
|
840
844
|
// ---------------------------------------------------------------------------
|
|
841
|
-
const CORE_VERSION = '1.19.
|
|
845
|
+
const CORE_VERSION = '1.19.207'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
842
846
|
|
|
843
847
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
844
848
|
// 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');
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// tests/provisioning_settings.mjs
|
|
2
|
+
//
|
|
3
|
+
// Per-project settings foundation (task 1003040, goal 1000058) — the surface the
|
|
4
|
+
// manage-page wave hangs off:
|
|
5
|
+
// • GET /provisioning/instances/:id/settings — effective settings + options
|
|
6
|
+
// • PATCH /provisioning/instances/:id/settings — validated, own-scoped change
|
|
7
|
+
//
|
|
8
|
+
// The load-bearing assertions:
|
|
9
|
+
// • the OWN-SCOPE WALL: another builder's GET/PATCH answers 404 and the write
|
|
10
|
+
// path is NEVER reached (no existence leak, no cross-owner mutation)
|
|
11
|
+
// • the Archon override works and is audited as api:archon
|
|
12
|
+
// • strict validation: off-vocabulary values and unknown keys are 400s
|
|
13
|
+
// • defaults = today's behavior (public / apply) for rows that predate the column
|
|
14
|
+
//
|
|
15
|
+
// DB-free: data-access fns are stubbed on the cached module object (the
|
|
16
|
+
// provisioning_domain_attach.mjs pattern). No network beyond localhost.
|
|
17
|
+
//
|
|
18
|
+
// Run: node tests/provisioning_settings.mjs
|
|
19
|
+
|
|
20
|
+
import { strict as assert } from 'node:assert';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
import http from 'node:http';
|
|
23
|
+
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const provisioning = require('../modules/provisioning/provisioning.js');
|
|
26
|
+
|
|
27
|
+
let passed = 0, failed = 0;
|
|
28
|
+
async function ta(name, fn) {
|
|
29
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
30
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ── stubbed data access on the cached module object ──────────────────────────
|
|
34
|
+
let instance = null; // the row getInstanceById answers with
|
|
35
|
+
let updateCalls = []; // recorded updateInstanceSettings calls
|
|
36
|
+
let events = []; // recorded provisioning_events
|
|
37
|
+
provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
|
|
38
|
+
provisioning.updateInstanceSettings = async (_db, id, patch) => {
|
|
39
|
+
updateCalls.push({ id, patch });
|
|
40
|
+
instance = { ...instance, settings: { ...(instance.settings || {}), ...patch } };
|
|
41
|
+
return { ...instance, id };
|
|
42
|
+
};
|
|
43
|
+
provisioning.recordEvent = async (_db, e) => { events.push(e); };
|
|
44
|
+
provisioning.getOpenIntent = async () => null;
|
|
45
|
+
|
|
46
|
+
const api = require('../src/module-api.js');
|
|
47
|
+
let builder = { id: 7, github_login: 'owner', rank: 'thetes' };
|
|
48
|
+
api.requireBuilder = (req, _res, next) => { req.builder = builder; next(); };
|
|
49
|
+
api.requirePermission = () => (_req, _res, next) => next();
|
|
50
|
+
api.branding = () => ({
|
|
51
|
+
domains: { publicOrigin: 'https://cb.test' },
|
|
52
|
+
identity: { productName: 'Cloud Bongos' },
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const express = require('express');
|
|
56
|
+
const provisioningRoutes = require('../modules/provisioning/routes/provisioning.js');
|
|
57
|
+
const app = express();
|
|
58
|
+
app.use(express.json());
|
|
59
|
+
app.use((_req, res, next) => {
|
|
60
|
+
res.fail = (code, opts, details) => {
|
|
61
|
+
const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
|
|
62
|
+
res.status(status).json({ error: { code, ...(typeof opts === 'object' ? opts : {}), ...(details || {}) } });
|
|
63
|
+
};
|
|
64
|
+
next();
|
|
65
|
+
});
|
|
66
|
+
app.use('/api/bongos', provisioningRoutes());
|
|
67
|
+
|
|
68
|
+
const server = app.listen(0);
|
|
69
|
+
await new Promise((r) => server.once('listening', r));
|
|
70
|
+
const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
71
|
+
|
|
72
|
+
function call(method, path, body) {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const payload = body === undefined ? null : JSON.stringify(body);
|
|
75
|
+
const req = http.request(`${base}${path}`, {
|
|
76
|
+
method,
|
|
77
|
+
headers: payload
|
|
78
|
+
? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }
|
|
79
|
+
: {},
|
|
80
|
+
}, (res) => {
|
|
81
|
+
let d = '';
|
|
82
|
+
res.on('data', (c) => { d += c; });
|
|
83
|
+
res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
|
|
84
|
+
});
|
|
85
|
+
req.on('error', reject);
|
|
86
|
+
req.end(payload || undefined);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
const get = (p) => call('GET', p);
|
|
90
|
+
const patch = (p, b) => call('PATCH', p, b);
|
|
91
|
+
|
|
92
|
+
function reset({ inst, as } = {}) {
|
|
93
|
+
instance = inst !== undefined ? inst
|
|
94
|
+
: { owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'spike.cloudbongos.com', settings: {} };
|
|
95
|
+
builder = as || { id: 7, github_login: 'owner', rank: 'thetes' };
|
|
96
|
+
updateCalls = [];
|
|
97
|
+
events = [];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
console.log('GET /provisioning/instances/:id/settings:');
|
|
101
|
+
|
|
102
|
+
await ta('owner reads DEFAULTS (public / apply) on a row with empty settings — today\'s behavior', async () => {
|
|
103
|
+
reset();
|
|
104
|
+
const r = await get('/provisioning/instances/18/settings');
|
|
105
|
+
assert.equal(r.status, 200, r.raw);
|
|
106
|
+
assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply' });
|
|
107
|
+
assert.deepEqual(r.body.options, { platform_visibility: ['public', 'gated'], joinability: ['open', 'apply', 'invite_only'] });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await ta('stored keys overlay the defaults', async () => {
|
|
111
|
+
reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', settings: { joinability: 'invite_only' } } });
|
|
112
|
+
const r = await get('/provisioning/instances/18/settings');
|
|
113
|
+
assert.equal(r.status, 200, r.raw);
|
|
114
|
+
assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'invite_only' });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await ta('ANOTHER builder\'s GET → 404 (no existence leak)', async () => {
|
|
118
|
+
reset({ as: { id: 8, github_login: 'stranger', rank: 'thetes' } });
|
|
119
|
+
const r = await get('/provisioning/instances/18/settings');
|
|
120
|
+
assert.equal(r.status, 404, r.raw);
|
|
121
|
+
assert.equal(r.body.error.code, 'instance_not_found');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await ta('an Archon may read ANY instance (the override)', async () => {
|
|
125
|
+
reset({ as: { id: 999, github_login: 'archon', rank: 'archon' } });
|
|
126
|
+
const r = await get('/provisioning/instances/18/settings');
|
|
127
|
+
assert.equal(r.status, 200, r.raw);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
await ta('unknown instance id → 404 for everyone', async () => {
|
|
131
|
+
reset({ inst: null });
|
|
132
|
+
const r = await get('/provisioning/instances/999/settings');
|
|
133
|
+
assert.equal(r.status, 404, r.raw);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
console.log('\nPATCH /provisioning/instances/:id/settings:');
|
|
137
|
+
|
|
138
|
+
await ta('owner changes one key — merged, returned effective, audited api:self', async () => {
|
|
139
|
+
reset();
|
|
140
|
+
const r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
|
|
141
|
+
assert.equal(r.status, 200, r.raw);
|
|
142
|
+
assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'open' });
|
|
143
|
+
assert.deepEqual(updateCalls, [{ id: 18, patch: { joinability: 'open' } }], 'exactly the sent key, nothing else');
|
|
144
|
+
assert.equal(events.length, 1);
|
|
145
|
+
assert.equal(events[0].event, 'settings');
|
|
146
|
+
assert.equal(events[0].actor, 'api:self');
|
|
147
|
+
assert.match(events[0].detail, /joinability=open/);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
await ta('owner changes both keys in one call', async () => {
|
|
151
|
+
reset();
|
|
152
|
+
const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated', joinability: 'invite_only' });
|
|
153
|
+
assert.equal(r.status, 200, r.raw);
|
|
154
|
+
assert.deepEqual(r.body.settings, { platform_visibility: 'gated', joinability: 'invite_only' });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await ta('the OWN-SCOPE WALL: another builder\'s PATCH → 404 and the write path is never reached', async () => {
|
|
158
|
+
reset({ as: { id: 8, github_login: 'stranger', rank: 'thetes' } });
|
|
159
|
+
const r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
|
|
160
|
+
assert.equal(r.status, 404, r.raw);
|
|
161
|
+
assert.equal(r.body.error.code, 'instance_not_found');
|
|
162
|
+
assert.equal(updateCalls.length, 0, 'no cross-owner mutation');
|
|
163
|
+
assert.equal(events.length, 0, 'no event for a refused write');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await ta('an Archon may change ANY instance — audited api:archon', async () => {
|
|
167
|
+
reset({ as: { id: 999, github_login: 'archon', rank: 'archon' } });
|
|
168
|
+
const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated' });
|
|
169
|
+
assert.equal(r.status, 200, r.raw);
|
|
170
|
+
assert.equal(events[0].actor, 'api:archon', 'the override is distinguishable in the audit ledger');
|
|
171
|
+
assert.equal(events[0].ownerBuilderId, 7, 'the event still belongs to the instance owner');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await ta('an off-vocabulary value → 400 validation_failed (nothing written)', async () => {
|
|
175
|
+
reset();
|
|
176
|
+
const r = await patch('/provisioning/instances/18/settings', { joinability: 'closed' });
|
|
177
|
+
assert.equal(r.status, 400, r.raw);
|
|
178
|
+
assert.equal(r.body.error.code, 'validation_failed');
|
|
179
|
+
assert.equal(updateCalls.length, 0);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
await ta('an unknown key → 400 (strict: a typo fails loudly, never silently dropped)', async () => {
|
|
183
|
+
reset();
|
|
184
|
+
const r = await patch('/provisioning/instances/18/settings', { joinabilty: 'open' });
|
|
185
|
+
assert.equal(r.status, 400, r.raw);
|
|
186
|
+
assert.equal(r.body.error.code, 'validation_failed');
|
|
187
|
+
assert.equal(updateCalls.length, 0);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
await ta('an empty body → 400 bad_settings with a plain sentence naming the keys', async () => {
|
|
191
|
+
reset();
|
|
192
|
+
const r = await patch('/provisioning/instances/18/settings', {});
|
|
193
|
+
assert.equal(r.status, 400, r.raw);
|
|
194
|
+
assert.equal(r.body.error.code, 'bad_settings');
|
|
195
|
+
assert.match(r.body.error.message, /platform_visibility, joinability/);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
server.close();
|
|
199
|
+
console.log(`\nprovisioning_settings: ${passed} passed, ${failed} failed`);
|
|
200
|
+
process.exit(failed ? 1 : 0);
|