@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.
Files changed (32) hide show
  1. package/.bongos-core.json +46 -36
  2. package/.claude/skills/new-project/SKILL.md +3 -1
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +4 -0
  5. package/clients/bongos-client/index.cjs +4 -0
  6. package/clients/bongos-client/index.d.ts +8 -1
  7. package/clients/bongos-client/index.mjs +4 -0
  8. package/docs/adr/0055-server-mediated-branch-publish.md +2 -0
  9. package/docs/adr/0133-github-app-manifest-onboarding-signin.md +2 -2
  10. package/docs/api/openapi.json +160 -5
  11. package/docs/api-reference.md +4 -2
  12. package/docs/recipes/ops-gotchas.md +8 -1
  13. package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
  14. package/modules/lifecycle/github-push.js +130 -18
  15. package/modules/lifecycle/publish-reconciler.js +44 -1
  16. package/modules/lifecycle/routes/tasks.js +21 -4
  17. package/modules/provisioning/migrations/provisioning_009_project_settings.sql +19 -0
  18. package/modules/provisioning/onboard-plan.js +1 -0
  19. package/modules/provisioning/provisioning.js +59 -10
  20. package/modules/provisioning/routes/provisioning.js +67 -0
  21. package/modules/provisioning/tests/provisioning.mjs +43 -3
  22. package/package-lock.json +2 -2
  23. package/package.json +1 -1
  24. package/public-landing/projects.html +19 -2
  25. package/scripts/gds/onboard.js +11 -3
  26. package/scripts/gds/provision.js +27 -4
  27. package/src/module-api.js +5 -1
  28. package/tests/onboard.mjs +5 -1
  29. package/tests/provision.mjs +30 -2
  30. package/tests/provisioning_settings.mjs +200 -0
  31. package/tests/publish_branch_route.mjs +153 -2
  32. 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, minimal perms, public, domain-named', () => {
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
- // sign-in needs no repo/account permissions; no active webhook
183
- assert.deepEqual(m.default_permissions, {});
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.205",
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.205",
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.205",
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 };
@@ -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 four irreducibly-human steps (create the GitHub repo, the
13
- // OAuth app + secret, own the domain, first sign-in) as precise, copy-paste-safe
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.`);
@@ -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, { clientId: creds.clientId, clientSecret: creds.clientSecret }), { mode: 0o600, sudo: true });
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.205'; // CI auto-patch carrier (ADR 0161); see changelog above
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)', () => {
@@ -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);