@bongos/core 1.19.419 → 1.19.421

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 (61) hide show
  1. package/.bongos-core.json +108 -58
  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 +2 -0
  5. package/clients/bongos-client/index.cjs +2 -0
  6. package/clients/bongos-client/index.d.ts +5 -1
  7. package/clients/bongos-client/index.mjs +2 -0
  8. package/docs/adr/0209-one-budget-for-the-account-existence-oracle.md +79 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +103 -5
  11. package/docs/api-reference.md +4 -3
  12. package/docs/copy-inventory.md +282 -269
  13. package/docs/copy-registry.json +487 -368
  14. package/docs/design/mocks/hub/mine.html +377 -0
  15. package/docs/design/projects-hub-direction-v2.md +25 -1
  16. package/docs/file-map.md +2 -0
  17. package/docs/module-api-changelog.md +4 -0
  18. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  19. package/modules/hall-ui/public/settings.html +3 -2
  20. package/modules/hall-ui/public/settings.js +10 -6
  21. package/modules/onboarding/routes/access-requests.js +16 -3
  22. package/modules/platform-identity/account-visibility.js +94 -3
  23. package/modules/platform-identity/platform-identity.js +50 -55
  24. package/modules/platform-identity/routes/connections.js +3 -3
  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/modules/ui-design/kit/fixtures/featured-hub.json +1 -0
  32. package/modules/ui-design/kit/fixtures/provisioning-instances-empty.json +4 -0
  33. package/modules/ui-design/kit/fixtures/provisioning-instances.json +99 -0
  34. package/modules/ui-design/kit/lib.js +6 -2
  35. package/modules/ui-design/kit/probe.js +1 -1
  36. package/modules/ui-design/kit/render.js +1 -1
  37. package/modules/ui-design/kit/serve.js +63 -2
  38. package/package-lock.json +2 -2
  39. package/package.json +1 -1
  40. package/public-landing/assets/cosmos.css +224 -18
  41. package/public-landing/projects-empty.states.json +49 -0
  42. package/public-landing/projects-floor.states.json +63 -0
  43. package/public-landing/projects.html +273 -93
  44. package/public-landing/projects.probes.json +32 -7
  45. package/public-landing/projects.states.json +10 -6
  46. package/scripts/gds/fitness.js +9 -0
  47. package/scripts/gds/onboard.js +41 -1
  48. package/scripts/gds/provision-net.js +7 -3
  49. package/src/bongos/middleware/rate-limit.js +68 -2
  50. package/src/bongos/routes/auth.js +19 -12
  51. package/src/module-api.js +13 -1
  52. package/tests/account_existence_rate_limit.mjs +375 -0
  53. package/tests/account_privacy_flags.mjs +17 -10
  54. package/tests/effective_visibility_predicate.mjs +891 -0
  55. package/tests/module_api.mjs +1 -0
  56. package/tests/onboard.mjs +56 -0
  57. package/tests/profile_route.mjs +22 -11
  58. package/tests/projects_hub.mjs +102 -5
  59. package/tests/projects_hub_pre_uat.mjs +45 -11
  60. package/tests/provision.mjs +20 -0
  61. package/tests/ui_design_kit.mjs +32 -2
