@bongos/core 1.19.428 → 1.19.430

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 (42) hide show
  1. package/.bongos-core.json +69 -44
  2. package/clients/bongos-client/index.d.ts +4 -4
  3. package/docs/adr/0214-canonical-profile-url-one-resolver-dark-until-the-page-exists.md +90 -0
  4. package/docs/adr/README.md +1 -0
  5. package/docs/api/openapi.json +13 -2
  6. package/docs/api-reference.md +1 -1
  7. package/docs/copy-inventory.md +137 -128
  8. package/docs/copy-registry.json +248 -166
  9. package/docs/design/mocks/hub/picker.html +421 -0
  10. package/docs/design/projects-hub-direction-v2.md +22 -0
  11. package/docs/file-map.md +2 -1
  12. package/docs/module-api-changelog.md +4 -0
  13. package/modules/hall-ui/public/profile-canonical.js +137 -0
  14. package/modules/hall-ui/public/profile-connections.js +20 -9
  15. package/modules/hall-ui/public/profile.html +9 -0
  16. package/modules/hall-ui/public/profile.js +5 -0
  17. package/modules/platform-identity/canonical-profile.js +143 -0
  18. package/modules/platform-identity/routes/projects.js +24 -0
  19. package/modules/platform-identity/routes/sso.js +7 -0
  20. package/modules/provisioning/module.json +1 -1
  21. package/modules/provisioning/provisioning.js +63 -2
  22. package/modules/provisioning/routes/provisioning.js +28 -6
  23. package/modules/provisioning/tests/provisioning.mjs +34 -0
  24. package/modules/ui-design/kit/fixtures/featured-hub.json +1 -1
  25. package/modules/ui-design/kit/fixtures/featured.json +1 -1
  26. package/modules/ui-design/kit/fixtures/provisioning-instances.json +1 -1
  27. package/modules/ui-design/kit/serve.js +28 -4
  28. package/package-lock.json +2 -2
  29. package/package.json +1 -1
  30. package/public-landing/assets/cosmos.css +37 -0
  31. package/public-landing/projects.html +164 -5
  32. package/public-landing/projects.probes.json +12 -0
  33. package/public-landing/projects.states.json +3 -2
  34. package/src/bongos/routes/builders.js +24 -2
  35. package/src/bongos/serve-internal.js +28 -2
  36. package/src/module-api.js +1 -1
  37. package/tests/canonical_profile_url.mjs +628 -0
  38. package/tests/profile_connections_ui.mjs +38 -0
  39. package/tests/profile_route.mjs +30 -5
  40. package/tests/projects_hub.mjs +122 -7
  41. package/tests/provisioning_settings.mjs +81 -2
  42. package/tests/provisioning_settings_env.mjs +6 -0
