@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.
- package/.bongos-core.json +75 -35
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0213-platform-terms-acceptance-is-the-hubs-own-witness.md +71 -0
- package/docs/adr/0214-canonical-profile-url-one-resolver-dark-until-the-page-exists.md +90 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +3 -2
- package/docs/copy-inventory.md +45 -36
- package/docs/copy-registry.json +127 -46
- package/docs/file-map.md +3 -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/account-visibility.js +31 -12
- package/modules/platform-identity/canonical-profile.js +143 -0
- package/modules/platform-identity/connections.js +4 -3
- package/modules/platform-identity/migrations/platform_identity_019_terms_acceptance.sql +47 -0
- package/modules/platform-identity/platform-identity.js +12 -10
- package/modules/platform-identity/routes/connections.js +4 -3
- package/modules/platform-identity/routes/sso.js +7 -0
- package/modules/platform-identity/terms-acceptance.js +97 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/terms.html +8 -0
- 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/connections_api.mjs +13 -8
- package/tests/connections_ownership.mjs +6 -1
- package/tests/effective_visibility_predicate.mjs +89 -14
- package/tests/profile_connections_ui.mjs +60 -7
- package/tests/profile_privacy_owner_view.mjs +31 -4
- package/tests/profile_route.mjs +45 -13
- 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
|
|
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
|
|
@@ -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'
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
|
56
|
-
// anything falsy (absent row, unreadable account) answers
|
|
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
|
-
|
|
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 `(${
|
|
74
|
+
return `(${accountActiveSql(alias)} AND ${q}account_visibility = 'public')`;
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
// The CONSENT floor on its own —
|
|
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
|
-
|
|
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
|
|
27
|
-
//
|
|
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 —
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
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.
|
|
59
|
-
//
|
|
60
|
-
//
|
|
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
|
}
|