@bongos/core 1.19.427 → 1.19.429

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 (36) hide show
  1. package/.bongos-core.json +75 -35
  2. package/clients/bongos-client/index.d.ts +1 -1
  3. package/docs/adr/0213-platform-terms-acceptance-is-the-hubs-own-witness.md +71 -0
  4. package/docs/adr/0214-canonical-profile-url-one-resolver-dark-until-the-page-exists.md +90 -0
  5. package/docs/adr/README.md +2 -0
  6. package/docs/api/openapi.json +3 -2
  7. package/docs/copy-inventory.md +45 -36
  8. package/docs/copy-registry.json +127 -46
  9. package/docs/file-map.md +3 -1
  10. package/docs/module-api-changelog.md +4 -0
  11. package/modules/hall-ui/public/profile-canonical.js +137 -0
  12. package/modules/hall-ui/public/profile-connections.js +20 -9
  13. package/modules/hall-ui/public/profile.html +9 -0
  14. package/modules/hall-ui/public/profile.js +5 -0
  15. package/modules/platform-identity/account-visibility.js +31 -12
  16. package/modules/platform-identity/canonical-profile.js +143 -0
  17. package/modules/platform-identity/connections.js +4 -3
  18. package/modules/platform-identity/migrations/platform_identity_019_terms_acceptance.sql +47 -0
  19. package/modules/platform-identity/platform-identity.js +12 -10
  20. package/modules/platform-identity/routes/connections.js +4 -3
  21. package/modules/platform-identity/routes/sso.js +7 -0
  22. package/modules/platform-identity/terms-acceptance.js +97 -0
  23. package/package-lock.json +2 -2
  24. package/package.json +1 -1
  25. package/public-landing/terms.html +8 -0
  26. package/src/bongos/routes/builders.js +24 -2
  27. package/src/bongos/serve-internal.js +28 -2
  28. package/src/module-api.js +1 -1
  29. package/tests/canonical_profile_url.mjs +628 -0
  30. package/tests/connections_api.mjs +13 -8
  31. package/tests/connections_ownership.mjs +6 -1
  32. package/tests/effective_visibility_predicate.mjs +89 -14
  33. package/tests/profile_connections_ui.mjs +60 -7
  34. package/tests/profile_privacy_owner_view.mjs +31 -4
  35. package/tests/profile_route.mjs +45 -13
  36. package/tests/terms_acceptance.mjs +573 -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
@@ -3,9 +3,10 @@
3
3
  // query cannot drift (ADR 0202 D4; the ADR 0182 `publishableSql` precedent).
4
4
  //
5
5
  // WHAT IT ANSWERS: "may the world see that this account exists at all?" —
6
- // profile_state = 'active' (consent: a hub session has touched the account,
7
- // ADR 0171 D3) AND account_visibility = 'public' (existence: the privacy-spec
8
- // D1 axis). Both floors, ANDed, in one place.
6
+ // CONSENT (profile_state = 'active' AND terms_accepted_at IS NOT NULL: a hub
7
+ // session has touched the account AND that human accepted the platform terms,
8
+ // ADR 0171 D3 + ADR 0213) AND account_visibility = 'public' (existence: the
9
+ // privacy-spec D1 axis). Both floors, ANDed, in one place.
9
10
  //
10
11
  // WHY ITS OWN FILE. ADR 0202 D4 requires connections to consume ONE shared
11
12
  // reader rather than grow a connections-flavoured variant — "adding a second
@@ -52,11 +53,11 @@
52
53
  // (ADR 0192 §6 / ADR 0194 §2).
53
54
  'use strict';
54
55
 
55
- // The JS twin. `account` is any row carrying profile_state + account_visibility;
56
- // anything falsy (absent row, unreadable account) answers false.
56
+ // The JS twin. `account` is any row carrying the consent columns +
57
+ // account_visibility; anything falsy (absent row, unreadable account) answers
58
+ // false. COMPOSED from isAccountActive so the consent axis has one definition.
57
59
  function isAccountVisible(account) {
58
- if (!account) return false;
59
- return account.profile_state === 'active' && account.account_visibility === 'public';
60
+ return isAccountActive(account) && account.account_visibility === 'public';
60
61
  }
61
62
 
62
63
  // The SAME predicate as a SQL fragment, for the reads that filter in the
@@ -70,25 +71,43 @@ function accountVisibleSql(alias) {
70
71
  throw new Error('accountVisibleSql: alias must be a bare SQL identifier');
71
72
  }
72
73
  const q = alias ? `${alias}.` : '';