@@ -0,0 +1,137 @@
1
+ // modules/hall-ui/public/profile-canonical.js — the canonical-profile wiring on
2
+ // the hall's profile page (P08, task 1002804; ADR 0214, ADR 0171 D1,
3
+ // profile-page spec Decision 1).
4
+ //
5
+ // ONE ANSWER, THREE BEHAVIOURS. A builder has exactly one canonical public
6
+ // profile, at <hub>/u/<handle>. What this page does about that depends on which
7
+ // instance is serving it, and the two cases are opposites:
8
+ //
9
+ // * THE HUB serves the same person's canonical page itself, from the same
10
+ // database. Two URLs for one page on one host is the duplication Decision 1
11
+ // exists to end, so the numeric ?id= form REDIRECTS to the handle URL.
12
+ // * A FEDERATED INSTANCE shows its OWN rank, works and achievements here —
13
+ // genuinely different content — so it must not redirect. It stays the local
14
+ // view, declares the hub page canonical, and LINKS OUT to the cross-project
15
+ // record rather than inlining numbers it would have to keep fresh
16
+ // (ADR 0171 D1: an instance-side copy is stale the moment a builder hides).
17
+ // * A VANILLA INSTANCE has no hub. Nothing here does anything.
18
+ //
19
+ // A page cannot both redirect to a URL and declare it canonical, and it does not
20
+ // have to: those are two different instances.
21
+ //
22
+ // IT IS ALL DARK TODAY, ON PURPOSE. `/u/<handle>` does not exist yet — the page
23
+ // is goal 1000048 R14, and the ADR that fixes its PATH (R01) is unwritten. A
24
+ // rel=canonical naming a 404 consolidates nothing and risks the one page that
25
+ // works, so CANONICAL_PROFILE_PAGE_LIVE is false and every branch below is a
26
+ // no-op. ADR 0214 D1 carries the argument.
27
+ //
28
+ // NOTHING HERE IS AUTHORITY (ADR 0016): a canonical URL is an address, never a
29
+ // grant. And nothing here decides who is visible — the server already did that
30
+ // by omitting `handle` for an account that is absent, provisional or private,
31
+ // identically in all three cases (ADR 0214 D5).
32
+
33
+ (() => {
34
+ 'use strict';
35
+
36
+ // BOUNDARY-FORCED DUPLICATION (the ADR 0091 §2 pattern, as
37
+ // platform-identity.js already does for the display-name cap): a browser
38
+ // script cannot require modules/platform-identity/canonical-profile.js, so the
39
+ // two constants below MIRROR it. tests/canonical_profile_url.mjs reads both
40
+ // files and fails if they ever disagree, which is what keeps this a mirror
41
+ // rather than a second opinion. Flip the module's copy and this one together.
42
+ const CANONICAL_PROFILE_PAGE_LIVE = false;
43
+ const CANONICAL_PROFILE_PREFIX = '/u/';
44
+
45
+ // Same charset the module re-checks: a handle is about to be interpolated into
46
+ // an href and a <link rel=canonical>, so it is validated here too rather than
47
+ // trusted because it arrived over the wire.
48
+ const HANDLE_RE = /^[a-z0-9-]{1,39}$/;
49
+
50
+ // The hub apex, stamped server-side into a <meta> (never a script global, and
51
+ // never straight into an href — an empty value in an href would silently
52
+ // become a same-host relative URL claiming to be the hub). Absent or empty
53
+ // means "no hub", and every branch below then does nothing.
54
+ function hubOrigin() {
55
+ const meta = document.querySelector('meta[name="hub-origin"]');
56
+ const raw = ((meta && meta.getAttribute('content')) || '').trim().replace(/\/+$/, '');
57
+ if (!raw) return null;
58
+ // Bare https origin only — a canonical must never name a downgraded scheme,
59
+ // and a value carrying a path is not an origin.
60
+ try {
61
+ const url = new URL(raw);
62
+ if (url.protocol !== 'https:' || url.username || url.password) return null;
63
+ if (url.pathname !== '/' || url.search || url.hash) return null;
64
+ return url.origin;
65
+ } catch (err) { return null; }
66
+ }
67
+
68
+ // The canonical public profile URL, or null when we must not point anywhere:
69
+ // the page is not live, no hub is known, or the account has no public handle
70
+ // (absent / provisional / private — indistinguishable by design).
71
+ function canonicalProfileUrl(handle) {
72
+ if (!CANONICAL_PROFILE_PAGE_LIVE) return null;
73
+ const origin = hubOrigin();
74
+ if (!origin) return null;
75
+ const h = String(handle == null ? '' : handle).trim().toLowerCase();
76
+ if (!HANDLE_RE.test(h)) return null;
77
+ return `${origin}${CANONICAL_PROFILE_PREFIX}${h}`;
78
+ }
79
+
80
+ // "Am I the hub?" — the enabled-module set the server injects. `platform-
81
+ // identity` IS the hub flag rather than a proxy for it (src/platform-server.js
82
+ // says so), so this asks the instance what it is and nothing has to know the
83
+ // platform's hostname.
84
+ function isHub() {
85
+ const mods = window.__MODULES__;
86
+ return Array.isArray(mods) ? mods.includes('platform-identity') : !!(mods && mods['platform-identity']);
87
+ }
88
+
89
+ function setCanonicalLink(url) {
90
+ let link = document.querySelector('link[rel="canonical"]');
91
+ if (!link) {
92
+ link = document.createElement('link');
93
+ link.setAttribute('rel', 'canonical');
94
+ document.head.appendChild(link);
95
+ }
96
+ link.setAttribute('href', url);
97
+ }
98
+
99
+ // The visible link-out — ADR 0171 D1's federated render path. It reuses the
100
+ // page's existing cross-project section, which on a federated instance is
101
+ // empty and hidden (the response carries no cross_project key there), so the
102
+ // heading a reader already understands gets the link instead of numbers.
103
+ function renderLinkOut(url) {
104
+ const section = document.getElementById('xp-scroll');
105
+ const body = document.getElementById('xp-body');
106
+ if (!section || !body) return;
107
+ // Defence in depth, and honestly UNOBSERVABLE today: canonicalProfileUrl has
108
+ // already refused anything outside `https://<bare origin>/u/[a-z0-9-]{1,39}`,
109
+ // so no value reaching here can contain a character this changes. It is the
110
+ // second wall for the day someone loosens the handle charset — a mutation
111
+ // test cannot make it bite, and that is the point rather than a gap.
112
+ const escapeHtml = (window.OTB && window.OTB.escapeHtml) || ((s) => String(s));
113
+ body.innerHTML = `<p class="scroll__lede">This builder's work spans more than this project. `
114
+ + `<a href="${escapeHtml(url)}" rel="noopener">See the cross-project record</a>.</p>`;
115
+ section.hidden = false;
116
+ }
117
+
118
+ // render(data) — called by profile.js once the profile response is in hand.
119
+ // `data.handle` is present iff the builder has a PUBLICLY VISIBLE platform
120
+ // account; absent for no account, provisional and private alike, so nothing
121
+ // here can tell those apart and neither can a visitor watching it.
122
+ function render(data) {
123
+ const url = canonicalProfileUrl(data && data.handle);
124
+ if (!url) return;
125
+ if (isHub()) {
126
+ // The hub's numeric form is an address for a page the hub also serves at
127
+ // the canonical URL. `replace`, not `assign`: the numeric URL should not
128
+ // sit in history as a place Back returns to.
129
+ if (new URLSearchParams(location.search).get('id')) location.replace(url);
130
+ return;
131
+ }
132
+ setCanonicalLink(url);
133
+ renderLinkOut(url);
134
+ }
135
+
136
+ window.OTBCanonical = { render, canonicalProfileUrl, hubOrigin, isHub };
137
+ })();
@@ -45,14 +45,18 @@
45
45
  // why `hide_stats` does not suppress it — hiding your stats hides the numbers
