@bongos/core 1.19.206 → 1.19.208

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.
@@ -591,6 +591,69 @@ module.exports = function provisioningRoutes() {
591
591
  }
592
592
  });
593
593
 
594
+ // ── Per-project settings (task 1003040, goal 1000058) ─────────────────────
595
+ // The foundation the manage-page wave hangs off: effective settings are the
596
+ // DEFAULTS (today's behavior — public surfaces, apply-to-join) overlaid with
597
+ // the instance's stored keys. Own-scoped with an Archon override: the owner
598
+ // manages their own project; an Archon may act on any (fleet support). 404
599
+ // for everyone else — no existence leak (the GET-by-id precedent).
600
+ function settingsAccessDenied(inst, builder) {
601
+ if (!inst) return true;
602
+ if (builder.rank === 'archon') return false;
603
+ return String(inst.owner_builder_id) !== String(builder.id);
604
+ }
605
+
606
+ // GET /provisioning/instances/:id/settings — read a project's effective
607
+ // settings + the allowed values (so a form can render its choices).
608
+ // rank: any authenticated builder (own resource; archon may read any).
609
+ router.get('/provisioning/instances/:id/settings', auth.requireBuilder, async (req, res) => {
610
+ const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
611
+ if (!id) return;
612
+ try {
613
+ const inst = await provisioning.getInstanceById(pool, id);
614
+ if (settingsAccessDenied(inst, req.builder)) return res.fail('instance_not_found', 404);
615
+ res.json({ ok: true, settings: provisioning.effectiveSettings(inst), options: provisioning.SETTINGS_VOCAB });
616
+ } catch (err) {
617
+ console.error('[provisioning] GET /provisioning/instances/:id/settings', err);
618
+ res.fail('settings_read_failed', { status: 500, message: 'internal error' });
619
+ }
620
+ });
621
+
622
+ // PATCH /provisioning/instances/:id/settings — change a project's settings.
623
+ // Body: any subset of the known keys; strict validation refuses unknown keys
624
+ // and off-vocabulary values. rank: any authenticated builder (own resource;
625
+ // archon may change any — the override is audited via the event's actor).
626
+ router.patch('/provisioning/instances/:id/settings', auth.requireBuilder, async (req, res) => {
627
+ const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
628
+ if (!id) return;
629
+ if (validateOrRespond(req, res, {
630
+ platform_visibility: { type: 'string', enum: provisioning.SETTINGS_VOCAB.platform_visibility },
631
+ joinability: { type: 'string', enum: provisioning.SETTINGS_VOCAB.joinability },
632
+ })) return;
633
+ const patch = {};
634
+ for (const key of Object.keys(provisioning.SETTINGS_VOCAB)) {
635
+ if ((req.body || {})[key] !== undefined) patch[key] = req.body[key];
636
+ }
637
+ if (!Object.keys(patch).length) {
638
+ return res.fail('bad_settings', { status: 400,
639
+ message: `The body carries no settings to change — send any of: ${Object.keys(provisioning.SETTINGS_VOCAB).join(', ')}.` });
640
+ }
641
+ try {
642
+ const inst = await provisioning.getInstanceById(pool, id);
643
+ if (settingsAccessDenied(inst, req.builder)) return res.fail('instance_not_found', 404);
644
+ const updated = await provisioning.updateInstanceSettings(pool, id, patch);
645
+ await provisioning.recordEvent(pool, {
646
+ instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'settings',
647
+ detail: `settings changed: ${Object.entries(patch).map(([k, v]) => `${k}=${v}`).join(', ')}`,
648
+ actor: String(inst.owner_builder_id) === String(req.builder.id) ? 'api:self' : 'api:archon',
649
+ }).catch(() => {});
650
+ res.json({ ok: true, settings: provisioning.effectiveSettings(updated) });
651
+ } catch (err) {
652
+ console.error('[provisioning] PATCH /provisioning/instances/:id/settings', err);
653
+ res.fail('settings_update_failed', { status: 500, message: 'internal error' });
654
+ }
655
+ });
656
+
594
657
  // POST /provisioning/instances/:id/teardown — request teardown of the caller's
595
658
  // OWN instance. rank: any authenticated builder (own resource). Enqueues a
596
659
  // 'teardown' intent; the runner executes it (web tier holds no tokens). 404 when
@@ -260,5 +260,43 @@ await ta('recordDnsNote / clearErrorNote are NARROW single-column updates — no
260
260
  assert.doesNotMatch(sql, /status\s*=|updated_at/, 'releasing the latch must not touch the lifecycle');
261
261
  });
