@bongos/core 1.19.420 → 1.19.422

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 (34) hide show
  1. package/.bongos-core.json +68 -33
  2. package/docs/copy-inventory.md +282 -281
  3. package/docs/copy-registry.json +375 -366
  4. package/docs/design/mocks/hub/mine.html +377 -0
  5. package/docs/design/projects-hub-direction-v2.md +25 -1
  6. package/docs/file-map.md +1 -0
  7. package/docs/module-api-changelog.md +4 -0
  8. package/modules/hall-ui/public/settings.html +3 -2
  9. package/modules/hall-ui/public/settings.js +10 -6
  10. package/modules/platform-identity/account-visibility.js +94 -3
  11. package/modules/platform-identity/platform-identity.js +45 -50
  12. package/modules/platform-identity/routes/connections.js +3 -3
  13. package/modules/ui-design/kit/fixtures/featured-hub.json +1 -0
  14. package/modules/ui-design/kit/fixtures/provisioning-instances-empty.json +4 -0
  15. package/modules/ui-design/kit/fixtures/provisioning-instances.json +99 -0
  16. package/modules/ui-design/kit/lib.js +6 -2
  17. package/modules/ui-design/kit/probe.js +1 -1
  18. package/modules/ui-design/kit/render.js +1 -1
  19. package/modules/ui-design/kit/serve.js +63 -2
  20. package/package-lock.json +2 -2
  21. package/package.json +1 -1
  22. package/public-landing/assets/cosmos.css +246 -26
  23. package/public-landing/projects-empty.states.json +49 -0
  24. package/public-landing/projects-floor.states.json +63 -0
  25. package/public-landing/projects.html +113 -52
  26. package/public-landing/projects.probes.json +32 -7
  27. package/public-landing/projects.states.json +10 -6
  28. package/src/module-api.js +1 -1
  29. package/tests/account_privacy_flags.mjs +17 -10
  30. package/tests/effective_visibility_predicate.mjs +891 -0
  31. package/tests/profile_route.mjs +22 -11
  32. package/tests/projects_hub.mjs +54 -5
  33. package/tests/projects_hub_pre_uat.mjs +19 -7
  34. package/tests/ui_design_kit.mjs +32 -2
@@ -14,10 +14,37 @@
14
14
  // already at the file-size ratchet. So the definition lands here, alone and
15
15
  // tiny, and 1002967 ADOPTS it rather than writing a second one.
16
16
  //
17
- // WHAT IT IS NOT. Not a rollup gate: hide_stats suppresses NUMBERS, not
18
- // existence, and is applied by the rollup readers (ADR 0171 D2), never here.
17
+ // THREE AXES, THREE ANSWERS, ONE HOME (task 1002967 adopting this file). The
18
+ // axes stay independent — conflating them is the trap the privacy spec D1/D2
19
+ // exists to prevent — but every EFFECTIVE answer derived from them lives here,
20
+ // because an answer that lives anywhere else is a second variant waiting to
21
+ // drift:
22
+ //
23
+ // accountVisibleSql / isAccountVisible EXISTENCE — may the world see that
24
+ // this account exists? Every PUBLIC read floors on this.
25
+ // accountActiveSql / isAccountActive CONSENT — active, without the
26
+ // existence axis. The OWN-SCOPE floor (ADR 0202 D4 §2): a private account
27
+ // is still a real participant in its own scope.
28
+ // rollupVisibleSql / isRollupVisible NUMBERS — visible AND NOT
29
+ // hide_stats. ADR 0171 D2's predicate EXTENDED with the existence axis,
30
+ // and COMPOSED from accountVisibleSql rather than restated.
31
+ // isRecruiterDiscoverable REACH — the COALESCE, privacy
32
+ // spec D1. A reach predicate only: it never buys addressability.
33
+ //
34
+ // The rollup pair was originally left to "the rollup readers" to apply. Task
35
+ // 1002967 moved it here for the reason this file exists: there are two such
36
+ // readers (listScoutingRollup and getPublicProfileExtras), so leaving them to
37
+ // spell `AND NOT hide_stats` themselves is exactly the second-variant problem,
38
+ // one level down. hide_stats still suppresses NUMBERS and never existence —
39
+ // that is why it appears in rollupVisibleSql and in nothing above it.
40
+ //
19
41
  // Not authority: an account being visible confers nothing (ADR 0016).