46
46
  // beside an edge, not the edge itself (ADR 0202 D4 §3).
47
47
  //
48
- // ADDRESSING. The connections API names a counterparty by HANDLE; this page
49
- // knows only a builder's github_login. It hands that login to
50
- // GET /connections/degree/:handle, which resolves the current handle or the
51
- // single previous-handle rename slot and echoes the CANONICAL handle back —
52
- // and that echoed handle, never the login, is what every write here sends. A
53
- // login resolving to neither answers 404 and the section stays hidden, the same
54
- // fail-closed answer a private or provisional account gets. The handle-addressed
55
- // hub page (/u/<handle>) will not need this hop at all.
48
+ // ADDRESSING. The connections API names a counterparty by HANDLE. Since task
49
+ // 1002804 the profile response carries the canonical `handle` itself (whenever
50
+ // the builder has a publicly visible platform account), so this page addresses
51
+ // by that and the login hop is only the FALLBACK for a server that predates it.
52
+ // That fallback is the one with the hole, and it is why the widening mattered:
53
+ // GET /connections/degree/:handle resolves a login against the current handle or
54
+ // the SINGLE previous-handle rename slot, so a twice-renamed builder matched
55
+ // neither, answered 404, and the section silently never appeared — the honest
56
+ // gap ADR 0212 named and left open. Either way the API echoes the CANONICAL
57
+ // handle back, and that echoed handle, never the login, is what every write here
58
+ // sends. An address resolving to nothing answers 404 and the section stays
59
+ // hidden, the same fail-closed answer a private or provisional account gets.
56
60
 