@@ -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);
@@ -0,0 +1 @@
1
+ {"_":"The hub's catalog (task 1003345): the landing's featured.json plus Tidewater, an all-null row nobody owns — the card-floor state opens ITS card, because Demo is owned by the signed-in stub (provisioning-instances.json) and an owned orb's plain click opens the manage view, never the card. Served under the name \"featured\" by projects.states.json's stub.fixtures alias; the landing keeps featured.json.","projects":[{"origin":"https://amazonprimea.com","name":"Off the Boats","tagline":"A singular shop, and a broader videogame in a persistent ancient Greek world that collides with modern day commerce & products.","art_url":"/assets/world/r2-chrome-blob-one.jpg","type":"game","template":"bead-wash","created_at":"2026-07-15T22:12:57.732Z","builders":11,"tags":["art-pipeline","character-anim","demo","game"],"owner":"lars589","owner_avatar":"https://avatars.githubusercontent.com/u/283020391?v=4","faces":["https://avatars.githubusercontent.com/u/283020391?v=4","https://avatars.githubusercontent.com/u/64553469?v=4","https://avatars.githubusercontent.com/u/290184380?v=4","https://avatars.githubusercontent.com/u/295990394?v=4","https://avatars.githubusercontent.com/u/35337514?v=4"],"shipped_30d":0,"shipped_30d_capped":false,"hall_origin":"https://builders.amazonprimea.com"},{"origin":"https://demo.cloudbongos.com","name":"Demo","tagline":"A live demo instance on Cloud Bongos.","art_url":null,"type":null,"template":"pair-gold-wash","created_at":"2026-07-14T19:16:38.160Z","builders":null,"tags":null,"owner":null,"owner_avatar":null,"faces":null,"shipped_30d":null,"shipped_30d_capped":null,"hall_origin":null},{"origin":"https://emersonian-circles.cloudbongos.com","name":"Emersonian Circles","tagline":"An independent project built on Cloud Bongos.","art_url":null,"type":null,"template":null,"created_at":"2026-07-25T02:21:55.641Z","builders":1,"tags":[],"owner":"lars589","owner_avatar":"https://avatars.githubusercontent.com/u/283020391?v=4","faces":[],"shipped_30d":30,"shipped_30d_capped":true,"hall_origin":null},{"origin":"https://tidewater.cloudbongos.com","name":"Tidewater","tagline":"A project that has declared nothing yet.","art_url":null,"type":null,"template":null,"created_at":"2026-07-20T08:00:00.000Z","builders":null,"tags":null,"owner":null,"owner_avatar":null,"faces":null,"shipped_30d":null,"shipped_30d_capped":null,"hall_origin":null}]}
@@ -0,0 +1,4 @@
1
+ {
2
+ "_": "The empty mine view (task 1003345): an owner with no projects yet. Named by a states file's stub.fixtures alias.",
3
+ "instances": []
4
+ }
@@ -0,0 +1,99 @@
1
+ {
2
+ "_": "The owner's projects for the kit (task 1003345): four states the mine list and the manage page render — live, standing up, snagged, torn down. Timestamps written \"$now-3m\" / \"$now-1d\" are resolved by the stub at request time so \"checked 3m ago\" stays true. Mooring (task 1003345, the grade's finding): live but unreachable since its last probe, so the restart door renders and the restart probe has a row to drive.",
3
+ "instances": [
4
+ {
5
+ "id": 101,
6
+ "slug": "demo",
7
+ "status": "active",
8
+ "domain": "demo.cloudbongos.com",
9
+ "target_ref": "builder/demo",
10
+ "hosting_shape": "co-tenant",
11
+ "onboard_mode": "scaffold",
12
+ "created_at": "2026-08-01T10:00:00.000Z",
13
+ "provisioned_at": "2026-08-01T10:20:00.000Z",
14
+ "updated_at": "$now-3m",
15
+ "last_verified_at": "$now-3m",
16
+ "last_verified_up": true,
17
+ "error_note": null,
18
+ "dns_note": null,
19
+ "torn_down_at": null,
20
+ "open_intent": null
21
+ },
22
+ {
23
+ "id": 102,
24
+ "slug": "harbor",
25
+ "status": "provisioning",
26
+ "domain": null,
27
+ "target_ref": "builder/harbor",
28
+ "hosting_shape": "standalone",
29
+ "onboard_mode": "scaffold",
30
+ "created_at": "$now-6m",
31
+ "provisioned_at": null,
32
+ "updated_at": "$now-2m",
33
+ "last_verified_at": null,
34
+ "last_verified_up": null,
35
+ "error_note": null,
36
+ "dns_note": null,
37
+ "torn_down_at": null,
38
+ "open_intent": {
39
+ "action": "standup",
40
+ "state": "running",
41
+ "created_at": "$now-6m"
42
+ }
43
+ },
44
+ {
45
+ "id": 103,
46
+ "slug": "tidepool",
47
+ "status": "error",
48
+ "domain": "tidepool.cloudbongos.com",
49
+ "target_ref": "builder/tidepool",
50
+ "hosting_shape": "co-tenant",
51
+ "onboard_mode": "adopt",
52
+ "created_at": "2026-08-20T09:00:00.000Z",
53
+ "provisioned_at": null,
54
+ "updated_at": "$now-1d",
55
+ "last_verified_at": null,
56
+ "last_verified_up": null,
57
+ "error_note": "The deploy could not read the repository: it is private, and the deploy key only reads public ones.",
58
+ "dns_note": null,
59
+ "torn_down_at": null,
60
+ "open_intent": null
61
+ },
62
+ {
63
+ "id": 104,
64
+ "slug": "old-lantern",
65
+ "status": "torn_down",
66
+ "domain": "old-lantern.cloudbongos.com",
67
+ "target_ref": "builder/old-lantern",
68
+ "hosting_shape": "co-tenant",
69
+ "onboard_mode": "scaffold",
70
+ "created_at": "2026-07-02T09:00:00.000Z",
71
+ "provisioned_at": "2026-07-02T09:30:00.000Z",
72
+ "updated_at": "2026-08-15T12:00:00.000Z",
73
+ "last_verified_at": "2026-08-15T11:50:00.000Z",
74
+ "last_verified_up": true,
75
+ "error_note": null,
76
+ "dns_note": null,
77
+ "torn_down_at": "2026-08-15T12:00:00.000Z",
78
+ "open_intent": null
79
+ },
80
+ {
81
+ "id": 105,
82
+ "slug": "mooring",
83
+ "status": "active",
84
+ "domain": "mooring.cloudbongos.com",
85
+ "target_ref": "builder/mooring",
86
+ "hosting_shape": "co-tenant",
87
+ "onboard_mode": "scaffold",
88
+ "created_at": "2026-08-10T09:00:00.000Z",
89
+ "provisioned_at": "2026-08-10T09:20:00.000Z",
90
+ "updated_at": "$now-20m",
91
+ "last_verified_at": "$now-20m",
92
+ "last_verified_up": false,
93
+ "error_note": null,
94
+ "dns_note": null,
95
+ "torn_down_at": null,
96
+ "open_intent": null
97
+ }
98
+ ]
99
+ }
@@ -237,6 +237,8 @@ function reducedMotionProblems({ running = [], frames = 0 } = {}) {
237
237
  // { "page": "/projects", "surface"?: "public-landing", "stub"?: { prefix?, child? },
238
238
  // "modes"?: ["dark" | "light", …] (the modes the page renders; default both — a page that pins
239
239
  // one mode declares it here), "modeQuery"?: "mode" | false, "ignoreRequests"?: [regex strings], "ignoreConsole"?: [...],
240
+ // stub.fixtures?: { "<fixture name>": "<other fixture file>" } (serve another file under a name — an empty list, a
241
+ // failure shape — for the states in THIS file; task 1003345),
240
242
  // "ignoreControls"?: [selectors], "states": { name: { url?, auth?, actions?, expect? } },
241
243
  // "reducedMotion"?: { auth?, url?, actions? } }
242
244
  function validateStatesFile(json) {
@@ -269,10 +271,11 @@ function validateStatesFile(json) {
269
271
  }
270
272
  function validateStub(stub, at) {
271
273
  if (stub === undefined) return [];
272
- if (!stub || typeof stub !== 'object' || Array.isArray(stub)) return [`${at}: an object { prefix?, child? }`];
274
+ if (!stub || typeof stub !== 'object' || Array.isArray(stub)) return [`${at}: an object { prefix?, child?, fixtures? }`];
273
275
  const errors = [];
274
276
  if (stub.prefix !== undefined && (typeof stub.prefix !== 'string' || !stub.prefix.startsWith('/') || !stub.prefix.endsWith('/'))) errors.push(`${at}.prefix: a URL prefix like "/builders/"`);
275
277
  if (stub.child !== undefined && typeof stub.child !== 'boolean') errors.push(`${at}.child: boolean (serve the child-instance shape)`);
278
+ if (stub.fixtures !== undefined && !(stub.fixtures && typeof stub.fixtures === 'object' && !Array.isArray(stub.fixtures) && Object.values(stub.fixtures).every((v) => typeof v === 'string' && /^[a-z0-9-]+$/.test(v)))) errors.push(`${at}.fixtures: an object of fixture name → the fixture file to serve under it (kebab-case names)`);
276
279
  return errors;
277
280
  }
278
281
 
@@ -339,11 +342,12 @@ function freePort() {
339
342
  s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => resolve(p)); });