73
- return `(${q}profile_state = 'active' AND ${q}account_visibility = 'public')`;
74
+ return `(${accountActiveSql(alias)} AND ${q}account_visibility = 'public')`;
74
75
  }
75
76
 
76
- // The CONSENT floor on its own — active, without the existence axis. A private
77
+ // The CONSENT floor on its own — consented, without the existence axis. A private
77
78
  // account is still a real participant in its OWN scope: ADR 0202 D4 §2 says its
78
79
  // connections appear in its own list and in the counterparty's own list, and an
79
80
  // incoming request must name who is asking or it cannot be decided. Those
80
81
  // own-scope reads floor on this; every PUBLIC read floors on accountVisibleSql.
82
+ //
83
+ // CONSENT IS TWO COLUMNS, NOT ONE (ADR 0213, task 1002913). profile_state='active'
84
+ // says the hub witnessed a human at /sso/authorize; terms_accepted_at says that
85
+ // human accepted the platform terms — the artifact ADR 0141 §4 deferred
86
+ // cross-project-aggregation consent to, and which ADR 0171 D3 wrote its own
87
+ // definition around ("the one path where a human is demonstrably at the hub, and
88
+ // where T&C acceptance can later be captured"). Both are stamped by the SAME
89
+ // upsertAccount branch, so they cannot disagree for any account activated after
90
+ // ADR 0213 shipped; requiring both here is what makes that structural rather than
91
+ // a promise, and what keeps a legacy row that predates the terms from publishing
92
+ // on a consent premise that was never true. It re-publishes on its next hub
93
+ // sign-in — the lazy upgrade D3's own backfill rule and P03 already chose.
94
+ //
95
+ // This is the ONE place terms_accepted_at is read. tests/effective_visibility_-
96
+ // predicate.mjs fails any other file that tests it, exactly as for the columns
97
+ // above.
81
98
  function accountActiveSql(alias) {
82
99
  if (alias && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(alias))) {
83
100
  throw new Error('accountActiveSql: alias must be a bare SQL identifier');
84
101
  }
85
- return `(${alias ? `${alias}.` : ''}profile_state = 'active')`;
102
+ const q = alias ? `${alias}.` : '';
103
+ return `(${q}profile_state = 'active' AND ${q}terms_accepted_at IS NOT NULL)`;
86
104
  }
87
105
 
88
106
  // The JS twin of accountActiveSql, for an own-scope reader that already holds
89
- // the row (routes/connections.js). Fail-closed, same as isAccountVisible.
107
+ // the row (routes/connections.js). Fail-closed, same as isAccountVisible: a row
108
+ // missing EITHER consent column is not consented.
90
109
  function isAccountActive(account) {
91
- return !!account && account.profile_state === 'active';
110
+ return !!account && account.profile_state === 'active' && account.terms_accepted_at != null;
92
111
  }
93
112
 
94
113
  // -------------------------------------------------------------------------
@@ -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
+ };
@@ -23,8 +23,9 @@
23
23
  // (ADR 0171 D4 / ADR 0202 D4).
24
24
  //
25
25
  // WHOSE GRAPH. Node visibility is viewer-relative by design (ADR 0202 title):
26
- // the viewer's OWN connections floor on profile_state='active' alone — a private
27
- // account that accepted you appears in your own-scope list (D4 §2) — while every
26
+ // the viewer's OWN connections floor on the CONSENT axis alone (accountActiveSql:
27
+ // profile_state + terms acceptance, ADR 0213) — a private account that accepted
28
+ // you appears in your own-scope list (D4 §2) — while every
28
29
  // DEEPER hop floors on the full public predicate in account-visibility.js. So a
29
30
  // bridge the viewer may not see yields `null`, not "3rd", and two viewers
30
31
  // legitimately seeing different labels for the same target is the model working.