57
61
  (() => {
58
62
  'use strict';
@@ -288,7 +292,14 @@
288
292
  const builder = (data && data.builder) || {};
289
293
  const isSelf = selfId != null && String(builder.id) === String(selfId);
290
294
  try {
291
- const shown = isSelf ? await renderOwn(body) : await renderOther(body, builder.github_login);
295
+ // Address by the CANONICAL handle when the response carries one (task
296
+ // 1002804 widened the profile read to include it), and fall back to the
297
+ // github_login hop for a server that predates that. The fallback is the
298
+ // thing with the hole: the rename window is a SINGLE previous_handle slot,
299
+ // so a twice-renamed builder's login matches neither and the section
300
+ // silently never appears — the honest gap ADR 0212 named and this closes.
301
+ const address = (data && typeof data.handle === 'string' && data.handle) || builder.github_login;
302
+ const shown = isSelf ? await renderOwn(body) : await renderOther(body, address);
292
303
  section.hidden = !shown;
293
304
  if (shown && body.addEventListener && !body.dataset.connWired) {
294
305
  body.dataset.connWired = '1';
@@ -5,6 +5,11 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Profile — {{worldName}}</title>
7
7
  <meta name="description" content="A builder of {{worldName}} — rank, works shipped, standing, and achievements.">
8
+ <!-- The platform hub's apex, stamped server-side (P08, task 1002804; ADR 0214
9
+ D2). Empty on a vanilla instance, and profile-canonical.js renders nothing
10
+ then — a meta, never an href, so an empty value cannot degrade into a
11
+ same-host URL claiming to be the hub. -->
12
+ <meta name="hub-origin" content="{{hubOrigin}}">
8
13
  <link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text x='16' y='24' font-size='24' text-anchor='middle' font-family='sans-serif'>⌂</text></svg>">
9
14
  <link rel="preconnect" href="https://fonts.googleapis.com">
10
15
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -145,6 +150,10 @@
145
150
  exists by the time renderProfile runs; profile.js guards on it anyway, so a
146
151
  page that omits this script simply has no connections section. -->
147
152
  <script src="/builders/profile-connections.js?v=2026-08-29-r20"></script>
153
+ <!-- Canonical-profile wiring (P08). Loaded BEFORE profile.js so
154
+ window.OTBCanonical exists when renderProfile runs; profile.js guards on it,
155
+ so a page that omits this script simply declares no canonical. -->
156
+ <script src="/builders/profile-canonical.js?v=2026-08-29-p08"></script>
148
157
  <script src="/builders/profile.js?v=2026-08-27-activity"></script>
149
158
  </body>
150
159
  </html>
@@ -538,6 +538,11 @@
538
538
  // reveals its own section or leaves it hidden. Optional by design: a page
539
539
  // that does not load it simply has no connections section.
540
540
  if (window.OTBConnections) window.OTBConnections.render(data, selfId);
541
+ // Canonical-profile wiring (P08, task 1002804) — the rel=canonical + the
542
+ // link-out to the cross-project record on a federated instance, and the
543
+ // numeric-id redirect on the hub. Optional the same way: a page without the
544
+ // script declares no canonical rather than breaking.
545
+ if (window.OTBCanonical) window.OTBCanonical.render(data);
541
546
  }
542
547
 
543
548
  // Render the friendly "no builder chosen" empty state for a signed-out visitor
@@ -0,0 +1,143 @@
1
+ // modules/platform-identity/canonical-profile.js — THE one answer to "where
2
+ // does this builder's canonical public profile live, and is it safe to point
3
+ // there?" (P08, task 1002804; ADR 0214; profile-page spec Decision 1, ADR 0171
4
+ // D1/D8).
5
+ //
6
+ // WHY ITS OWN FILE. Three surfaces need the same answer — the `rel=canonical`
7
+ // a federated instance emits, the visible "see the cross-project record" link
8
+ // beside it, and the numeric-id redirect the hub performs. Three surfaces each
9
+ // building their own URL is three ways to disagree about where a profile lives
10
+ // and, worse, three independent judgements about whether the target exists yet.
11
+ // One resolver removes both. (platform-identity.js is also at its file-size
12
+ // ratchet with zero headroom, which is why this lands beside it rather than in
13
+ // it — the account-visibility.js precedent.)
14
+ //
15
+ // THE FLAG IS THE POINT. `/u/<handle>` does not exist: the page is goal 1000048
16
+ // R14 (task 1002365, `backlog`), the SEO-namespace ADR that fixes its PATH is
17
+ // R01 (`ready`, unwritten), and R03 in the same goal proposes moving platform
18
+ // pages under /bongos/…. A rel=canonical naming a URL that 404s consolidates
19
+ // nothing — a 404 cannot be indexed — while risking the one page that does
20
+ // work. So the resolver answers null and every consumer renders nothing.
21
+ // ADR 0214 D1 carries the full argument and the flip checklist.
22
+ //
23
+ // Not authority (ADR 0016): a canonical URL is an address, never a grant.
24
+ 'use strict';
25
+
26
+ const { isAccountVisible } = require('./account-visibility');
27
+
28
+ // The canonical profile namespace on the hub (profile-page spec Decision 1).
29
+ // One spelling, so nothing hardcodes '/u/' a second time.
30
+ const CANONICAL_PROFILE_PREFIX = '/u/';
31
+
32
+ // FALSE until the page at CANONICAL_PROFILE_PREFIX actually resolves. Flipping
33
+ // this turns on the canonical link, the link-out and the numeric-id redirect
34
+ // together, everywhere, at once — which is exactly why it is one boolean and
35
+ // not three.
36
+ //
37
+ // Flip it only when ALL FOUR hold (ADR 0214 D1):
38
+ // 1. goal 1000048 R14 (task 1002365) serves the page at this path;
39
+ // 2. R01 has ratified that path — R03 may still move platform pages;
40
+ // 3. the page answers 404, never 403, for absent / provisional / private,
41
+ // matching GET /profiles/:handle;
42
+ // 4. P09's noindex-until-first-ship is in place, so flipping does not publish
43
+ // a farm of empty profiles.
44
+ // tests/canonical_profile_url.mjs fails if a /u/ page route lands in the tree
45
+ // while this is still false, so the flag cannot silently fall behind the page.
46
+ const CANONICAL_PROFILE_PAGE_LIVE = false;
47
+
48
+ // A handle is the URL key, so it may only ever be the charset P01 normalizes to
49
+ // (lowercase alnum + hyphen). Re-checked HERE rather than trusted, because this
50
+ // value is about to be interpolated into a URL that goes into an href and a
51
+ // <link rel=canonical>: a resolver that builds URLs should refuse anything it
52
+ // cannot vouch for rather than rely on every future caller having normalized.
53
+ const HANDLE_RE = /^[a-z0-9-]{1,39}$/;
54
+
55
+ // An origin is only usable as a canonical target if it is a bare https origin —
56
+ // no path, no query, no credentials. http is refused outright: a canonical
57
+ // pointing at a downgraded scheme is a signal we should never emit.
58
+ function normalizeHubOrigin(origin) {
59
+ const raw = String(origin ?? '').trim().replace(/\/+$/, '');
60
+ if (!raw) return null;
61
+ let url;
62
+ try { url = new URL(raw); } catch { return null; }
63
+ if (url.protocol !== 'https:') return null;
64
+ if (url.username || url.password) return null;
65
+ if (url.pathname !== '/' || url.search || url.hash) return null;
66
+ return url.origin;
67
+ }
68
+
69
+ // canonicalProfilePath(handle) — the path half, or null for a handle this
70
+ // resolver will not vouch for. Split out from the URL so the composition rules
71
+ // stay provable while the liveness flag below is false: a gate that short-
72
+ // circuits everything would otherwise leave the interesting logic untested.
73
+ function canonicalProfilePath(handle) {
74
+ const h = String(handle ?? '').trim().toLowerCase();
75
+ return HANDLE_RE.test(h) ? `${CANONICAL_PROFILE_PREFIX}${h}` : null;
76
+ }
77
+
78
+ // canonicalProfileUrl(hubOrigin, handle) — the canonical public profile URL, or
79
+ // null when we must not point anywhere: the page is not live, no hub origin is
80
+ // known (a vanilla instance), the origin is not a bare https origin, or the
81
+ // handle is absent/malformed. EVERY consumer renders nothing on null; none of
82
+ // them may fall back to a guess.
83
+ function canonicalProfileUrl(hubOrigin, handle) {
84
+ if (!CANONICAL_PROFILE_PAGE_LIVE) return null;
85
+ const origin = normalizeHubOrigin(hubOrigin);
86
+ const path = canonicalProfilePath(handle);
87
+ return origin && path ? `${origin}${path}` : null;
88
+ }
89
+
90
+ // getPublicHandle(githubId) — the account's PUBLIC handle, or null.
91
+ //
92
+ // Floors on the SHARED existence predicate (isAccountVisible), never a variant:
93
+ // active AND public. Deliberately NOT the rollup predicate — `hide_stats`
94
+ // suppresses NUMBERS and never existence (account-visibility.js; resolveHandle
95
+ // says the same), so a builder who hides their stats still has a public profile
96
+ // at a canonical URL, it just carries no numbers. Gating the handle on
97
+ // hide_stats would deny a canonical URL and a connect button to exactly the
98
+ // people who chose a public profile without public numbers.
99
+ //
100
+ // A null is returned identically for "no account", "provisional" and "private"
101
+ // — the three cases ADR 0171 D2 requires to stay indistinguishable — so a
102
+ // caller can never tell them apart, and neither can anything downstream of one.
103
+ async function getPublicHandle(githubId, { pool }) {
104
+ if (githubId === null || githubId === undefined) return null;
105
+ const id = String(githubId);
106
+ // Non-numeric principals (the `system:*` ids) would blow up the bigint cast;
107
+ // they have no account, so answer the same null an absent row gets.
108
+ if (!/^\d+$/.test(id)) return null;
109
+ // THE ROW CARRIES EVERY COLUMN THE THREE SHARED PREDICATES READ, not just the
110
+ // ones this function happens to need — the getPublicProfileExtras shape.
111
+ //
112
+ // Two different reasons, both learned the hard way:
113
+ // * `terms_accepted_at` is REQUIRED. Task 1002913 moved the T&C witness into
114
+ // isAccountActive, which isAccountVisible composes from, so a row without
115
+ // the column reads as un-consented and this function fail-closes to null
116
+ // for EVERYONE. Fail-closed is the safe direction, but it is still broken.
117
+ // * `hide_stats` is NOT read here, and is selected anyway so the CHOICE of
118
+ // predicate is the only thing deciding the answer. Omit it and
119
+ // isAccountVisible and isRollupVisible agree on every row — the predicate
120
+ // looks load-bearing while being decorative, and a later swap to the wrong
121
+ // one fails OPEN in silence.
122
+ //
123
+ // The rule this leaves behind: a reader that applies a shared predicate must
124
+ // SELECT that predicate's whole column set, or it is deciding on absent data.
125
+ const { rows } = await pool.query(
126
+ `SELECT handle, profile_state, account_visibility, terms_accepted_at, hide_stats
127
+ FROM platform_identity_accounts WHERE github_id = $1::bigint`,
128
+ [id],
129
+ );
130
+ const row = rows[0];
131
+ if (!isAccountVisible(row)) return null;
132
+ const handle = String(row.handle ?? '').trim().toLowerCase();
133
+ return HANDLE_RE.test(handle) ? handle : null;
134
+ }
135
+
136
+ module.exports = {
137
+ CANONICAL_PROFILE_PREFIX,
138
+ CANONICAL_PROFILE_PAGE_LIVE,
139
+ canonicalProfilePath,
140
+ canonicalProfileUrl,
141
+ normalizeHubOrigin,
142
+ getPublicHandle,
143
+ };
@@ -17,6 +17,12 @@ const pi = require('../platform-identity');
17
17
  const projectStats = require('../project-stats');
18
18
 
19
19
  const { pool, requireBuilder, requirePermission, validateOrRespond, parseId, corsPublicGet } = api;
20
+ const log = api.logger('platform-identity');
21
+
22
+ // a catalog origin's host, the way the hub's sky joins it to an owned instance
23
+ function hostOfOrigin(origin) {
24
+ try { return new URL(String(origin)).hostname.replace(/^www\./, '').toLowerCase(); } catch { return ''; }
25
+ }
20
26
 
21
27
  module.exports = function projectsRoutes() {
22
28
  const router = express.Router();
@@ -127,6 +133,23 @@ module.exports = function projectsRoutes() {
127
133
  } catch (err) {
128
134
  console.error('[platform-identity] project stats enrichment failed (non-fatal):', err.message);
129
135
  }
136
+ // The owners' planets (task 1003347): a project's owner may have chosen how
137
+ // the platform draws it. The choice lives on the provisioning row, so it
138
+ // reaches this public feed through the `provisioning.projectPlanets` port —
139
+ // optional, because an instance without that module has no choices to
140
+ // carry — host-matched, the join the sky makes. Best-effort like the stats:
141
+ // a failed read leaves every project on the platform's own pick.
142
+ let planets = {};
143
+ try {
144
+ planets = await api.resolveOptional('provisioning.projectPlanets', async () => ({}))(projects.map((p) => hostOfOrigin(p.origin)));
145
+ } catch (err) {
146
+ log.warn('project planets enrichment failed (non-fatal):', err.message);
147
+ }
148
+ // the wire always carries both keys: null = the platform's own pick
149
+ const planetOf = (p) => {
150
+ const c = (planets && planets[hostOfOrigin(p.origin)]) || {};
151
+ return { planet_template: c.planet_template || null, planet_accent: c.planet_accent || null };
152
+ };
130
153
  // The enrichment's keys (builders, tags, owner, owner_avatar, faces,
131
154
  // shipped_30d, shipped_30d_capped, hall_origin) never collide with the
132
155
  // catalog's; a project that did not answer carries the ONE shared null
@@ -137,6 +160,7 @@ module.exports = function projectsRoutes() {
137
160
  ...p,
138
161
  art_url: projectStats.httpUrl(p.art_url, 2048),
139
162
  ...(stats[p.origin] || projectStats.NULL_STATS),
163
+ ...planetOf(p),
140
164
  })),
141
165
  });