340
343
  });
341
344
  }
342
- async function startStub({ surface, auth = false, port, prefix, child = false } = {}) {
345
+ async function startStub({ surface, auth = false, port, prefix, child = false, fixtures } = {}) {
343
346
  const p = port || await freePort();
344
347
  const env = { ...process.env, STUB_PORT: String(p), AUTH: auth ? '1' : '0', CHILD: child ? '1' : '0' };
345
348
  if (surface) env.SURFACE = surface;
346
349
  if (prefix) env.PREFIX = prefix;
350
+ if (fixtures) env.FIXTURE_ALIAS = JSON.stringify(fixtures);
347
351
  const proc = spawn(process.execPath, [path.join(KIT_DIR, 'serve.js')], { env, stdio: ['ignore', 'pipe', 'pipe'] });
348
352
  await new Promise((resolve, reject) => {
349
353
  let out = '';
@@ -52,7 +52,7 @@ async function main() {
52
52
 
53
53
  const stubs = [];
54
54
  let baseOut = lib.argOf(args, '--base-out', null), baseIn = lib.argOf(args, '--base-in', null);
55
- const stubOpts = { surface, prefix: json.stub && json.stub.prefix, child: json.stub && json.stub.child };
55
+ const stubOpts = { surface, prefix: json.stub && json.stub.prefix, child: json.stub && json.stub.child, fixtures: json.stub && json.stub.fixtures };
56
56
  if (!baseOut) { const s = await lib.startStub({ ...stubOpts, auth: false }); stubs.push(s); baseOut = s.base; }
57
57
  if (!baseIn) { const s = await lib.startStub({ ...stubOpts, auth: true }); stubs.push(s); baseIn = s.base; }
58
58
  const stop = () => stubs.forEach((s) => s.stop());
@@ -76,7 +76,7 @@ async function main() {
76
76
 
77
77
  const stubs = [];
78
78
  let baseOut = lib.argOf(args, '--base-out', null), baseIn = lib.argOf(args, '--base-in', null);
79
- const stubOpts = { surface, prefix: json.stub && json.stub.prefix, child: json.stub && json.stub.child };
79
+ const stubOpts = { surface, prefix: json.stub && json.stub.prefix, child: json.stub && json.stub.child, fixtures: json.stub && json.stub.fixtures };
80
80
  if (!baseOut) { const s = await lib.startStub({ ...stubOpts, auth: false }); stubs.push(s); baseOut = s.base; }
81
81
  if (!baseIn) { const s = await lib.startStub({ ...stubOpts, auth: true }); stubs.push(s); baseIn = s.base; }
82
82
  const stop = () => stubs.forEach((s) => s.stop());
@@ -40,6 +40,9 @@ const AUTH = process.env.AUTH === '1';
40
40
  const CHILD = process.env.CHILD === '1';
41
41
  const LIVE = process.env.LIVE || '';
42
42
  const FAIL = new Set(String(process.env.FAIL || '').split(',').map((s) => s.trim()).filter(Boolean));
43
+ // a states file may point a fixture NAME at another FILE (stub.fixtures, task 1003345) — the
44
+ // way the mine view's empty state is rendered without a second stub shape
45
+ const ALIAS = JSON.parse(process.env.FIXTURE_ALIAS || '{}');
43
46
  const FIXTURES = path.join(__dirname, 'fixtures');
44
47
  const DOCS = path.join(ROOT, 'docs');
45
48
  const TRANSFORM = path.join(ROOT, 'scripts', 'gds', 'ui-kit-transform.js');
@@ -101,11 +104,59 @@ const UNAUTH = { error: { code: 'unauthenticated', message: 'unauthenticated' }
101
104
  function json(res, status, body) { res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(body)); }
102
105
  function fixture(name, res, origin) {
103
106
  if (FAIL.has(name)) return json(res, 500, { error: { code: 'stub_failure', message: `FAIL=${name}` } });
104
- const file = path.join(FIXTURES, name + '.json');
107
+ const file = path.join(FIXTURES, (ALIAS[name] || name) + '.json');
105
108
  if (!fs.existsSync(file)) return json(res, 404, { error: { code: 'no_fixture', message: name } });
106
109
  res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
107
110
  res.end(name === 'featured' ? JSON.stringify(featured(file, origin)) : fs.readFileSync(file));
108
111
  }
112
+ // The owner's projects (task 1003345): the list, one project ({ instance, open_intent }: a
113
+ // wrong id answers 404, the route's own shape), and its settings row. "$now-3m" strings are
114
+ // resolved per request so a "live" chip reads "checked 3m ago" on every render.
115
+ function stamp(v) {
116
+ if (Array.isArray(v)) return v.map(stamp);
117
+ if (v && typeof v === 'object') { const o = {}; for (const [k, x] of Object.entries(v)) o[k] = stamp(x); return o; }
118
+ const m = typeof v === 'string' && v.match(/^\$now(?:([+-]\d+)([smhd]))?$/);
119
+ if (!m) return v;
120
+ const unit = { s: 1e3, m: 6e4, h: 36e5, d: 864e5 }[m[2] || 's'];
121
+ return new Date(Date.now() + (m[1] ? Number(m[1]) * unit : 0)).toISOString();
122
+ }
123
+ // the receipt each ask gets: the message the page shows verbatim, so a probe can pin it
124
+ const FLOW_RECEIPTS = {
125
+ teardown: { queued: true, message: 'Teardown queued.' },
126
+ restart: { queued: true, message: 'Restart queued.' },
127
+ domain: { recorded: true, queued: true, message: 'Address recorded.' },
128
+ provision: { queued: true, message: 'Queued. The platform picks it up from here.' },
129
+ settings: { settings: { platform_visibility: 'public', joinability: 'open' }, push: { queued: true, message: 'Saved. The project restarts to apply it.' } },
130
+ };
131
+ // a teardown or restart ask is REMEMBERED for the run: the page re-reads the project right
132
+ // after a receipt (doDelete → loadManage), and what it must then see is the queued intent —
133
+ // the same open_intent shape the route stamps — not the untouched fixture row
134
+ const ASKED = new Map();
135
+ function flowReceipt(rest, method) {
136
+ if (method === 'POST' && rest === '/provisioning/instances') return FLOW_RECEIPTS.provision;
137
+ const m = rest.match(/^\/provisioning\/instances\/(\d+)\/(teardown|restart|domain|settings)$/);
138
+ if (!m) return null;
139
+ if (m[2] === 'settings') return method === 'PATCH' ? FLOW_RECEIPTS.settings : null;
140
+ if (method !== 'POST') return null;
141
+ if (m[2] === 'teardown' || m[2] === 'restart') ASKED.set(m[1], m[2]);
142
+ return FLOW_RECEIPTS[m[2]];
143
+ }
144
+ function withAsk(inst) {
145
+ const action = ASKED.get(String(inst.id));
146
+ return action ? { ...inst, open_intent: { action, state: 'queued', created_at: '$now' } } : inst;
147
+ }
148
+ function instances(res, id, settings) {
149
+ const name = 'provisioning-instances';
150
+ if (FAIL.has(name)) return json(res, 500, { error: { code: 'stub_failure', message: `FAIL=${name}` } });
151
+ const file = path.join(FIXTURES, (ALIAS[name] || name) + '.json');
152
+ if (!fs.existsSync(file)) return json(res, 404, { error: { code: 'no_fixture', message: name } });
153
+ const list = stamp((JSON.parse(fs.readFileSync(file, 'utf8')).instances || []).map(withAsk));
154
+ if (id === null) return json(res, 200, { instances: list });
155
+ const inst = list.find((i) => String(i.id) === String(id));
156
+ if (!inst) return json(res, 404, { error: { code: 'not_found', message: 'no such instance' } });
157
+ if (settings) return json(res, 200, { settings: { platform_visibility: 'public', joinability: 'open' }, options: { platform_visibility: ['public', 'gated'], joinability: ['open', 'apply', 'invite_only'] } });
158
+ return json(res, 200, { instance: inst, open_intent: inst.open_intent || null });
159
+ }
109
160
  // A project's art in the featured fixture may be root-relative (the surface's
110
161
  // own /assets/…, served by this stub) — the page accepts only an http(s) art
111
162
  // URL, so it is absolutised to this origin per request, as the builder's
@@ -156,7 +207,17 @@ const server = http.createServer((req, res) => {
156
207
  if (FEEDS[rest]) return LIVE ? proxy(req, res) : fixture(FEEDS[rest], res, origin);
157
208
  if (rest === '/me') return AUTH ? json(res, 200, me(origin)) : json(res, 401, UNAUTH);
158
209
  if (AUTHED[rest]) return AUTH ? fixture(AUTHED[rest], res) : json(res, 401, UNAUTH);
159
- if (rest === '/provisioning/instances') return AUTH ? json(res, 200, { instances: [] }) : json(res, 401, UNAUTH);
210
+ if (rest === '/provisioning/instances' && req.method === 'GET') return AUTH ? instances(res, null, false) : json(res, 401, UNAUTH);
211
+ const one = rest.match(/^\/provisioning\/instances\/(\d+)(\/settings)?$/);
212
+ if (one && req.method === 'GET') return AUTH ? instances(res, one[1], !!one[2]) : json(res, 401, UNAUTH);
213
+ // the manage flows (task 1003345): every door on a row and on the manage page only ASKS
214
+ // (ADR 0111) — the stub answers each ask with the route's receipt so the probes can drive
215
+ // attach / restart / teardown / revive / join end to end; nothing changes in the fixture
216
+ if (req.method !== 'GET') {
217
+ const flow = flowReceipt(rest, req.method);
218
+ if (flow) return AUTH ? json(res, 200, flow) : json(res, 401, UNAUTH);
219
+ }
220
+ if (rest.startsWith('/github/repo')) return AUTH ? json(res, 200, { repo: { private: false, admin: true } }) : json(res, 401, UNAUTH);
160
221
  if (rest === '/auth/logout') return json(res, 200, { ok: true });
161
222
  if (rest.startsWith('/auth/web/start')) { res.writeHead(302, { location: PREFIX }); return res.end(); }
162
223
  if (rest === '/instance') { const b = pages.size ? pages.values().next().value.brand : {}; return json(res, 200, { brand: { productName: (b && b.productName) || '', project: {} }, modules: {} }); }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.419",
3
+ "version": "1.19.421",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.419",
9
+ "version": "1.19.421",
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.419",
3
+ "version": "1.19.421",
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",