@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.
- package/.bongos-core.json +41 -26
- package/.claude/skills/new-project/SKILL.md +1 -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 +7 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +156 -3
- package/docs/api-reference.md +4 -2
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/provisioning/migrations/provisioning_009_project_settings.sql +19 -0
- package/modules/provisioning/onboard-plan.js +1 -0
- package/modules/provisioning/provisioning.js +41 -2
- package/modules/provisioning/routes/provisioning.js +63 -0
- package/modules/provisioning/tests/provisioning.mjs +38 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/projects.html +17 -0
- package/scripts/gds/setup.js +160 -58
- package/src/module-api.js +5 -1
- package/tests/onboard.mjs +5 -1
- package/tests/provisioning_settings.mjs +200 -0
- package/tests/setup_device_flow_fallback.mjs +179 -0
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
package/scripts/gds/setup.js
CHANGED
|
@@ -273,7 +273,129 @@ async function promptOnboardingConsent() {
|
|
|
273
273
|
}
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
-
(
|
|
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
|
-
|
|
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
|
-
}
|
|
571
|
-
|
|
572
|
-
|
|
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.
|
|
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);
|