142
166
  } catch (err) {
@@ -18,6 +18,7 @@ const express = require('express');
18
18
  const api = require('../../../src/module-api');
19
19
  const pi = require('../platform-identity');
20
20
  const scoutingAuthz = require('../scouting-authz');
21
+ const canonicalProfile = require('../canonical-profile');
21
22
  const hubKeys = require('../hub-keys');
22
23
  const hubDevice = require('../hub-device');
23
24
  // task 1003208: structured logging (pino via the doorway) — was console.*.
@@ -120,6 +121,12 @@ function registerSeams() {
120
121
  // The ownership fact lives in this module, so the answer does too; core
121
122
  // asks through the port and treats an absent one as a denial.
122
123
  isProjectOwner: (githubId) => scoutingAuthz.hasOwnerMembership(githubId, { pool }),
124
+ // task 1002804 (P08, ADR 0214 D3): the account's PUBLIC handle — the
125
+ // canonical profile address — so the numeric profile route can name a
126
+ // builder by the rename-proof key instead of a mutable github_login.
127
+ // Floors on the EXISTENCE predicate, not the rollup one: hide_stats
128
+ // suppresses numbers, never addressability.
129
+ getPublicHandle: (githubId) => canonicalProfile.getPublicHandle(githubId, { pool }),
123
130
  });