@@ -128,7 +129,7 @@ async function findAccountByHandle(handle, { pool = defaultPool } = {}) {
128
129
  const norm = String(handle ?? '').trim().toLowerCase();
129
130
  if (!/^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/.test(norm)) return null;
130
131
  const { rows } = await pool.query(
131
- `SELECT github_id, handle, display_name, avatar_url, profile_state, account_visibility
132
+ `SELECT github_id, handle, display_name, avatar_url, profile_state, terms_accepted_at, account_visibility
132
133
  FROM platform_identity_accounts
133
134
  WHERE lower(handle) = $1 OR lower(previous_handle) = $1
134
135
  ORDER BY (lower(handle) = $1) DESC
@@ -0,0 +1,47 @@
1
+ -- platform_identity_019_terms_acceptance.sql — the platform T&C acceptance
2
+ -- record (ADR 0213, task 1002913; the artifact ADR 0141 §4 assumed and ADR 0171
3
+ -- D3 reserved a place for).
4
+ --
5
+ -- WHAT IT IS. ADR 0141 deferred consent for CROSS-PROJECT AGGREGATION to "the
6
+ -- platform T&C" and conditioned the scouting view on it; ADR 0171 D3 then made
7
+ -- consent a fail-closed profile_state whose activation moment is "the one path
8
+ -- where a human is demonstrably at the hub (and where T&C acceptance can later
9
+ -- be captured, task 1002913)". This is that capture. terms_accepted_at is the
10
+ -- moment the hub itself witnessed the human accept; terms_version records WHICH
11
+ -- text they accepted, so a later revision is a distinguishable event rather than
12
+ -- a silent overwrite.
13
+ --
14
+ -- WRITTEN ONLY BY THE HUB'S OWN SIGN-IN. Both columns are set exclusively by
15
+ -- upsertAccount's `activate` branch, whose sole caller is /sso/authorize, where
16
+ -- `activate: true` is a source literal and every other field comes from the
17
+ -- hub's resolved session (ADR 0205 D3). No request-body field can reach them —
18
+ -- an acceptance record a caller could assert is not evidence of anything.
19
+ --
20
+ -- IDEMPOTENT BY CONSTRUCTION. The conflict branch writes
21
+ -- COALESCE(existing, now()), so the FIRST acceptance is the one that stands and
22
+ -- signing in again never rewrites or duplicates it.
23
+ --
24
+ -- NO BACKFILL — DELIBERATE, and the same asymmetry ADR 0171 D3 chose. Existing
25
+ -- accounts get NULL: no platform T&C covering cross-project aggregation has ever
26
+ -- been shown to anyone, so no acceptance exists to record, and inventing a
27
+ -- timestamp would fabricate the very artifact this task exists to make real.
28
+ -- They re-accept on their next hub-session authorize, exactly as D3's own
29
+ -- backfill rule provides ("all existing accounts start provisional and lazily
30
+ -- upgrade on their next hub-session authorize") and P03 ratified (task 1002799:
31
+ -- no backfill activation). The consequence — currently-active accounts are not
32
+ -- publicly visible until their next hub sign-in — is the owner decision carried
33
+ -- on blocker 1000122; grandfathering is one line here if the owner prefers it.
34
+ --
35
+ -- Additive + platform_identity_-namespaced (ADR 0083 §5). Idempotent.
36
+
37
+ BEGIN;
38
+
39
+ ALTER TABLE platform_identity_accounts
40
+ ADD COLUMN IF NOT EXISTS terms_accepted_at timestamptz,
41
+ ADD COLUMN IF NOT EXISTS terms_version text;
42
+
43
+ -- No index: acceptance is read only as part of the per-account effective-
44
+ -- visibility predicate, and accounts are already looked up by github_id via the
45
+ -- UNIQUE constraint from platform_identity_001_tables.sql.
46
+
47
+ COMMIT;
@@ -18,6 +18,7 @@ const api = require('../../src/module-api');
18
18
  // D4 named). Every public read below joins it from here; nothing in this file
19
19
  // re-derives one (see account-visibility.js).
20
20
  const visibility = require('./account-visibility');
21
+ const terms = require('./terms-acceptance');
21
22
 
22
23
  const { pool: defaultPool } = api;
23
24
  const { ACCOUNT_VISIBILITY, accountVisibleSql, rollupVisibleSql, isRollupVisible, scoutingListableSql } = visibility;
@@ -139,10 +140,11 @@ async function ensureAccount({ githubId, githubLogin, displayName, avatarUrl },
139
140
  // github_id). Called at /sso/authorize for the signed-in hub user. Identity only.
140
141
  //
141
142
  // `activate` is the ONE consent upgrade path (ADR 0171 D3): an account becomes
142
- // profile_state='active' iff touched by a hub-session /sso/authorize — the path
143
- // where a human is demonstrably at the hub. Every other caller (device-poll,
144
- // membership check-in) leaves the fail-closed 'provisional' default; activation
145
- // is one-way (a later non-activating upsert never demotes an active account).
143
+ // profile_state='active' iff touched by a hub-session /sso/authorize — where a
144
+ // human is demonstrably at the hub — and the SAME branch stamps the platform T&C
145
+ // acceptance (ADR 0213, idempotent; ./terms-acceptance.js), so no account is
146
+ // active without one. Other callers (device-poll, check-in) leave the fail-closed
147
+ // 'provisional' + a NULL acceptance; activation is one-way (never demotes).
146
148
  //
147
149
  // display_name is source-guarded (the ADR 0156 mirror, P02/task 1002798): once
148
150
  // a builder sets the account name themselves (setAccountProfileFields stamps
@@ -154,18 +156,18 @@ async function ensureAccount({ githubId, githubLogin, displayName, avatarUrl },
154
156
  // rewrites identity; a federated check-in gets `ensureAccount`.
155
157
  async function upsertAccount({ githubId, githubLogin, displayName, avatarUrl, activate = false }, { pool = defaultPool } = {}) {
156
158
  const { rows } = await pool.query(
157
- `INSERT INTO platform_identity_accounts (github_id, github_login, display_name, avatar_url, profile_state)
158
- VALUES ($1, $2, $3, $4, CASE WHEN $5::boolean THEN 'active' ELSE 'provisional' END)
159
+ `INSERT INTO platform_identity_accounts (github_id, github_login, display_name, avatar_url, profile_state${terms.acceptanceInsertColumns()})
160
+ VALUES ($1, $2, $3, $4, CASE WHEN $5::boolean THEN 'active' ELSE 'provisional' END${terms.acceptanceInsertValues('$5', '$6')})
159
161
  ON CONFLICT (github_id) DO UPDATE SET
160
162
  github_login = EXCLUDED.github_login,
161
163
  display_name = CASE WHEN platform_identity_accounts.display_name_source = 'user'
162
164
  THEN platform_identity_accounts.display_name
163
165
  ELSE EXCLUDED.display_name END,
164
166
  avatar_url = EXCLUDED.avatar_url,
165
- profile_state = CASE WHEN $5::boolean THEN 'active' ELSE platform_identity_accounts.profile_state END,
167
+ profile_state = CASE WHEN $5::boolean THEN 'active' ELSE platform_identity_accounts.profile_state END, ${terms.acceptanceConflictSet('$5', '$6')}
166
168
  updated_at = now()
167
169
  RETURNING id, github_id, github_login, display_name, avatar_url, profile_state, handle`,
168
- [githubId, githubLogin, displayName ?? null, safeAvatarUrl(avatarUrl), !!activate],
170
+ [githubId, githubLogin, displayName ?? null, safeAvatarUrl(avatarUrl), !!activate, terms.TERMS_VERSION],
169
171
  );
170
172
  const row = rows[0];
171
173
  // Seed the handle on first sight (spec Decision 3); a no-op once set.
@@ -189,7 +191,7 @@ async function getAccountByLogin(githubLogin, { pool = defaultPool } = {}) {
189
191
  async function getAccount(githubId, { pool = defaultPool } = {}) {
190
192
  const { rows } = await pool.query(
191
193
  `SELECT id, github_id, github_login, display_name, avatar_url, profile_state, hide_stats,
192
- account_visibility, recruiter_discoverable,
194
+ terms_accepted_at, terms_version, account_visibility, recruiter_discoverable,
193
195
  handle, previous_handle, handle_changed_at, bio, links, display_name_source,
194
196
  created_at, updated_at
195
197
  FROM platform_identity_accounts WHERE github_id = $1`,
@@ -861,7 +863,7 @@ async function getRollupForBuilder(githubId, { pool = defaultPool } = {}) {
861
863
  async function getPublicProfileExtras(githubId, { pool = defaultPool } = {}) {
862
864
  if (!isNumericGithubId(githubId)) return null;
863
865
  const { rows } = await pool.query(
864
- `SELECT profile_state, account_visibility, hide_stats
866
+ `SELECT profile_state, terms_accepted_at, account_visibility, hide_stats
865
867
  FROM platform_identity_accounts WHERE github_id = $1`,
866
868
  [githubId],
867
869
  );
@@ -55,9 +55,10 @@ function connectionId(req) {
55
55
  }
56
56
 
57
57
  // The caller's platform account, or a { fail } describing why they cannot take
58
- // part. profile_state = 'active' is the floor for the WHOLE feature (ADR 0202
59
- // D4 §1): a provisional account cannot send, cannot receive, and never appears
60
- // in a list or a frontier. This is about the CALLER, so it may say so plainly.
58
+ // part. The CONSENT axis (isAccountActive — active AND the platform terms
59
+ // accepted, ADR 0213) is the floor for the WHOLE feature (ADR 0202 D4 §1): a
60
+ // provisional account cannot send, cannot receive, and never appears in a list
61
+ // or a frontier. This is about the CALLER, so it may say so plainly.
61
62
  async function actingAccount(builder) {
62
63
  const githubId = builder && builder.github_id;
63
64
  if (!pi.isNumericGithubId(githubId)) {
@@ -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
  }