262
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
+
263
301
  console.log(`\nprovisioning module: ${passed} passed, ${failed} failed`);
264
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.206",
3
+ "version": "1.19.208",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.206",
9
+ "version": "1.19.208",
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.206",
3
+ "version": "1.19.208",
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",
@@ -2861,6 +2861,22 @@ 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
+ /* device-flow — the one-checkbox repair for terminal sign-in (task 1003051):
2865
+ GitHub creates a manifest-born App with Device Flow OFF and offers no API to
2866
+ enable it, so `bongos setup` in a terminal is refused until the owner ticks
2867
+ the box. Web sign-in works either way; the CLI also has a web-token fallback. */
2868
+ function renderStepDeviceFlow() {
2869
+ if (live.manifest !== 'done') {
2870
+ return '<li class="slocked" data-onboard-step="device-flow"><b>Enable Device Flow — one checkbox</b><p>Unlocks once GitHub sign-in above is wired. It lets builders sign in from a terminal (<code style="font-family:var(--font-mono)">bongos setup</code>); web sign-in works without it.</p></li>';
2871
+ }
2872
+ var appUrl = live.appSlug ? 'https://github.com/settings/apps/' + esc(live.appSlug) : null;
2873
+ return '<li data-onboard-step="device-flow"><b>Enable Device Flow — one checkbox</b><p>' +
2874
+ (appUrl
2875
+ ? 'Open <a href="' + appUrl + '" target="_blank" rel="noopener">your app’s settings</a>, check <strong>Enable Device Flow</strong>, and save. '
2876
+ : 'GitHub → Settings → Developer settings → GitHub Apps → your app → check <strong>Enable Device Flow</strong> → save. ') +
2877
+ 'GitHub leaves it off on new apps and only offers it as this checkbox — until it’s ticked, terminal sign-in falls back to a web token from the hall’s Settings page.</p></li>';
2878
+ }
2879
+
2864
2880
  /* app-install — the ADR 0055 publish credential (task 1003049): the sign-in App
2865
2881
  doubles as it, but only an INSTALLED App can mint push tokens; without this
2866
2882
  one click every finished task waits at confirmed for a manual merge. */
@@ -2894,6 +2910,7 @@ h1[tabindex="-1"]:focus{outline:none;}
2894
2910
  var STEP_RENDERERS = {
2895
2911
  'github-repo': renderStepGithubRepo,
2896
2912
  'oauth-app': renderStepOauthApp,
2913
+ 'device-flow': renderStepDeviceFlow,
2897
2914
  'app-install': renderStepAppInstall,
2898
2915
  'first-sign-in': renderStepFirstSignIn,
2899
2916
  'enter-hall': renderStepEnterHall
@@ -273,7 +273,129 @@ async function promptOnboardingConsent() {
273
273
  }
274
274
  }
275
275
 