124
131
  }
125
132
  }
@@ -14,6 +14,6 @@
14
14
  "migrations": true,
15
15
  "pollers": ["liveness-sweep"]
16
16
  },
17
- "provides": ["provisioning.instanceState"],
17
+ "provides": ["provisioning.instanceState", "provisioning.projectPlanets"],
18
18
  "consumes": []
19
19
  }
@@ -124,6 +124,26 @@ const SETTINGS_VOCAB = Object.freeze({
124
124
  });
125
125
  const DEFAULT_SETTINGS = Object.freeze({ platform_visibility: 'public', joinability: 'apply' });
126
126
 
127
+ // The project's PLANET (task 1003347, direction record §16): how the platform
128
+ // DRAWS it on the sky, in the owner's list and on the card — its template
129
+ // (shape × material × style, the thirty-six names platform-identity's
130
+ // PROJECT_TEMPLATES pins; mirrored here so provisioning never imports the hub's
131
+ // module, the PROJECT_TYPES precedent) and its accent (one of the world's five
132
+ // TOKEN NAMES — a name, never a colour: the Fifteen Rule). Stored in the same
133
+ // jsonb `settings` column, but deliberately NOT in SETTINGS_VOCAB: a setting is a
134
+ // policy the instance obeys (it rides web.env and a restart applies it); the
135
+ // planet is the platform's to draw, so it has no default (null = the platform's
136
+ // own pick: a curated template, else the walk), never rides the env transport,
137
+ // and never restarts anything.
138
+ const PLANET_SHAPES = ['blob', 'pair', 'bead'];
139
+ const PLANET_MATERIALS = ['frost', 'gold', 'obsidian'];
140
+ const PLANET_TEMPLATES = Object.freeze([
141
+ ...PLANET_SHAPES.flatMap((s) => ['tint', 'wash', 'chrome'].map((y) => `${s}-${y}`)),
142
+ ...PLANET_SHAPES.flatMap((s) => PLANET_MATERIALS.flatMap((m) => ['tint', 'wash', 'plain'].map((y) => `${s}-${m}-${y}`))),
143
+ ]);
144
+ const PLANET_ACCENTS = Object.freeze(['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent']);
145
+ const PLANET_VOCAB = Object.freeze({ planet_template: PLANET_TEMPLATES, planet_accent: PLANET_ACCENTS });
146
+
127
147
  // The optional project-detail interview (BV1.R15, task 1002334) — the "tell us
128
148
  // about your project" step every creation form offers and every owner may skip.
129
149
  // The interview asks two things, and the vocabulary is the contract for the
@@ -285,6 +305,10 @@ function publicInstance(row) {
285
305
  // instances so a declaration shows before the catalog carries it. Nullable:
286
306
  // rows predating provisioning_007, or a declined answer, project null.
287
307
  type: row.type || null,
308
+ // The owner's planet (task 1003347): how the platform draws this project —
309
+ // null = the platform's own pick. Public by nature (the sky shows it), so
310
+ // the list and the manage page read it off the row, no second read.
311
+ ...planetChoice(row),
288
312
  // The optional project-detail interview (task 1002334): what the owner said
289
313
  // they are building, and the answers that feed R17's recommendations. Both
290
314
  // are legitimately empty — the step is skippable by design — and `description`
@@ -366,6 +390,18 @@ function effectiveSettings(row) {
366
390
  return out;
367
391
  }
368
392
 
393
+ // The owner's planet choice as READ (task 1003347): each key its stored
394
+ // on-vocabulary value or null — unset, or a name that has since left the
395
+ // vocabulary, which never leaks. PURE; the shape is constant so a wire never
396
+ // drops a key.
397
+ function planetChoice(row) {
398
+ const stored = row && row.settings && typeof row.settings === 'object' && !Array.isArray(row.settings)
399
+ ? row.settings : {};
400
+ const out = {};
401
+ for (const [key, vocab] of Object.entries(PLANET_VOCAB)) out[key] = vocab.includes(stored[key]) ? stored[key] : null;
402
+ return out;
403
+ }
404
+
369
405
  // A description as it may be STORED: trimmed, collapsed of runaway whitespace,
370
406
  // capped, and empty-means-absent. Returns null for anything that isn't real prose,
371
407
  // because null is what leaves the project dark-matter — an empty string stored in a
@@ -1033,6 +1069,30 @@ async function instanceStateForSlug(db, slug) {
1033
1069
  };
1034
1070
  }
