@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.
- package/.bongos-core.json +69 -44
- package/clients/bongos-client/index.d.ts +4 -4
- package/docs/adr/0214-canonical-profile-url-one-resolver-dark-until-the-page-exists.md +90 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +13 -2
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +137 -128
- package/docs/copy-registry.json +248 -166
- package/docs/design/mocks/hub/picker.html +421 -0
- package/docs/design/projects-hub-direction-v2.md +22 -0
- package/docs/file-map.md +2 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/profile-canonical.js +137 -0
- package/modules/hall-ui/public/profile-connections.js +20 -9
- package/modules/hall-ui/public/profile.html +9 -0
- package/modules/hall-ui/public/profile.js +5 -0
- package/modules/platform-identity/canonical-profile.js +143 -0
- package/modules/platform-identity/routes/projects.js +24 -0
- package/modules/platform-identity/routes/sso.js +7 -0
- package/modules/provisioning/module.json +1 -1
- package/modules/provisioning/provisioning.js +63 -2
- package/modules/provisioning/routes/provisioning.js +28 -6
- package/modules/provisioning/tests/provisioning.mjs +34 -0
- package/modules/ui-design/kit/fixtures/featured-hub.json +1 -1
- package/modules/ui-design/kit/fixtures/featured.json +1 -1
- package/modules/ui-design/kit/fixtures/provisioning-instances.json +1 -1
- package/modules/ui-design/kit/serve.js +28 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +37 -0
- package/public-landing/projects.html +164 -5
- package/public-landing/projects.probes.json +12 -0
- package/public-landing/projects.states.json +3 -2
- package/src/bongos/routes/builders.js +24 -2
- package/src/bongos/serve-internal.js +28 -2
- package/src/module-api.js +1 -1
- package/tests/canonical_profile_url.mjs +628 -0
- package/tests/profile_connections_ui.mjs +38 -0
- package/tests/profile_route.mjs +30 -5
- package/tests/projects_hub.mjs +122 -7
- package/tests/provisioning_settings.mjs +81 -2
- 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
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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
|
|
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: ${
|
|
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 && !
|
|
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' });
|