276
- (async () => {
276
+ // ── W1: the manifest-born App has Device Flow OFF (task 1003051) ─────────────
277
+ // GitHub creates an App from a manifest with Device Flow unchecked and offers
278
+ // no API to enable it (only a settings checkbox), so on a fresh wizard-born
279
+ // instance the documented CLI sign-in is hard-broken until the owner ticks the
280
+ // box. Detect the proxied refusal (POST /auth/device/start surfaces GitHub's
281
+ // device_flow_disabled — task 2126's envelope) and fall back to the hall's
282
+ // CLI-token path so setup still completes. Pure; exported for tests.
283
+ function isDeviceFlowDisabled(start) {
284
+ if (!start || start.ok) return false;
285
+ const err = start.data && start.data.error;
286
+ const text = [
287
+ typeof err === 'string' ? err : '',
288
+ err && typeof err === 'object' ? `${err.code || ''} ${err.message || ''}` : '',
289
+ start.data && start.data.message ? String(start.data.message) : '',
290
+ ].join(' ');
291
+ return /device_flow_disabled/i.test(text);
292
+ }
293
+
294
+ // The plain-language fallback guidance (task 1003051): what happened, the web
295
+ // path that works right now, and the one-checkbox owner fix. Pure; exported.
296
+ function deviceFlowFallbackLines(base) {
297
+ const settings = `${base}/builders/settings`;
298
+ return [
299
+ '',
300
+ "This project's GitHub App has Device Flow switched off, so terminal sign-in",
301
+ 'cannot start. Apps created by the one-click setup begin this way — GitHub',
302
+ 'only offers the switch as a checkbox in the App settings; no setup flow can',
303
+ 'tick it automatically.',
304
+ '',
305
+ 'Sign in through the web instead (works right now):',
306
+ ` 1. Open ${settings} and sign in with GitHub.`,
307
+ ' 2. Under "CLI Access", click "Re-issue CLI token".',
308
+ ' 3. Copy the token it shows and paste it below (or run the printed',
309
+ ' one-liner in another shell, then re-run setup).',
310
+ '',
311
+ 'To repair terminal sign-in for everyone (the project owner, once):',
312
+ ' GitHub → Settings → Developer settings → GitHub Apps → your app →',
313
+ ' check "Enable Device Flow" → Save. Web sign-in is unaffected either way.',
314
+ ];
315
+ }
316
+
317
+ // Verify a hall-minted CLI token against /me and save the session (the
318
+ // paste-token.js contract: nothing is written unless the token verifies).
319
+ // Returns { ok:true, builder } or { ok:false, status }. Exported for tests.
320
+ async function redeemCliToken(token) {
321
+ let res;
322
+ try {
323
+ res = await fetch(`${API_BASE}/api/gds/me`, { headers: { Authorization: `Bearer ${token}` } });
324
+ } catch (err) {
325
+ console.error(`Could not reach ${API_BASE}: ${err.message}`);
326
+ return { ok: false, status: 0 };
327
+ }
328
+ if (!res.ok) {
329
+ console.error(res.status === 401
330
+ ? `Token rejected by ${API_BASE} (HTTP 401) — copy the full token from the Settings page and try again.`
331
+ : `Verification call failed (HTTP ${res.status}).`);
332
+ return { ok: false, status: res.status };
333
+ }
334
+ const me = await res.json();
335
+ await saveSession({ token, builder: me.builder, api_base: API_BASE });
336
+ return { ok: true, builder: me.builder };
337
+ }
338
+
339
+ // Everything setup does AFTER a session is saved — shared verbatim by the
340
+ // Device Flow success path and the W1 CLI-token fallback, so the fallback
341
+ // completes the SAME onboarding end to end (task 1003051).
342
+ async function finishSetup(builder) {
343
+ console.log('');
344
+ console.log(`Authenticated as ${builder.github_login} (#${builder.id})`);
345
+ if (builder.display_name != null) console.log(`Display name: ${builder.display_name}`);
346
+ if (builder.total_credits != null) console.log(`Total credits: ${builder.total_credits}`);
347
+ console.log(`Session saved to ${SESSION_PATH}`);
348
+ // SEC #863: activate the project git hooks (core.hooksPath → .husky) so
349
+ // the tracked hooks run — here that's the pre-push permission gate
350
+ // (task 1002608); an instance may track more. Best effort — an activation
351
+ // hiccup must never fail an otherwise-good setup; the post-push main
352
+ // audit is the fail-closed net regardless.
353
+ try {
354
+ const { activate } = require('./install-git-hooks');
355
+ const r = activate();
356
+ const live = (r.hooks && r.hooks.length) ? r.hooks.join(', ') : 'none tracked';
357
+ if (r.status === 'activated') console.log(`Git hooks activated (live hooks: ${live})`);
358
+ else if (r.status === 'already-active') console.log(`Git hooks already active (live hooks: ${live})`);
359
+ else console.log(`Note: ${r.message}`);
360
+ } catch (e) {
361
+ console.log(`Note: could not activate git hooks (${e.message}); run: git config core.hooksPath .husky`);
362
+ }
363
+ await promptGeminiKey();
364
+ // #1281: deliver the GDS shared art key to entitled newcomers right now, so
365
+ // a brand-new Xenos's first art task works without waiting for the next
366
+ // session start. Best-effort, bounded — never block setup. (Phase 2 will
367
+ // make the promptGeminiKey copy above rank-aware — "we cover your first art
368
+ // tasks; add your own key when you reach Thetes".)
369
+ try {
370
+ const { spawnSync } = require('node:child_process');
371
+ const path = require('node:path');
372
+ spawnSync(process.execPath, [path.join(__dirname, 'fetch-art-key.js')],
373
+ { stdio: 'ignore', timeout: 8000 });
374
+ } catch (_) { /* non-fatal — the session-start sync will deliver it later */ }
375
+ await promptAnthropicLink();
376
+ await promptDisciplines();
377
+ await promptOnboardingConsent();
378
+ // #766: explicit "what's next?" footer. The Xenos path is action-only —
379
+ // no primer to read first — so a newcomer goes straight from setup to
380
+ // surveying the works. Ship three and the city makes you a Thetes.
381
+ try {
382
+ const meRes = await (await cliClient()).me.getMe();
383
+ if (meRes.ok) {
384
+ console.log('');
385
+ printOnboardingNudge(meRes.data);
386
+ console.log('');
387
+ console.log('Newcomer path:');
388
+ console.log(` 1. Choose thy craft: open ${hallBase()} (creative / engineering)`);
389
+ console.log(' 2. Survey work: /builder-start');
390
+ console.log(' 3. Claim + ship: /builder-claim N → /builder-ship');
391
+ console.log(' 4. Ship three works and you rise to Thetes — the whole queue opens.');
392
+ }
393
+ } catch (_) {
394
+ // never let a UX footer break a successful setup
395
+ }
396
+ }
397
+
398
+ async function main() {
277
399
  const forceFlag = process.argv.includes('--force');
278
400
 
279
401
  // Environment preflight (task 1002401, onboarding-audit area 4). Before auth,
@@ -310,6 +432,7 @@ async function promptOnboardingConsent() {
310
432
  dryRunStep(`No existing session at ${SESSION_PATH}; would proceed straight to Device Flow.`);
311
433
  }
312
434
  dryRunStep('Would POST /api/gds/auth/device/start to get a user_code + verification_uri from GitHub.');
435
+ dryRunStep('If GitHub refuses with device_flow_disabled (a one-click App whose checkbox is unticked, task 1003051): would print the web sign-in guidance (hall Settings → CLI Access) and prompt for a pasted CLI token instead of dead-ending.');
313
436
  dryRunStep('Would print the verification_uri and user_code, then poll /api/gds/auth/device/poll every few seconds until the user approves or the code expires.');
314
437
  dryRunStep(`On approval, would write the bearer token + builder profile to ${SESSION_PATH} (chmod 600).`);
315
438
  if (SKIP_KEYS) {
@@ -448,6 +571,31 @@ async function promptOnboardingConsent() {
448
571
  console.log('Starting GitHub Device Flow...');
449
572
  const start = await (await cliClient()).auth.postAuthDeviceStart();
450
573
  if (!start.ok) {
574
+ // W1 (task 1003051): a manifest-born App refuses Device Flow until its owner
575
+ // ticks the checkbox — fall back to the hall's CLI-token path instead of
576
+ // dead-ending the documented setup command.
577
+ if (isDeviceFlowDisabled(start)) {
578
+ for (const line of deviceFlowFallbackLines(API_BASE)) console.log(line);
579
+ console.log('');
580
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
581
+ // Resolve '' on stdin EOF too, so a non-interactive run exits with the
582
+ // guidance instead of hanging on a question nobody can answer.
583
+ const token = (await new Promise((resolve) => {
584
+ rl.question('Paste the CLI token here (or press Enter to quit): ', resolve);
585
+ rl.once('close', () => resolve(''));
586
+ })).trim();
587
+ rl.close();
588
+ if (!token) {
589
+ console.error('No token pasted — run the Settings page one-liner, then re-run setup.');
590
+ process.exit(1);
591
+ }
592
+ const redeemed = await redeemCliToken(token);
593
+ if (!redeemed.ok) process.exit(1);
594
+ console.log('');
595
+ console.log('Signed in via the web token — terminal Device Flow stays broken until the owner ticks the checkbox.');
596
+ await finishSetup(redeemed.builder);
597
+ process.exit(0);
598
+ }
451
599
  console.error(`device/start failed (${start.status}):`, start.data);
452
600
  if (start.data?.error === 'auth_not_configured') {
453
601
  console.error('');
@@ -509,65 +657,19 @@ async function promptOnboardingConsent() {
509
657
  const { token, builder } = poll.data;
510
658
  await saveSession({ token, builder, api_base: API_BASE });
511
659
  console.log('');
512
- console.log('');
513
- console.log(`Authenticated as ${builder.github_login} (#${builder.id})`);
514
- console.log(`Display name: ${builder.display_name}`);
515
- console.log(`Total credits: ${builder.total_credits}`);
516
- console.log(`Session saved to ${SESSION_PATH}`);
517
- // SEC #863: activate the project git hooks (core.hooksPath → .husky) so
518
- // the tracked hooks run — here that's the pre-push permission gate
519
- // (task 1002608); an instance may track more. Best effort — an activation
520
- // hiccup must never fail an otherwise-good setup; the post-push main
521
- // audit is the fail-closed net regardless.
522
- try {
523
- const { activate } = require('./install-git-hooks');
524
- const r = activate();
525
- const live = (r.hooks && r.hooks.length) ? r.hooks.join(', ') : 'none tracked';
526
- if (r.status === 'activated') console.log(`Git hooks activated (live hooks: ${live})`);
527
- else if (r.status === 'already-active') console.log(`Git hooks already active (live hooks: ${live})`);
528
- else console.log(`Note: ${r.message}`);
529
- } catch (e) {
530
- console.log(`Note: could not activate git hooks (${e.message}); run: git config core.hooksPath .husky`);
531
- }
532
- await promptGeminiKey();
533
- // #1281: deliver the GDS shared art key to entitled newcomers right now, so
534
- // a brand-new Xenos's first art task works without waiting for the next
535
- // session start. Best-effort, bounded — never block setup. (Phase 2 will
536
- // make the promptGeminiKey copy above rank-aware — "we cover your first art
537
- // tasks; add your own key when you reach Thetes".)
538
- try {
539
- const { spawnSync } = require('node:child_process');
540
- const path = require('node:path');
541
- spawnSync(process.execPath, [path.join(__dirname, 'fetch-art-key.js')],
542
- { stdio: 'ignore', timeout: 8000 });
543
- } catch (_) { /* non-fatal — the session-start sync will deliver it later */ }
544
- await promptAnthropicLink();
545
- await promptDisciplines();
546
- await promptOnboardingConsent();
547
- // #766: explicit "what's next?" footer. The Xenos path is action-only —
548
- // no primer to read first — so a newcomer goes straight from setup to
549
- // surveying the works. Ship three and the city makes you a Thetes.
550
- try {
551
- const meRes = await (await cliClient()).me.getMe();
552
- if (meRes.ok) {
553
- console.log('');
554
- printOnboardingNudge(meRes.data);
555
- console.log('');
556
- console.log('Newcomer path:');
557
- console.log(` 1. Choose thy craft: open ${hallBase()} (creative / engineering)`);
558
- console.log(' 2. Survey work: /builder-start');
559
- console.log(' 3. Claim + ship: /builder-claim N → /builder-ship');
560
- console.log(' 4. Ship three works and you rise to Thetes — the whole queue opens.');
561
- }
562
- } catch (_) {
563
- // never let a UX footer break a successful setup
564
- }
660
+ await finishSetup(builder);
565
661
  process.exit(0);
566
662
  }
567
663
  }
568
664
  console.error('\nTimed out waiting for approval.');
569
665
  process.exit(1);
570
- })().catch((err) => {
571
- console.error('fatal:', err);
572
- process.exit(1);
573
- });
666
+ }
667
+
668
+ if (require.main === module) {
669
+ main().catch((err) => {
670
+ console.error('fatal:', err);
671
+ process.exit(1);
672
+ });
673
+ }
674
+
675
+ module.exports = { isDeviceFlowDisabled, deviceFlowFallbackLines, redeemCliToken };
package/src/module-api.js CHANGED
@@ -839,8 +839,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
839
839
  // landed since 1.19.204 with no explicit bump. run 32061149280. (task 1002620)
840
840
  // 1.19.206 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
841
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)
844
+ // 1.19.208 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
845
+ // landed since 1.19.207 with no explicit bump. run 32068332007. (task 1002620)
842
846
  // ---------------------------------------------------------------------------
843
- const CORE_VERSION = '1.19.206'; // CI auto-patch carrier (ADR 0161); see changelog above
847
+ const CORE_VERSION = '1.19.208'; // CI auto-patch carrier (ADR 0161); see changelog above
844
848
 
845
849
  // A namespaced logger so a module's log lines are attributable + consistent.
846
850
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
package/tests/onboard.mjs CHANGED
@@ -15,7 +15,7 @@ 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', 'app-install', 'first-sign-in', 'enter-hall']);
18
+ assert.deepEqual(human, ['github-repo', 'oauth-app', 'device-flow', '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
@@ -25,6 +25,10 @@ test('onboardPlan is the canonical sequence: name+address first, the builders ha
25
25
  // must be prompted before the owner walks away into the hall.
26
26
  assert.ok(keys.indexOf('oauth-place') < keys.indexOf('app-install'), 'the App exists before it can be installed');
27
27
  assert.ok(keys.indexOf('app-install') < keys.indexOf('first-sign-in'), 'install prompted before sign-in');
28
+ // The Device Flow checkbox (task 1003051 / gap W1) also needs the App to exist,
29
+ // and belongs with the sign-in wiring it repairs.
30
+ assert.ok(keys.indexOf('oauth-place') < keys.indexOf('device-flow'), 'the App exists before its checkbox can be ticked');
31
+ assert.ok(keys.indexOf('device-flow') < keys.indexOf('first-sign-in'), 'checkbox prompted before sign-in');
28
32
  });
29
33
 
30
34
  test('onboardPlan(adopt): same sequence + human steps, but the scaffold-leg labels branch (task 2046)', () => {
@@ -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);