1035
1071
 
1072
+ // The `provisioning.projectPlanets` port (task 1003347): the owners' planet
1073
+ // choices for a set of HOSTS (the catalog's origins, host-matched — the join the
1074
+ // hub's sky makes), so platform-identity's public featured feed can carry them
1075
+ // without importing this module. The newest row per host wins; a host with no
1076
+ // row, or no choice, is absent. Read-only.
1077
+ async function planetChoicesForHosts(db, hosts) {
1078
+ const wanted = [...new Set((hosts || []).map((h) => String(h || '').toLowerCase()).filter(Boolean))];
1079
+ if (!wanted.length) return {};
1080
+ const { rows } = await db.query(
1081
+ `SELECT lower(domain) AS host, settings
1082
+ FROM provisioning_instances
1083
+ WHERE domain IS NOT NULL AND lower(domain) = ANY($1)
1084
+ ORDER BY updated_at DESC`,
1085
+ [wanted],
1086
+ );
1087
+ const out = {};
1088
+ for (const r of rows) {
1089
+ if (out[r.host]) continue;
1090
+ const choice = planetChoice(r);
1091
+ if (choice.planet_template || choice.planet_accent) out[r.host] = choice;
1092
+ }
1093
+ return out;
1094
+ }
1095
+
1036
1096
  // Fleet cost ledger (Archon): per-instance monthly estimate + a total, over the
1037
1097
  // instances that still cost something (not torn_down). VISIBILITY only (ADR 0111 §6).
