@bongos/core 1.19.418 → 1.19.420
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 +95 -45
- package/.claude/skills/new-project/SKILL.md +22 -11
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +18 -0
- package/clients/bongos-client/index.cjs +18 -0
- package/clients/bongos-client/index.d.ts +28 -1
- package/clients/bongos-client/index.mjs +18 -0
- package/docs/adr/0207-connections-implementation-departures-and-honest-limits.md +90 -0
- package/docs/adr/0209-one-budget-for-the-account-existence-oracle.md +79 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +518 -5
- package/docs/api-reference.md +16 -3
- package/docs/copy-inventory.md +186 -174
- package/docs/copy-registry.json +348 -238
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +18 -0
- package/modules/onboarding/routes/access-requests.js +16 -3
- package/modules/platform-identity/account-visibility.js +58 -0
- package/modules/platform-identity/connections.js +490 -0
- package/modules/platform-identity/migrations/platform_identity_015_connections.sql +78 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/platform-identity.js +5 -5
- package/modules/platform-identity/routes/connections.js +236 -0
- package/modules/platform-identity/tests/platform-identity.mjs +20 -0
- package/modules/provisioning/migrations/provisioning_013_project_detail.sql +54 -0
- package/modules/provisioning/onboard-plan.js +13 -0
- package/modules/provisioning/provisioning.js +113 -5
- package/modules/provisioning/routes/provisioning.js +105 -0
- package/modules/provisioning/tests/provisioning.mjs +84 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/projects.html +161 -42
- package/scripts/gds/fitness.js +9 -0
- package/scripts/gds/onboard.js +41 -1
- package/scripts/gds/provision-net.js +7 -3
- package/src/bongos/middleware/rate-limit.js +68 -2
- package/src/bongos/routes/auth.js +19 -12
- package/src/module-api.js +13 -1
- package/tests/account_existence_rate_limit.mjs +375 -0
- package/tests/connections_api.mjs +568 -0
- package/tests/connections_ownership.mjs +313 -0
- package/tests/module_api.mjs +1 -0
- package/tests/onboard.mjs +56 -0
- package/tests/own_scope_guard.mjs +5 -0
- package/tests/projects_hub.mjs +69 -0
- package/tests/projects_hub_pre_uat.mjs +27 -5
- package/tests/provision.mjs +20 -0
|
@@ -86,6 +86,30 @@ function ownerFromTargetRef(ref) {
|
|
|
86
86
|
return m ? m[1] : null;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
// The project-detail answers, validated LOUDLY (task 1002334). `detail` arrives as
|
|
90
|
+
// a nested object, and the strict body validator only reaches top-level fields — so
|
|
91
|
+
// without this a typo'd answer key would be dropped by normalizeProjectDetail and
|
|
92
|
+
// the caller would be told nothing, which is exactly the silent-drop failure the
|
|
93
|
+
// strict validator exists to prevent (and that a re-request's type_ignored receipt
|
|
94
|
+
// was written to avoid). Returns null when clean, or a { code, message } to fail
|
|
95
|
+
// with. Both the create route and PATCH …/detail run it, so they cannot drift.
|
|
96
|
+
function badProjectDetail(detail) {
|
|
97
|
+
if (detail === undefined || detail === null) return null;
|
|
98
|
+
if (typeof detail !== 'object' || Array.isArray(detail)) {
|
|
99
|
+
return { code: 'bad_detail', message: 'detail must be an object of answers.' };
|
|
100
|
+
}
|
|
101
|
+
const vocab = provisioning.PROJECT_DETAIL_VOCAB;
|
|
102
|
+
for (const [key, value] of Object.entries(detail)) {
|
|
103
|
+
if (!Object.prototype.hasOwnProperty.call(vocab, key)) {
|
|
104
|
+
return { code: 'bad_detail', message: `Unknown detail answer "${key}" — the interview asks: ${Object.keys(vocab).join(', ')}.` };
|
|
105
|
+
}
|
|
106
|
+
if (!vocab[key].includes(value)) {
|
|
107
|
+
return { code: 'bad_detail', message: `"${key}" must be one of: ${vocab[key].join(', ')}.` };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
89
113
|
// Minimal HTML escaper for the callback landing page (no template engine here).
|
|
90
114
|
function esc(s) {
|
|
91
115
|
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
|
|
@@ -388,8 +412,16 @@ module.exports = function provisioningRoutes() {
|
|
|
388
412
|
onboard_mode: { type: 'string', maxLength: 20 },
|
|
389
413
|
// the declared project Type (task 1002772) — optional, closed vocabulary
|
|
390
414
|
type: { type: 'string', enum: [...provisioning.PROJECT_TYPES] },
|
|
415
|
+
// The optional project-detail step (task 1002334) — "tell us about your
|
|
416
|
+
// project", skippable everywhere. Absent means SKIPPED, which is a supported
|
|
417
|
+
// default: the project simply stays dark-matter until a description exists
|
|
418
|
+
// (ADR 0182 D4), and PATCH …/detail is the way back.
|
|
419
|
+
description: { type: 'string', maxLength: provisioning.DESCRIPTION_MAX },
|
|
420
|
+
detail: { type: 'object' },
|
|
391
421
|
})) return;
|
|
392
422
|
const body = req.body || {};
|
|
423
|
+
const detailFault = badProjectDetail(body.detail);
|
|
424
|
+
if (detailFault) return res.fail(detailFault.code, { status: 400, message: detailFault.message });
|
|
393
425
|
const slug = String(body.slug || '').trim().toLowerCase();
|
|
394
426
|
if (!provisioning.isValidSlug(slug)) {
|
|
395
427
|
return res.fail('bad_slug', 400, { reason: 'lowercase, 2–40 chars of [a-z0-9-], no leading/trailing/double dash' });
|
|
@@ -472,6 +504,9 @@ module.exports = function provisioningRoutes() {
|
|
|
472
504
|
targetRef: body.target_ref, hostingShape: shape,
|
|
473
505
|
domain, tier: body.tier, onboardMode,
|
|
474
506
|
type: body.type,
|
|
507
|
+
// The optional detail step's answers (task 1002334) — absent means the
|
|
508
|
+
// owner skipped it, which is the supported default, not an error.
|
|
509
|
+
description: body.description, detail: body.detail,
|
|
475
510
|
});
|
|
476
511
|
// Slug already taken by ANOTHER builder — refuse (never reassign ownership).
|
|
477
512
|
if (!created && String(instance.owner_builder_id) !== String(req.builder.id)) {
|
|
@@ -525,6 +560,16 @@ module.exports = function provisioningRoutes() {
|
|
|
525
560
|
hint: "a re-request never changes an existing instance's declared type",
|
|
526
561
|
};
|
|
527
562
|
}
|
|
563
|
+
// …and the same for the detail answers (task 1002334). Unlike type this one
|
|
564
|
+
// HAS a mutation route, so the receipt names it instead of just refusing —
|
|
565
|
+
// otherwise an owner who came back to fill in the description they skipped
|
|
566
|
+
// would be told nothing and left dark-matter with no idea why.
|
|
567
|
+
let detailIgnored;
|
|
568
|
+
if (!created && (body.description !== undefined || body.detail !== undefined)) {
|
|
569
|
+
detailIgnored = {
|
|
570
|
+
hint: `a re-request never changes an existing instance's project detail — PATCH /provisioning/instances/${instance.id}/detail instead`,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
528
573
|
res.status(created ? 201 : 200).json({
|
|
529
574
|
instance: provisioning.publicInstance(instance),
|
|
530
575
|
open_intent: intent ? { action: intent.action, state: intent.state } : null,
|
|
@@ -533,6 +578,7 @@ module.exports = function provisioningRoutes() {
|
|
|
533
578
|
...(domainIgnored ? { domain_ignored: domainIgnored } : {}),
|
|
534
579
|
...(noAddressIgnored ? { no_address_ignored: noAddressIgnored } : {}),
|
|
535
580
|
...(typeIgnored ? { type_ignored: typeIgnored } : {}),
|
|
581
|
+
...(detailIgnored ? { detail_ignored: detailIgnored } : {}),
|
|
536
582
|
});
|
|
537
583
|
} catch (err) {
|
|
538
584
|
log.error('[provisioning] POST /provisioning/instances', err);
|
|
@@ -644,6 +690,65 @@ module.exports = function provisioningRoutes() {
|
|
|
644
690
|
}
|
|
645
691
|
});
|
|
646
692
|
|
|
693
|
+
// ── The optional project detail (task 1002334, goal 1000046) ──────────────
|
|
694
|
+
// PATCH /provisioning/instances/:id/detail — answer (or re-answer) the
|
|
695
|
+
// "tell us about your project" step after creation. Body: any subset of
|
|
696
|
+
// { description, detail }. rank: any authenticated builder (own resource;
|
|
697
|
+
// archon may change any — same gate as settings).
|
|
698
|
+
//
|
|
699
|
+
// THIS ROUTE IS WHAT MAKES THE STEP SKIPPABLE. Skipping at creation leaves the
|
|
700
|
+
// project dark-matter — off the map and un-joinable (ADR 0182 D4) — and that is
|
|
701
|
+
// a supported default only because there is a way back. Without this route
|
|
702
|
+
// "optional" would mean "permanently unpublishable", i.e. a dead end, which is
|
|
703
|
+
// precisely what criterion `proj-detail-interview` refuses.
|
|
704
|
+
//
|
|
705
|
+
// Deliberately NOT a settings-apply: nothing here is a policy the running
|
|
706
|
+
// instance obeys, so there is no web.env to patch and no restart to queue. The
|
|
707
|
+
// answers live on the platform row until a catalog row materializes (the
|
|
708
|
+
// provisioning_007 `type` precedent), and R16 (task 1002335) is the surface that
|
|
709
|
+
// reads `description` to compute publishability and name what is still missing.
|
|
710
|
+
router.patch('/provisioning/instances/:id/detail', auth.requireBuilder, async (req, res) => {
|
|
711
|
+
const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
|
|
712
|
+
if (!id) return;
|
|
713
|
+
if (validateOrRespond(req, res, {
|
|
714
|
+
description: { type: 'string', maxLength: provisioning.DESCRIPTION_MAX },
|
|
715
|
+
detail: { type: 'object' },
|
|
716
|
+
})) return;
|
|
717
|
+
const body = req.body || {};
|
|
718
|
+
const detailFault = badProjectDetail(body.detail);
|
|
719
|
+
if (detailFault) return res.fail(detailFault.code, { status: 400, message: detailFault.message });
|
|
720
|
+
if (body.description === undefined && body.detail === undefined) {
|
|
721
|
+
return res.fail('bad_detail', { status: 400,
|
|
722
|
+
message: 'The body carries nothing to change — send description, detail, or both.' });
|
|
723
|
+
}
|
|
724
|
+
try {
|
|
725
|
+
const inst = await provisioning.getInstanceById(pool, id);
|
|
726
|
+
if (instanceAccessDenied(inst, req.builder)) return res.fail('instance_not_found', 404);
|
|
727
|
+
const actor = String(inst.owner_builder_id) === String(req.builder.id) ? 'api:self' : `api:archon:${req.builder.id}`;
|
|
728
|
+
// An empty description is CLEARING it, not a no-op string: normalize here so
|
|
729
|
+
// the column never holds '' — a blank string would read as answered to a
|
|
730
|
+
// `description IS NOT NULL` publish check and put an empty husk on the map.
|
|
731
|
+
const patch = {};
|
|
732
|
+
if (body.description !== undefined) patch.description = provisioning.normalizeDescription(body.description);
|
|
733
|
+
if (body.detail !== undefined) patch.detail = body.detail;
|
|
734
|
+
const updated = await provisioning.updateInstanceDetail(pool, id, patch);
|
|
735
|
+
await provisioning.recordEvent(pool, {
|
|
736
|
+
instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'detail',
|
|
737
|
+
detail: `project detail changed: ${Object.keys(patch).join(', ')}`,
|
|
738
|
+
actor,
|
|
739
|
+
}).catch(() => {});
|
|
740
|
+
res.json({
|
|
741
|
+
ok: true,
|
|
742
|
+
description: updated.description || null,
|
|
743
|
+
detail: provisioning.projectDetail(updated),
|
|
744
|
+
options: provisioning.PROJECT_DETAIL_VOCAB,
|
|
745
|
+
});
|
|
746
|
+
} catch (err) {
|
|
747
|
+
log.error('[provisioning] PATCH /provisioning/instances/:id/detail', err);
|
|
748
|
+
res.fail('detail_update_failed', { status: 500, message: 'internal error' });
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
|
|
647
752
|
// ── Per-project settings (task 1003040, goal 1000058) ─────────────────────
|
|
648
753
|
// The foundation the manage-page wave hangs off: effective settings are the
|
|
649
754
|
// DEFAULTS (today's behavior — public surfaces, apply-to-join) overlaid with
|
|
@@ -298,5 +298,89 @@ await ta('updateInstanceSettings: jsonb MERGE (||) + updated_at, parameterized,
|
|
|
298
298
|
assert.deepEqual(params, [18, JSON.stringify({ joinability: 'open' })]);
|
|
299
299
|
});
|
|
300
300
|
|
|
301
|
+
console.log('');
|
|
302
|
+
console.log('the optional project-detail interview (BV1.R15, task 1002334):');
|
|
303
|
+
|
|
304
|
+
t('normalizeDescription: collapses whitespace, caps, and empty means ABSENT (never an empty string)', () => {
|
|
305
|
+
const NL = String.fromCharCode(10);
|
|
306
|
+
assert.equal(P.normalizeDescription(' A co-op farming' + NL + ' game. '), 'A co-op farming game.');
|
|
307
|
+
// null, not '' — the publish gate reads `description IS NOT NULL`, so a blank
|
|
308
|
+
// string stored in a nullable column would publish an empty husk (ADR 0182 D4)
|
|
309
|
+
assert.equal(P.normalizeDescription(' '), null);
|
|
310
|
+
assert.equal(P.normalizeDescription(''), null);
|
|
311
|
+
assert.equal(P.normalizeDescription(null), null);
|
|
312
|
+
assert.equal(P.normalizeDescription(42), null);
|
|
313
|
+
assert.equal(P.normalizeDescription('x'.repeat(P.DESCRIPTION_MAX + 50)).length, P.DESCRIPTION_MAX);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
t('normalizeProjectDetail: on-vocabulary keys only — an unknown key or value is DROPPED, never defaulted', () => {
|
|
317
|
+
assert.deepEqual(P.normalizeProjectDetail({ team_shape: 'community' }), { team_shape: 'community' });
|
|
318
|
+
assert.deepEqual(P.normalizeProjectDetail({ team_shape: 'enormous' }), {}, 'an off-vocabulary answer is not stored');
|
|
319
|
+
assert.deepEqual(P.normalizeProjectDetail({ favourite_colour: 'blue' }), {}, 'an unknown question is not stored');
|
|
320
|
+
// skipping is the supported default, so every not-an-object shape reads as {}
|
|
321
|
+
for (const skipped of [undefined, null, 'solo', ['solo'], 7]) {
|
|
322
|
+
assert.deepEqual(P.normalizeProjectDetail(skipped), {}, 'a skipped interview stores {}');
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
t('projectDetail: a stored value that has since LEFT the vocabulary never leaks out of the read surface', () => {
|
|
327
|
+
assert.deepEqual(P.projectDetail({ detail: { team_shape: 'solo' } }), { team_shape: 'solo' });
|
|
328
|
+
assert.deepEqual(P.projectDetail({ detail: { team_shape: 'guild' } }), {});
|
|
329
|
+
assert.deepEqual(P.projectDetail({}), {}, 'a row predating the migration reads as skipped');
|
|
330
|
+
assert.deepEqual(P.projectDetail(null), {});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
t('PROJECT_DETAIL_VOCAB carries NO defaults — an unanswered question stays unanswered', () => {
|
|
334
|
+
// The deliberate asymmetry with SETTINGS_VOCAB: a setting is a policy the
|
|
335
|
+
// instance must obey, so it needs a default; a detail answer is something the
|
|
336
|
+
// owner SAID, and a default would feed R17 a recommendation nobody asked for.
|
|
337
|
+
assert.equal(P.DEFAULT_PROJECT_DETAIL, undefined, 'there is no defaults table to drift from');
|
|
338
|
+
for (const vocab of Object.values(P.PROJECT_DETAIL_VOCAB)) {
|
|
339
|
+
assert.ok(Array.isArray(vocab) && vocab.length, 'every question has a closed answer list');
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
t('publicInstance surfaces both halves, and both are legitimately empty when skipped', () => {
|
|
344
|
+
const answered = P.publicInstance({ id: 1, slug: 'mercury', description: 'A thing.', detail: { team_shape: 'solo' } });
|
|
345
|
+
assert.equal(answered.description, 'A thing.');
|
|
346
|
+
assert.deepEqual(answered.detail, { team_shape: 'solo' });
|
|
347
|
+
const skipped = P.publicInstance({ id: 2, slug: 'venus' });
|
|
348
|
+
assert.equal(skipped.description, null, 'no description is null — what keeps the project dark-matter');
|
|
349
|
+
assert.deepEqual(skipped.detail, {}, 'and a skipped interview reads as {}, never undefined');
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
await ta('createInstance: the answers ride the INSERT, and a skipped interview inserts null + {}', async () => {
|
|
353
|
+
let params = null;
|
|
354
|
+
const db = { query: async (q, p) => { params = p; return { rows: [{ id: 3 }] }; } };
|
|
355
|
+
await P.createInstance(db, {
|
|
356
|
+
ownerBuilderId: 1, slug: 'mercury', description: ' A co-op game. ',
|
|
357
|
+
detail: { team_shape: 'community', bogus: 'x' },
|
|
358
|
+
});
|
|
359
|
+
assert.equal(params[11], 'A co-op game.', 'the description is normalized before it is stored');
|
|
360
|
+
assert.equal(params[12], JSON.stringify({ team_shape: 'community' }), 'and off-vocabulary keys never reach the column');
|
|
361
|
+
await P.createInstance(db, { ownerBuilderId: 1, slug: 'venus' });
|
|
362
|
+
assert.equal(params[11], null, 'a skipped interview stores null — not an empty string');
|
|
363
|
+
assert.equal(params[12], '{}');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
await ta('updateInstanceDetail: detail MERGES, description REPLACES, and neither is written unasked', async () => {
|
|
367
|
+
let sql = '', params = null;
|
|
368
|
+
const db = { query: async (q, p) => { sql = q; params = p; return { rows: [{ id: 18 }] }; } };
|
|
369
|
+
|
|
370
|
+
await P.updateInstanceDetail(db, 18, { detail: { team_shape: 'small-team' } });
|
|
371
|
+
assert.ok(sql.includes('detail = detail || $2::jsonb'), 'one answer does not erase the others');
|
|
372
|
+
assert.ok(!sql.includes('description ='), 'an unsent description is left alone, never nulled');
|
|
373
|
+
assert.deepEqual(params, [18, JSON.stringify({ team_shape: 'small-team' })]);
|
|
374
|
+
|
|
375
|
+
await P.updateInstanceDetail(db, 18, { description: 'Filled in later.' });
|
|
376
|
+
assert.ok(sql.includes('description = $2'), 'the way back for a skipped step');
|
|
377
|
+
assert.ok(!sql.includes('detail = detail'), 'and it touches no answers it was not given');
|
|
378
|
+
|
|
379
|
+
// clearing is allowed and un-publishes — the honest consequence of deleting
|
|
380
|
+
// half the identity gate, not a bug to guard against
|
|
381
|
+
await P.updateInstanceDetail(db, 18, { description: null });
|
|
382
|
+
assert.deepEqual(params, [18, null]);
|
|
383
|
+
});
|
|
384
|
+
|
|
301
385
|
console.log(`\nprovisioning module: ${passed} passed, ${failed} failed`);
|
|
302
386
|
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.420",
|
|
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.420",
|
|
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.420",
|
|
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",
|