20
42
  //
43
+ // tests/effective_visibility_predicate.mjs enforces that this file is the ONLY
44
+ // first-party server file allowed to compare these columns to a literal, and
45
+ // that every SELECT on platform_identity_accounts either joins one of these
46
+ // predicates or is declared non-public with a reason.
47
+ //
21
48
  // Fail-closed: a missing/absent account row is invisible, never visible
22
49
  // (ADR 0192 §6 / ADR 0194 §2).
23
50
  'use strict';
@@ -55,4 +82,68 @@ function accountActiveSql(alias) {
55
82
  return `(${alias ? `${alias}.` : ''}profile_state = 'active')`;
56
83
  }
57
84
 
58
- module.exports = { isAccountVisible, accountVisibleSql, accountActiveSql };
85
+ // The JS twin of accountActiveSql, for an own-scope reader that already holds
86
+ // the row (routes/connections.js). Fail-closed, same as isAccountVisible.
87
+ function isAccountActive(account) {
88
+ return !!account && account.profile_state === 'active';
89
+ }
90
+
91
+ // -------------------------------------------------------------------------
92
+ // The rollup axis: visible AND not hidden (task 1002967 — ADR 0171 D2 extended)
93
+ // -------------------------------------------------------------------------
94
+
95
+ // The predicate the two ROLLUP readers join — listScoutingRollup (/people,
96
+ // /scouting) and getPublicProfileExtras (the platform-identity.hub port behind
97
+ // core GET /builders/:id/profile). COMPOSED from accountVisibleSql, never
98
+ // restated, so changing what "visible" means moves this with it.
99
+ function rollupVisibleSql(alias) {
100
+ if (alias && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(alias))) {
101
+ throw new Error('rollupVisibleSql: alias must be a bare SQL identifier');
102
+ }
103
+ return `(${accountVisibleSql(alias)} AND NOT ${alias ? `${alias}.` : ''}hide_stats)`;
104
+ }
105
+
106
+ // The JS twin of rollupVisibleSql, for a reader that already holds the row.
107
+ function isRollupVisible(account) {
108
+ return isAccountVisible(account) && !account.hide_stats;
109
+ }
110
+
111
+ // -------------------------------------------------------------------------
112
+ // The reach axis: recruiting discoverability (privacy spec D1)
113
+ // -------------------------------------------------------------------------
114
+
115
+ // The two stored values of account_visibility. Mirrors the CHECK in
116
+ // migrations/platform_identity_012_account_privacy.sql.
117
+ const ACCOUNT_VISIBILITY = ['public', 'private'];
118
+
119
+ // isRecruiterDiscoverable(account) — THE effective-recruiter-reach predicate.
120
+ // The JS twin of COALESCE(recruiter_discoverable, account_visibility='public');
121
+ // nothing else may spell that COALESCE out. Moved here from platform-identity.js
122
+ // by task 1002967 so all three effective answers share one home.
123
+ //
124
+ // The tri-state is the point: NULL = follow visibility (opt-OUT for a public
125
+ // account, opt-IN for a private one), an explicit boolean overrides — an
126
+ // explicit true on a PRIVATE account is exactly the privacy spec's D2
127
+ // "private, discoverable" row, which the D3 recruiter sliver exists to serve.
128
+ //
129
+ // NOT a complete gate, and NEVER addressability: profile_state='active' still
130
+ // floors every recruiter surface, hide_stats still empties the sliver to
131
+ // name-only, and a discoverable private account is still absent from every
132
+ // public read. Fail-closed on a missing account.
133
+ function isRecruiterDiscoverable(account) {
134
+ if (!account) return false;
135
+ const explicit = account.recruiter_discoverable;
136
+ if (typeof explicit === 'boolean') return explicit;
137
+ return account.account_visibility === 'public';
138
+ }
139
+
140
+ module.exports = {
141
+ isAccountVisible,
142
+ accountVisibleSql,
143
+ accountActiveSql,
144
+ isAccountActive,
145
+ rollupVisibleSql,
146
+ isRollupVisible,
147
+ ACCOUNT_VISIBILITY,
148
+ isRecruiterDiscoverable,
149
+ };
@@ -14,8 +14,13 @@
14
14
 