1038
1098
  async function costLedger(db, { limit = 500 } = {}) {
@@ -1066,9 +1126,10 @@ module.exports = {
1066
1126
  RESTART_FROM, RESTART_SHAPES,
1067
1127
  TIER_MONTHLY_USD, CERT_ELIGIBLE_STATES,
1068
1128
  SETTINGS_VOCAB, DEFAULT_SETTINGS, PROJECT_DETAIL_VOCAB, DESCRIPTION_MAX,
1129
+ PLANET_TEMPLATES, PLANET_ACCENTS, PLANET_VOCAB,
1069
1130
  // pure
1070
1131
  isValidSlug, costEstimateUsd, publicInstance, normalizeHostname,
1071
- defaultDomainForSlug, isReservedHostname, effectiveSettings,
1132
+ defaultDomainForSlug, isReservedHostname, effectiveSettings, planetChoice,
1072
1133
  normalizeDescription, normalizeProjectDetail, projectDetail,
1073
1134
  // pure — GitHub App Manifest flow (task 2080)
1074
1135
  githubAppName, buildGithubAppManifest, manifestPostUrl, publicGithubApp,
@@ -1080,7 +1141,7 @@ module.exports = {
1080
1141
  createInstance, setInstanceStatus, updateInstanceDomain, updateInstanceSettings, updateInstanceDetail,
1081
1142
  createInstance, setInstanceStatus, updateInstanceDomain, clearReleasedDomains, updateInstanceSettings,
1082
1143
  enqueueIntent, enqueueTeardown, enqueueSettingsApply, enqueueRestart, getOpenIntent, claimNextIntent, resolveIntent,
1083
- recordEvent, instanceStateForSlug, costLedger,
1144
+ recordEvent, instanceStateForSlug, planetChoicesForHosts, costLedger,
1084
1145
  // data access — GitHub App Manifest attempts (task 2080)
1085
1146
  createOAuthManifest, getOAuthManifestByState, attachManifestCode,
1086
1147
  getLatestOAuthManifestForInstance, claimNextOAuthManifest, resolveOAuthManifest,
@@ -253,9 +253,13 @@ function callbackPage({ ok, title, body, state, domain }) {
253
253
  // • PROVIDES port `provisioning.instanceState` (slug) → { status, … } | null.
254
254
  // Lets other code read an instance's provisioning status via
255
255
  // resolveOptional without importing this module (box.hasEverConnected pattern).
256
+ // • PROVIDES port `provisioning.projectPlanets` (hosts[]) → { host: { planet_template,
257
+ // planet_accent } } — the owners' planet choices (task 1003347), so the hub's
258
+ // public featured feed can carry them without importing this module.
256
259
  function registerProvisioningSeams() {
257
260
  if (api.hasProvider('provisioning.instanceState')) return;
258
261
  api.registerProvider('provisioning.instanceState', (slug) => provisioning.instanceStateForSlug(pool, slug));
262
+ api.registerProvider('provisioning.projectPlanets', (hosts) => provisioning.planetChoicesForHosts(pool, hosts));
259
263
  }
260
264
 
261
265
  // Project an instance row for the fleet view — the owner identity + the same
@@ -764,7 +768,10 @@ module.exports = function provisioningRoutes() {
764
768
  try {
765
769
  const inst = await provisioning.getInstanceById(pool, id);
766
770
  if (instanceAccessDenied(inst, req.builder)) return res.fail('instance_not_found', 404);
767
- res.json({ ok: true, settings: provisioning.effectiveSettings(inst), options: provisioning.SETTINGS_VOCAB });
771
+ // `planet` rides beside `settings` (task 1003347): the same row, read by a
772
+ // different rule — no defaults, null = the platform's own pick.
773
+ res.json({ ok: true, settings: provisioning.effectiveSettings(inst), planet: provisioning.planetChoice(inst),
774
+ options: { ...provisioning.SETTINGS_VOCAB, ...provisioning.PLANET_VOCAB } });
768
775
  } catch (err) {
769
776
  log.error('[provisioning] GET /provisioning/instances/:id/settings', err);
770
777
  res.fail('settings_read_failed', { status: 500, message: 'internal error' });
@@ -792,14 +799,18 @@ module.exports = function provisioningRoutes() {
792
799
  if (validateOrRespond(req, res, {
793
800
  platform_visibility: { type: 'string', enum: provisioning.SETTINGS_VOCAB.platform_visibility },
794
801
  joinability: { type: 'string', enum: provisioning.SETTINGS_VOCAB.joinability },
802
+ // the planet (task 1003347): nullable on purpose — null hands the pick back to the platform
803
+ planet_template: { type: 'string', enum: provisioning.PLANET_VOCAB.planet_template },
804
+ planet_accent: { type: 'string', enum: provisioning.PLANET_VOCAB.planet_accent },
795
805
  })) return;
806
+ const settingKeys = [...Object.keys(provisioning.SETTINGS_VOCAB), ...Object.keys(provisioning.PLANET_VOCAB)];
796
807
  const patch = {};
797
- for (const key of Object.keys(provisioning.SETTINGS_VOCAB)) {
808
+ for (const key of settingKeys) {
798
809
  if ((req.body || {})[key] !== undefined) patch[key] = req.body[key];
799
810
  }
800
811
  if (!Object.keys(patch).length) {
801
812
  return res.fail('bad_settings', { status: 400,
802
- message: `The body carries no settings to change — send any of: ${Object.keys(provisioning.SETTINGS_VOCAB).join(', ')}.` });
813
+ message: `The body carries no settings to change — send any of: ${settingKeys.join(', ')}.` });
803
814
  }
804
815
  try {
805
816
  const inst = await provisioning.getInstanceById(pool, id);
@@ -811,8 +822,17 @@ module.exports = function provisioningRoutes() {
811
822
  // whole-form save or a retried request must not bounce a live project. The
812
823
  // one exception is the owner re-kick (the domain route's F19 shape): the same
813
824
  // values re-sent while a retired push left its error_note re-queue the push.
814
- const before = provisioning.effectiveSettings(inst);
825
+ const before = { ...provisioning.effectiveSettings(inst), ...provisioning.planetChoice(inst) };
815
826
  const changed = Object.entries(patch).some(([k, v]) => before[k] !== v);
827
+ // The planet is the platform's to draw (task 1003347): a change to it
828
+ // alone is applied the moment it is read back — the sky, the list and the
829
+ // card repaint from the row — so it queues no push and restarts nothing.
830
+ // Only a POLICY key (SETTINGS_VOCAB) reaches the instance, and only a
831
+ // policy key re-sent over a retired push's error_note re-kicks that push.
832
+ const policyKeys = Object.keys(provisioning.SETTINGS_VOCAB);
833
+ const policyTouched = policyKeys.some((k) => patch[k] !== undefined);
834
+ const policyChanged = policyKeys.some((k) => patch[k] !== undefined && before[k] !== patch[k]);
835
+ const policyResent = !!inst.error_note && policyTouched;
816
836
  const updated = changed ? await provisioning.updateInstanceSettings(pool, id, patch) : inst;
817
837
  if (changed) {
818
838
  await provisioning.recordEvent(pool, {
@@ -834,8 +854,10 @@ module.exports = function provisioningRoutes() {
834
854
  const reachable = provisioning.SETTINGS_APPLY_SHAPES.has(inst.hosting_shape);
835
855
  const receipt = (fields) => ({ reachable, ...fields });
836
856
  let push;
837
- if (!changed && !inst.error_note) {
857
+ if (!changed && !policyResent) {
838
858
  push = receipt({ queued: false, restart: false, message: 'No change — this project already has these settings.' });
859
+ } else if (!policyChanged && !policyResent) {
860
+ push = receipt({ queued: false, restart: false, message: 'Saved. The sky, your list and the card draw it this way from here; nothing on your project restarts for it.' });
839
861
  } else if (!provisioning.SETTINGS_APPLY_FROM.has(inst.status)) {
840
862
  const offline = inst.status === 'torn_down' || inst.status === 'tearing_down';
841
863
  push = receipt({ queued: false, restart: false, message: offline
@@ -867,7 +889,7 @@ module.exports = function provisioningRoutes() {
867
889
  : 'Saved. A restart is already queued for this project and will carry this change.' });
868
890
  }
869
891
  }
870
- res.json({ ok: true, saved: provisioning.effectiveSettings(updated), push });
892
+ res.json({ ok: true, saved: provisioning.effectiveSettings(updated), planet: provisioning.planetChoice(updated), push });
871
893
  } catch (err) {
872
894
  log.error('[provisioning] PATCH /provisioning/instances/:id/settings', err);
873
895
  res.fail('settings_update_failed', { status: 500, message: 'internal error' });