@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.
Files changed (48) hide show
  1. package/.bongos-core.json +95 -45
  2. package/.claude/skills/new-project/SKILL.md +22 -11
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +18 -0
  5. package/clients/bongos-client/index.cjs +18 -0
  6. package/clients/bongos-client/index.d.ts +28 -1
  7. package/clients/bongos-client/index.mjs +18 -0
  8. package/docs/adr/0207-connections-implementation-departures-and-honest-limits.md +90 -0
  9. package/docs/adr/0209-one-budget-for-the-account-existence-oracle.md +79 -0
  10. package/docs/adr/README.md +2 -0
  11. package/docs/api/openapi.json +518 -5
  12. package/docs/api-reference.md +16 -3
  13. package/docs/copy-inventory.md +186 -174
  14. package/docs/copy-registry.json +348 -238
  15. package/docs/file-map.md +1 -0
  16. package/docs/module-api-changelog.md +4 -0
  17. package/modules/dev-box/app/src/vendor/bongos-client.cjs +18 -0
  18. package/modules/onboarding/routes/access-requests.js +16 -3
  19. package/modules/platform-identity/account-visibility.js +58 -0
  20. package/modules/platform-identity/connections.js +490 -0
  21. package/modules/platform-identity/migrations/platform_identity_015_connections.sql +78 -0
  22. package/modules/platform-identity/module.json +1 -1
  23. package/modules/platform-identity/platform-identity.js +5 -5
  24. package/modules/platform-identity/routes/connections.js +236 -0
  25. package/modules/platform-identity/tests/platform-identity.mjs +20 -0
  26. package/modules/provisioning/migrations/provisioning_013_project_detail.sql +54 -0
  27. package/modules/provisioning/onboard-plan.js +13 -0
  28. package/modules/provisioning/provisioning.js +113 -5
  29. package/modules/provisioning/routes/provisioning.js +105 -0
  30. package/modules/provisioning/tests/provisioning.mjs +84 -0
  31. package/package-lock.json +2 -2
  32. package/package.json +1 -1
  33. package/public-landing/projects.html +161 -42
  34. package/scripts/gds/fitness.js +9 -0
  35. package/scripts/gds/onboard.js +41 -1
  36. package/scripts/gds/provision-net.js +7 -3
  37. package/src/bongos/middleware/rate-limit.js +68 -2
  38. package/src/bongos/routes/auth.js +19 -12
  39. package/src/module-api.js +13 -1
  40. package/tests/account_existence_rate_limit.mjs +375 -0
  41. package/tests/connections_api.mjs +568 -0
  42. package/tests/connections_ownership.mjs +313 -0
  43. package/tests/module_api.mjs +1 -0
  44. package/tests/onboard.mjs +56 -0
  45. package/tests/own_scope_guard.mjs +5 -0
  46. package/tests/projects_hub.mjs +69 -0
  47. package/tests/projects_hub_pre_uat.mjs +27 -5
  48. 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.418",
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.418",
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.418",
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",