15
15
  const crypto = require('node:crypto');
16
16
  const api = require('../../src/module-api');
17
+ // THE effective-visibility predicate (task 1002967, adopting the file ADR 0202
18
+ // D4 named). Every public read below joins it from here; nothing in this file
19
+ // re-derives one (see account-visibility.js).
20
+ const visibility = require('./account-visibility');
17
21
 
18
22
  const { pool: defaultPool } = api;
23
+ const { ACCOUNT_VISIBILITY, accountVisibleSql, rollupVisibleSql, isRollupVisible } = visibility;
19
24
 
20
25
  // Default authorization-code lifetime — deliberately short (~60s). A code is a
21
26
  // bearer secret that travels through the browser; a tight TTL bounds the replay
@@ -717,11 +722,15 @@ async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped =
717
722
  // disciplines union is computed separately in `disc`. Proven against real SQL by
718
723
  // tests/scouting_rollup_fanout.mjs.
719
724
  //
720
- // Consent by construction (ADR 0171 D2/D3): only builders whose account is
721
- // profile_state='active' AND NOT hide_stats appear — /people and /scouting are
722
- // live cross-project surfaces, so the predicate lives IN the query, not in any
723
- // caller's memory. Provisional (no hub-session consent) and hidden builders drop
724
- // out entirely; the projection stays an explicit whitelist (no raw-row spread).
725
+ // Consent by construction (ADR 0171 D2/D3, extended by task 1002967): only
726
+ // builders whose account passes rollupVisibleSql — active, publicly visible, not
727
+ // hidden — appear. /people and /scouting are live cross-project surfaces, so the
728
+ // predicate lives IN the query, not in any caller's memory, and it is the SHARED
729
+ // one so this list can never disagree with /profiles/:handle about who is
730
+ // visible. Provisional, private and hidden builders drop out entirely; the
731
+ // projection stays an explicit whitelist (no raw-row spread). A private account
732
+ // that is recruiter-discoverable is absent here on purpose: the full rollup is
733
+ // never its disclosure — the D3 sliver is (a separate authorization-scoped port).
725
734
  async function listScoutingRollup({ limit = 100, pool = defaultPool } = {}) {
726
735
  const { rows } = await pool.query(
727
736
  `WITH agg AS (
@@ -735,8 +744,7 @@ async function listScoutingRollup({ limit = 100, pool = defaultPool } = {}) {
735
744
  WHERE EXISTS (
736
745
  SELECT 1 FROM platform_identity_accounts pa
737
746
  WHERE pa.github_id = a.github_id
738
- AND pa.profile_state = 'active'
739
- AND NOT pa.hide_stats)
747
+ AND ${rollupVisibleSql('pa')})
740
748
  GROUP BY a.github_id
741
749
  ), disc AS (
742
750
  SELECT x.github_id, array_agg(DISTINCT x.d) AS disciplines
@@ -840,20 +848,21 @@ async function getRollupForBuilder(githubId, { pool = defaultPool } = {}) {
840
848
  };
841
849
  }
842
850
 
843
- // The PUBLIC read behind the 'platform-identity.hub' port (ADR 0171 D1/D2):
844
- // null unless the account exists, consented (profile_state='active') and is not
845
- // hidden (hide_stats) — the fail-closed rule every public surface inherits by
846
- // construction. A null is deliberately indistinguishable between "no account",
847
- // "provisional" and "hidden" (no partial numbers; a count plus a hidden total is
848
- // a leak by subtraction).
851
+ // The PUBLIC read behind the 'platform-identity.hub' port (ADR 0171 D1/D2,
852
+ // extended by task 1002967): null unless the account passes the SHARED rollup
853
+ // predicate — exists, consented (profile_state='active'), publicly visible
854
+ // (account_visibility='public') and not hidden (hide_stats). The fail-closed
855
+ // rule every public surface inherits by construction. A null is deliberately
856
+ // indistinguishable between "no account", "provisional", "private" and "hidden"
857
+ // (no partial numbers; a count plus a hidden total is a leak by subtraction).
849
858
  async function getPublicProfileExtras(githubId, { pool = defaultPool } = {}) {
850
859
  if (!isNumericGithubId(githubId)) return null;
851
860
  const { rows } = await pool.query(
852
- `SELECT profile_state, hide_stats FROM platform_identity_accounts WHERE github_id = $1`,
861
+ `SELECT profile_state, account_visibility, hide_stats
862
+ FROM platform_identity_accounts WHERE github_id = $1`,
853
863
  [githubId],
854
864
  );
855
- const acct = rows[0];
856
- if (!acct || acct.profile_state !== 'active' || acct.hide_stats) return null;
865
+ if (!isRollupVisible(rows[0])) return null;
857
866
  return getRollupForBuilder(githubId, { pool });
858
867
  }
859
868
 
@@ -920,32 +929,10 @@ async function setAccountHideStats({ githubId, githubLogin, displayName, avatarU
920
929
  // Account privacy: visibility + recruiter reach (task 1002966 — privacy spec D1)
921
930
  // -------------------------------------------------------------------------
922
931
 
923
- // The two stored values of account_visibility (spec D1's EXISTENCE axis).
924
- // 'private' means the public profile read 404s to everyone — including the
925
- // owner, who reaches their own page through the own-scope side door instead.
926
- const ACCOUNT_VISIBILITY = ['public', 'private'];
927
-
928
- // isRecruiterDiscoverable(account) — THE effective-recruiter-reach predicate
929
- // (privacy spec D1). One definition, so every reader answers "may a recruiting
930
- // surface see this account at all?" the same way; task 1002967 adopts this
931
- // rather than re-deriving it, and nothing else should spell the COALESCE out.
932
- //
933
- // The JS twin of COALESCE(recruiter_discoverable, account_visibility='public').
934
- // The tri-state is the point: NULL = follow visibility (opt-OUT for a public
935
- // account, opt-IN for a private one), an explicit boolean overrides — an
936
- // explicit true on a PRIVATE account is exactly the spec's D2 "private,
937
- // discoverable" row, which is what the recruiter sliver exists to serve.
938
- //
939
- // NOT a complete gate on its own. profile_state='active' still floors every
940
- // recruiter surface (a provisional account is never visible), and hide_stats
941
- // still empties the sliver down to name-only — both stay the caller's to apply.
942
- // Fail-closed on a missing/absent account.
943
- function isRecruiterDiscoverable(account) {
944
- if (!account) return false;
945
- const explicit = account.recruiter_discoverable;
946
- if (typeof explicit === 'boolean') return explicit;
947
- return account.account_visibility === 'public';
948
- }
932
+ // ACCOUNT_VISIBILITY and isRecruiterDiscoverable moved to ./account-visibility.js
933
+ // (task 1002967) — every effective-visibility answer now has ONE home, beside
934
+ // the predicate the public reads join. They are re-exported below, so `pi.`
935
+ // callers are unchanged.
949
936
 
950
937
  // The own-scoped privacy WRITE (task 1002966) — callers key this by the
951
938
  // AUTHENTICATED session's github_id, never a caller-named one. Validates here,
@@ -1123,10 +1110,18 @@ async function setAccountHandle({ githubId, handle }, { pool = defaultPool } = {
1123
1110
  // resolution). Case-insensitive. An exact current-handle match always wins;
1124
1111
  // otherwise the single-slot previous_handle matches and answers a move to the
1125
1112
  // current handle (the 301 window from P01). Only publicly addressable accounts
1126
- // resolve (profile_state = 'active', ADR 0171 D3) — provisional and unknown are
1127
- // the same null (the 404, indistinguishable by design). hide_stats deliberately
1128
- // does NOT gate resolution: hiding removes the rollup, not the page (D2 —
1129
- // addressability and hide are different invariants).
1113
+ // resolve — the SHARED accountVisibleSql predicate (task 1002967), so this is
1114
+ // the same "visible" every other public read uses. Provisional, PRIVATE and
1115
+ // unknown are all the same null: the 404 the privacy spec D2 requires, and it is
1116
+ // a 404 to EVERYONE INCLUDING THE OWNER, because this function takes no viewer
1117
+ // and therefore cannot vary by one (public responses stay viewer-independent,
1118
+ // ADR 0171 D4). An owner reaches their own private page through the own-scope
1119
+ // side door instead (GET /me/cross-project → getOwnProfileExtras) — the page
1120
+ // composition varies, the public response never does. A 403 is deliberately
1121
+ // never returned: it would confirm the handle exists.
1122
+ // hide_stats deliberately does NOT gate resolution: hiding removes the rollup,
1123
+ // not the page (D2 — addressability and hide are different invariants), which is
1124
+ // exactly why accountVisibleSql and rollupVisibleSql are two predicates.
1130
1125
  // Returns null | { moved_to } | { account: { github_id, github_login,
1131
1126
  // display_name, avatar_url, handle, created_at } }.
1132
1127
  async function resolveHandle(handle, { pool = defaultPool } = {}) {
@@ -1136,7 +1131,7 @@ async function resolveHandle(handle, { pool = defaultPool } = {}) {
1136
1131
  `SELECT github_id, github_login, display_name, avatar_url, handle, bio, links, created_at,
1137
1132
  (lower(handle) = $1) AS is_current
1138
1133
  FROM platform_identity_accounts
1139
- WHERE profile_state = 'active'
1134
+ WHERE ${accountVisibleSql()}
1140
1135
  AND (lower(handle) = $1 OR lower(previous_handle) = $1)
1141
1136
  ORDER BY (lower(handle) = $1) DESC
1142
1137
  LIMIT 1`,
@@ -1467,9 +1462,9 @@ module.exports = {
1467
1462
  getPublicProfileExtras,
1468
1463
  getOwnProfileExtras,
1469
1464
  setAccountHideStats,
1470
- // account privacy: visibility + recruiter reach (privacy spec D1)
1471
- ACCOUNT_VISIBILITY,
1472
- isRecruiterDiscoverable,
1465
+ // account privacy: the effective-visibility predicates (./account-visibility.js
1466
+ // — one home for every effective answer; task 1002967 / ADR 0202 D4, 0207)
1467
+ ...visibility,
1473
1468
  setAccountPrivacy,
1474
1469
  // builder handle (P01)
1475
1470
  RESERVED_HANDLES,
@@ -40,7 +40,7 @@ const express = require('express');
40
40
  const api = require('../../../src/module-api');
41
41
  const pi = require('../platform-identity');
42
42
  const conn = require('../connections');
43
- const { isAccountVisible } = require('../account-visibility');
43
+ const { isAccountVisible, isAccountActive } = require('../account-visibility');
44
44
 
45
45
  const { pool, requireBuilder, validateOrRespond, parsePagination } = api;
46
46
  const log = api.logger('platform-identity');
@@ -64,7 +64,7 @@ async function actingAccount(builder) {
64
64
  return { fail: ['no_platform_identity', { status: 400, message: 'this session has no platform account' }] };
65
65
  }
66
66
  const acct = await pi.getAccount(githubId, { pool });
67
- if (!acct || acct.profile_state !== 'active') {
67
+ if (!isAccountActive(acct)) {
68
68
  return { fail: ['profile_provisional', { status: 403, message: 'sign in to the platform once to activate your profile before connecting' }] };
69
69
  }
70
70
  return { githubId };
@@ -78,7 +78,7 @@ async function visibleTarget(viewerGithubId, handle) {
78
78
  const acct = await conn.findAccountByHandle(handle, { pool });
79
79
  if (!acct) return null;
80
80
  if (isAccountVisible(acct)) return acct;
81
- if (acct.profile_state !== 'active') return null;
81
+ if (!isAccountActive(acct)) return null;
82
82
  return (await conn.isConnectedTo(viewerGithubId, acct.github_id, { pool })) ? acct : null;
83
83
  }
84
84
 
@@ -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.420",
3
+ "version": "1.19.422",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.420",
9
+ "version": "1.19.422",
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.420",
3
+ "version": "1.19.422",
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",