@bongos/core 1.19.428 → 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 +46 -26
- package/clients/bongos-client/index.d.ts +1 -1
- 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 +3 -2
- package/docs/copy-inventory.md +24 -23
- package/docs/copy-registry.json +37 -28
- package/docs/file-map.md +2 -1
- package/docs/module-api-changelog.md +2 -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/sso.js +7 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- 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/docs/copy-registry.json
CHANGED
|
@@ -3,17 +3,17 @@
|
|
|
3
3
|
"generator": "scripts/gds/copy-inventory.js",
|
|
4
4
|
"goal": 1000074,
|
|
5
5
|
"counts": {
|
|
6
|
-
"strings":
|
|
7
|
-
"unique_ids":
|
|
8
|
-
"certain":
|
|
6
|
+
"strings": 1589,
|
|
7
|
+
"unique_ids": 1383,
|
|
8
|
+
"certain": 1589,
|
|
9
9
|
"heuristic": 0,
|
|
10
10
|
"duplicate_ids": 157
|
|
11
11
|
},
|
|
12
12
|
"surfaces": {
|
|
13
13
|
"builders-hall": {
|
|
14
14
|
"at": "modules/hall-ui/public",
|
|
15
|
-
"files":
|
|
16
|
-
"strings":
|
|
15
|
+
"files": 74,
|
|
16
|
+
"strings": 1165
|
|
17
17
|
},
|
|
18
18
|
"status-dashboard": {
|
|
19
19
|
"at": "modules/status-ui/public",
|
|
@@ -623,7 +623,7 @@
|
|
|
623
623
|
"text": "Cross-project record",
|
|
624
624
|
"occurrences": 2,
|
|
625
625
|
"at": [
|
|
626
|
-
"modules/hall-ui/public/profile.html:
|
|
626
|
+
"modules/hall-ui/public/profile.html:52",
|
|
627
627
|
"modules/hall-ui/public/settings.html:86"
|
|
628
628
|
]
|
|
629
629
|
},
|
|
@@ -1183,8 +1183,8 @@
|
|
|
1183
1183
|
"text": "Request sent",
|
|
1184
1184
|
"occurrences": 2,
|
|
1185
1185
|
"at": [
|
|
1186
|
-
"modules/hall-ui/public/profile-connections.js:
|
|
1187
|
-
"modules/hall-ui/public/profile-connections.js:
|
|
1186
|
+
"modules/hall-ui/public/profile-connections.js:86",
|
|
1187
|
+
"modules/hall-ui/public/profile-connections.js:134"
|
|
1188
1188
|
]
|
|
1189
1189
|
},
|
|
1190
1190
|
{
|
|
@@ -2141,7 +2141,7 @@
|
|
|
2141
2141
|
"surface": "builders-hall",
|
|
2142
2142
|
"text": "Activity, by craft",
|
|
2143
2143
|
"file": "modules/hall-ui/public/profile.html",
|
|
2144
|
-
"line":
|
|
2144
|
+
"line": 89,
|
|
2145
2145
|
"origin": "html-text",
|
|
2146
2146
|
"confidence": "certain"
|
|
2147
2147
|
},
|
|
@@ -2663,7 +2663,7 @@
|
|
|
2663
2663
|
"surface": "builders-hall",
|
|
2664
2664
|
"text": "By the numbers",
|
|
2665
2665
|
"file": "modules/hall-ui/public/profile.html",
|
|
2666
|
-
"line":
|
|
2666
|
+
"line": 42,
|
|
2667
2667
|
"origin": "html-text",
|
|
2668
2668
|
"confidence": "certain"
|
|
2669
2669
|
},
|
|
@@ -3599,7 +3599,7 @@
|
|
|
3599
3599
|
"surface": "builders-hall",
|
|
3600
3600
|
"text": "Could not load the profile.",
|
|
3601
3601
|
"file": "modules/hall-ui/public/profile.js",
|
|
3602
|
-
"line":
|
|
3602
|
+
"line": 625,
|
|
3603
3603
|
"origin": "js-notice",
|
|
3604
3604
|
"confidence": "certain"
|
|
3605
3605
|
},
|
|
@@ -4112,7 +4112,7 @@
|
|
|
4112
4112
|
"surface": "builders-hall",
|
|
4113
4113
|
"text": "Cross-project record",
|
|
4114
4114
|
"file": "modules/hall-ui/public/profile.html",
|
|
4115
|
-
"line":
|
|
4115
|
+
"line": 52,
|
|
4116
4116
|
"origin": "html-text",
|
|
4117
4117
|
"confidence": "certain"
|
|
4118
4118
|
},
|
|
@@ -4517,7 +4517,7 @@
|
|
|
4517
4517
|
"surface": "builders-hall",
|
|
4518
4518
|
"text": "Every idea this builder captured — promoted, discarded, merged, or still open.",
|
|
4519
4519
|
"file": "modules/hall-ui/public/profile.html",
|
|
4520
|
-
"line":
|
|
4520
|
+
"line": 109,
|
|
4521
4521
|
"origin": "html-text",
|
|
4522
4522
|
"confidence": "certain"
|
|
4523
4523
|
},
|
|
@@ -4895,7 +4895,7 @@
|
|
|
4895
4895
|
"surface": "builders-hall",
|
|
4896
4896
|
"text": "Goals created",
|
|
4897
4897
|
"file": "modules/hall-ui/public/profile.html",
|
|
4898
|
-
"line":
|
|
4898
|
+
"line": 101,
|
|
4899
4899
|
"origin": "html-text",
|
|
4900
4900
|
"confidence": "certain"
|
|
4901
4901
|
},
|
|
@@ -5201,7 +5201,7 @@
|
|
|
5201
5201
|
"surface": "builders-hall",
|
|
5202
5202
|
"text": "Ideas filed, and what became of them",
|
|
5203
5203
|
"file": "modules/hall-ui/public/profile.html",
|
|
5204
|
-
"line":
|
|
5204
|
+
"line": 108,
|
|
5205
5205
|
"origin": "html-text",
|
|
5206
5206
|
"confidence": "certain"
|
|
5207
5207
|
},
|
|
@@ -5678,7 +5678,7 @@
|
|
|
5678
5678
|
"surface": "builders-hall",
|
|
5679
5679
|
"text": "Loading profile…",
|
|
5680
5680
|
"file": "modules/hall-ui/public/profile.html",
|
|
5681
|
-
"line":
|
|
5681
|
+
"line": 28,
|
|
5682
5682
|
"origin": "html-text",
|
|
5683
5683
|
"confidence": "certain"
|
|
5684
5684
|
},
|
|
@@ -7334,7 +7334,7 @@
|
|
|
7334
7334
|
"surface": "builders-hall",
|
|
7335
7335
|
"text": "Recent works",
|
|
7336
7336
|
"file": "modules/hall-ui/public/profile.html",
|
|
7337
|
-
"line":
|
|
7337
|
+
"line": 66,
|
|
7338
7338
|
"origin": "html-text",
|
|
7339
7339
|
"confidence": "certain"
|
|
7340
7340
|
},
|
|
@@ -7577,7 +7577,7 @@
|
|
|
7577
7577
|
"surface": "builders-hall",
|
|
7578
7578
|
"text": "Request sent",
|
|
7579
7579
|
"file": "modules/hall-ui/public/profile-connections.js",
|
|
7580
|
-
"line":
|
|
7580
|
+
"line": 86,
|
|
7581
7581
|
"origin": "js-markup",
|
|
7582
7582
|
"confidence": "certain"
|
|
7583
7583
|
},
|
|
@@ -7586,7 +7586,7 @@
|
|
|
7586
7586
|
"surface": "builders-hall",
|
|
7587
7587
|
"text": "Request sent",
|
|
7588
7588
|
"file": "modules/hall-ui/public/profile-connections.js",
|
|
7589
|
-
"line":
|
|
7589
|
+
"line": 134,
|
|
7590
7590
|
"origin": "js-markup",
|
|
7591
7591
|
"confidence": "certain"
|
|
7592
7592
|
},
|
|
@@ -7905,6 +7905,15 @@
|
|
|
7905
7905
|
"origin": "html-text",
|
|
7906
7906
|
"confidence": "certain"
|
|
7907
7907
|
},
|
|
7908
|
+
{
|
|
7909
|
+
"id": "a9c398651862",
|
|
7910
|
+
"surface": "builders-hall",
|
|
7911
|
+
"text": "See the cross-project record",
|
|
7912
|
+
"file": "modules/hall-ui/public/profile-canonical.js",
|
|
7913
|
+
"line": 114,
|
|
7914
|
+
"origin": "js-markup",
|
|
7915
|
+
"confidence": "certain"
|
|
7916
|
+
},
|
|
7908
7917
|
{
|
|
7909
7918
|
"id": "17d5b5739f46",
|
|
7910
7919
|
"surface": "builders-hall",
|
|
@@ -8162,7 +8171,7 @@
|
|
|
8162
8171
|
"surface": "builders-hall",
|
|
8163
8172
|
"text": "Sign in to the platform once to activate your profile before connecting.",
|
|
8164
8173
|
"file": "modules/hall-ui/public/profile-connections.js",
|
|
8165
|
-
"line":
|
|
8174
|
+
"line": 220,
|
|
8166
8175
|
"origin": "js-markup",
|
|
8167
8176
|
"confidence": "certain"
|
|
8168
8177
|
},
|
|
@@ -8288,7 +8297,7 @@
|
|
|
8288
8297
|
"surface": "builders-hall",
|
|
8289
8298
|
"text": "Something went wrong. Try again.",
|
|
8290
8299
|
"file": "modules/hall-ui/public/profile-connections.js",
|
|
8291
|
-
"line":
|
|
8300
|
+
"line": 275,
|
|
8292
8301
|
"origin": "js-notice",
|
|
8293
8302
|
"confidence": "certain"
|
|
8294
8303
|
},
|
|
@@ -8594,7 +8603,7 @@
|
|
|
8594
8603
|
"surface": "builders-hall",
|
|
8595
8604
|
"text": "That request is no longer available.",
|
|
8596
8605
|
"file": "modules/hall-ui/public/profile-connections.js",
|
|
8597
|
-
"line":
|
|
8606
|
+
"line": 270,
|
|
8598
8607
|
"origin": "js-notice",
|
|
8599
8608
|
"confidence": "certain"
|
|
8600
8609
|
},
|
|
@@ -8729,7 +8738,7 @@
|
|
|
8729
8738
|
"surface": "builders-hall",
|
|
8730
8739
|
"text": "The most recent works this builder has shipped.",
|
|
8731
8740
|
"file": "modules/hall-ui/public/profile.html",
|
|
8732
|
-
"line":
|
|
8741
|
+
"line": 67,
|
|
8733
8742
|
"origin": "html-text",
|
|
8734
8743
|
"confidence": "certain"
|
|
8735
8744
|
},
|
|
@@ -8954,7 +8963,7 @@
|
|
|
8954
8963
|
"surface": "builders-hall",
|
|
8955
8964
|
"text": "They have asked to connect.",
|
|
8956
8965
|
"file": "modules/hall-ui/public/profile-connections.js",
|
|
8957
|
-
"line":
|
|
8966
|
+
"line": 203,
|
|
8958
8967
|
"origin": "js-markup",
|
|
8959
8968
|
"confidence": "certain"
|
|
8960
8969
|
},
|
|
@@ -9458,7 +9467,7 @@
|
|
|
9458
9467
|
"surface": "builders-hall",
|
|
9459
9468
|
"text": "What this builder has done, seen one craft at a time.",
|
|
9460
9469
|
"file": "modules/hall-ui/public/profile.html",
|
|
9461
|
-
"line":
|
|
9470
|
+
"line": 90,
|
|
9462
9471
|
"origin": "html-text",
|
|
9463
9472
|
"confidence": "certain"
|
|
9464
9473
|
},
|
|
@@ -9638,7 +9647,7 @@
|
|
|
9638
9647
|
"surface": "builders-hall",
|
|
9639
9648
|
"text": "Works completed",
|
|
9640
9649
|
"file": "modules/hall-ui/public/profile.html",
|
|
9641
|
-
"line":
|
|
9650
|
+
"line": 96,
|
|
9642
9651
|
"origin": "html-text",
|
|
9643
9652
|
"confidence": "certain"
|
|
9644
9653
|
},
|
|
@@ -11420,7 +11429,7 @@
|
|
|
11420
11429
|
"surface": "builders-hall",
|
|
11421
11430
|
"text": "{…} asking to connect",
|
|
11422
11431
|
"file": "modules/hall-ui/public/profile-connections.js",
|
|
11423
|
-
"line":
|
|
11432
|
+
"line": 136,
|
|
11424
11433
|
"origin": "js-markup",
|
|
11425
11434
|
"confidence": "certain"
|
|
11426
11435
|
},
|
|
@@ -11663,7 +11672,7 @@
|
|
|
11663
11672
|
"surface": "builders-hall",
|
|
11664
11673
|
"text": "{…} you have sent",
|
|
11665
11674
|
"file": "modules/hall-ui/public/profile-connections.js",
|
|
11666
|
-
"line":
|
|
11675
|
+
"line": 137,
|
|
11667
11676
|
"origin": "js-markup",
|
|
11668
11677
|
"confidence": "certain"
|
|
11669
11678
|
},
|
package/docs/file-map.md
CHANGED
|
@@ -244,7 +244,7 @@
|
|
|
244
244
|
│ ├── terrain.js ← (Phase D + ADR 0012, CJS): TERRAIN + OBJECT enums + BLOCKING_TERRAINS + BLOCKING_OBJECTS (server twin of public/game/world/terrain.js)
|
|
245
245
|
│ └── map.js ← (Phase D + ADR 0012): two-grid INITIAL_MAP {ground, objects}; isBlocked checks both layers (mirrors public/game/world/{terrain,map}.js)
|
|
246
246
|
│ (public-status/ moved to modules/status-ui/public/ in BV1.R84 — see the modules/ block above)
|
|
247
|
-
│ (public-builders/ moved to modules/hall-ui/public/ in BV1.R85 — the authenticated builders' hall; see the modules/ block above. It keeps its DEDICATED serving block in src/bongos/serve-internal.js: the builders.<apex> + apex /builders/* shim, canonical redirects, the citizen/Archon page gates, asset-stamping, and the module hall-widget injection — partitioned out of the generic web-surface loop. index.html=the hall dashboard; work/board/roadmap/goals/watch/harbor/gate/pair/settings/ranks/sessions/primer/diagrams/atlas pages; ideas.{html,js,css}=the Full Idea composer (task 1002928 / R152 of goal 1000062 — the five R141 fields with a live R142 completeness meter, both fetched from the server so the page carries no copy of the rules; drafts in localStorage so navigation costs nothing. Task 1002930 / R154 added the SPARK QUEUE above it and a second mode: `?develop=<id>` reuses the same form to develop a spark — the spark's words shown above the fields, its title read-only, and the file routed to POST /inbox/:id/develop instead of POST /inbox, under its own per-idea draft key); idea.html/idea.js=the idea-detail PAGE (/idea/:id, task 1002929 / R153 — the SECOND two-segment hall page after /task/:id: grade, the five answers in template order, the score, and both attributions. The GRADE decides what the page contains — a quick idea gets no five-field skeleton. task-detail.js keeps the #/idea/N overlay because ~320 of its lines are the Metic+ triage panel; the overlay now links here); profile.html/profile.js=the public, shareable builder profile page (/profile?id=N — rank, works, credits, karma, achievements; task 1467); profile-connections.js=section 6 of that page, the hub-only connections block (task 1002296 / R20 of goal 1000045 — the viewer-relative 1st/2nd/3rd/out-of-network label, the mutuals it was computed from, the connect button, and on your own profile both request queues plus your connections ledger. Loaded BEFORE profile.js, which calls window.OTBConnections.render behind a guard, so a page that omits the script simply has no section; the section reveals only when the hub's /connections routes answer, exactly as the cross-project record does. ADR 0212 records why it is on this page rather than the not-yet-built /u/<handle>, and why it addresses a builder by github_login and then uses the canonical handle the API echoes back); task.html/task.js=the task-detail page (/task/:id, task 751 — replaced the old #/task/N overlay); builders.js=the controller; dom-utils.js/hall-widgets.js=the widget+settings-panel registries.)
|
|
247
|
+
│ (public-builders/ moved to modules/hall-ui/public/ in BV1.R85 — the authenticated builders' hall; see the modules/ block above. It keeps its DEDICATED serving block in src/bongos/serve-internal.js: the builders.<apex> + apex /builders/* shim, canonical redirects, the citizen/Archon page gates, asset-stamping, and the module hall-widget injection — partitioned out of the generic web-surface loop. index.html=the hall dashboard; work/board/roadmap/goals/watch/harbor/gate/pair/settings/ranks/sessions/primer/diagrams/atlas pages; ideas.{html,js,css}=the Full Idea composer (task 1002928 / R152 of goal 1000062 — the five R141 fields with a live R142 completeness meter, both fetched from the server so the page carries no copy of the rules; drafts in localStorage so navigation costs nothing. Task 1002930 / R154 added the SPARK QUEUE above it and a second mode: `?develop=<id>` reuses the same form to develop a spark — the spark's words shown above the fields, its title read-only, and the file routed to POST /inbox/:id/develop instead of POST /inbox, under its own per-idea draft key); idea.html/idea.js=the idea-detail PAGE (/idea/:id, task 1002929 / R153 — the SECOND two-segment hall page after /task/:id: grade, the five answers in template order, the score, and both attributions. The GRADE decides what the page contains — a quick idea gets no five-field skeleton. task-detail.js keeps the #/idea/N overlay because ~320 of its lines are the Metic+ triage panel; the overlay now links here); profile.html/profile.js=the public, shareable builder profile page (/profile?id=N — rank, works, credits, karma, achievements; task 1467); profile-connections.js=section 6 of that page, the hub-only connections block (task 1002296 / R20 of goal 1000045 — the viewer-relative 1st/2nd/3rd/out-of-network label, the mutuals it was computed from, the connect button, and on your own profile both request queues plus your connections ledger. Loaded BEFORE profile.js, which calls window.OTBConnections.render behind a guard, so a page that omits the script simply has no section; the section reveals only when the hub's /connections routes answer, exactly as the cross-project record does. ADR 0212 records why it is on this page rather than the not-yet-built /u/<handle>, and why it addresses a builder by github_login and then uses the canonical handle the API echoes back); profile-canonical.js=the canonical-profile wiring for that page (task 1002804 / P08, ADR 0214 — on a FEDERATED instance the rel=canonical plus the visible "see the cross-project record" link-out ADR 0171 D1 asks for; on the HUB the numeric ?id= form redirects to the handle URL instead, because a page cannot both redirect to a URL and declare it canonical. All three read ONE resolver, modules/platform-identity/canonical-profile.js, which answers null while /u/<handle> does not exist — so everything is dark today and one boolean turns it all on together); task.html/task.js=the task-detail page (/task/:id, task 751 — replaced the old #/task/N overlay); builders.js=the controller; dom-utils.js/hall-widgets.js=the widget+settings-panel registries.)
|
|
248
248
|
│
|
|
249
249
|
├── infra/ ← ON-BOX scripts (task 1002699): the four reporter/heartbeat crons every dev box runs from its /workspace clone — box-heartbeat.sh (idle-sweep activity signal), box-report-version.sh (HEAD → POST /box/version), box-report-host-keys.sh (SSH host keys → POST /box/host-keys), box-report-terminal.sh (ttyd URL+credential → POST /box/terminal). Delivered by the workspace clone itself (modules/dev-box/box-access.js sparse scope includes infra/); crons self-installed by box-source-fetch.sh's per-tick bring-up. API base resolves GDS_BASE env → session-file api_base → skip (no hardcoded host in the neutral core).
|
|
250
250
|
├── devbox-app/ ← OTB Dev Box — Electron menu-bar/tray switch for the builder's box (#904, ADR 0045; internal). SELF-CONTAINED: own package.json (never feeds the droplet's npm ci); built by .github/workflows/build-devbox-app.yml; published to the public releases repo per config/devbox-app.json; release recipe docs/recipes/devbox-app-release.md
|
|
@@ -351,6 +351,7 @@ tests/
|
|
|
351
351
|
├── own_scope_guard.mjs ← (task 1003343, goal 1000045 criterion own-scope-held-by-a-test): the OWN-SCOPE structural guard, generalised out of memory_isolation.mjs. An explicit inventory of own-scoped route files; inside them the route's declared PATH decides — a path naming no builder must never read a subject from the request (direct, optional-chained, destructured, aliased, computed-key or header), a path that DOES name one needs a per-route exemption whose gate the guard verifies is present. Repo-wide backstop: any file mounting a `/me` route must be in the inventory. Source-grepping, so it runs in the DB-free `unit` lane.
|
|
352
352
|
├── account_existence_rate_limit.mjs ← (task 1003339, goal 1000045, ADR 0209): the two PUBLIC account-existence probes (onboarding GET /access-requests/status + core GET /auth/web/admission-status) share ONE per-IP budget. Counts to the limit on each route, proves the budget is shared in both directions and when alternated, moves Date.now to prove the window slides on the real mounted route, and proves a refused probe issues no query. DB-free; mutation-checked three ways.
|
|
353
353
|
├── effective_visibility_predicate.mjs ← (task 1002967, goal 1000045): the EFFECTIVE-VISIBILITY structural guard over `modules/platform-identity/account-visibility.js` (the file task 1002295 landed and this task ADOPTED rather than duplicating). Three layers — SQL and JS agree row-for-row over the privacy-spec D2 matrix; that file is the only one allowed to compare `profile_state`/`account_visibility`/`hide_stats` to a literal (per-occurrence exemptions, each with a reason and a checked obligation); and every `SELECT` on `platform_identity_accounts` is enumerated FROM SOURCE and must join a shared predicate, be declared non-public, or be a checked deferral to a named caller — so a read that does not exist yet (hub search, the sitemap) fails the build until its author classifies it. Behavioural half runs the real `/profiles/:handle` route over an in-memory accounts table filtered by each statement's own WHERE clause: private → 404 (never 403), byte-identical to stranger and owner alike. DB-free `unit` lane.
|
|
354
|
+
├── canonical_profile_url.mjs ← (task 1002804, goal 1000045 criterion C1, ADR 0214): the P08 proof — the canonical profile URL is resolved in ONE place, `handle` reaches `GET /builders/:id/profile` under the EXISTENCE predicate (so a hidden builder keeps their address), absent/provisional/private are BYTE-IDENTICAL so the numeric-id redirect cannot become an existence oracle, and the widened response still leaks no private column. Real core router over a WHERE-derived fake pool on a real socket; the real browser script in a vm above a stub DOM, run twice — as shipped (every surface dark, because `/u/<handle>` still 404s) and with the liveness constant flipped, so the three live behaviours are proved rather than deferred. Asserts its own DB-free lane membership. DB-free `unit` lane.
|
|
354
355
|
├── application_lifecycle.mjs ← (task 1002284, goal 1000045, ADR 0201 D1/D3.2/D4 + ADR 0208): the application lifecycle EXECUTED — apply, list-pending, approve, decline, RESCIND (`invited → dismissed`), idempotency on the state already held, and the two things that must never happen: a builder without `access_request.review` moving the queue, and a VOUCH buying admission. Real router, REAL `requirePermission` resolving against the real government catalog's rank seed, and the REAL `hasInvitedAccessRequest` from `src/bongos/db-kernel.js` pointed at the same in-memory `access_requests` table (a stubbed `./pool`), whose filter is built from each statement's own WHERE clause and which refuses a predicate it cannot read. The no-authority proof seeds a vouched row and asserts the approve path and the admission read are byte-identical to the unvouched twin, and that no statement on either path names `applicant_github_id`/`vouched_at`. DB-free `unit` lane.
|
|
355
356
|
├── application_echo.mjs ← (task 1002284, goal 1000045, ADR 0201 D1.3/D1.4): the hub's STATUSLESS echo of an application. Part A pins the domain functions — the read binds `APPLICATION_ECHO_WINDOW_DAYS` itself (one 30, not three), the write is an upsert on the pair, neither statement mentions a status, and a non-numeric principal is refused before any query (ADR 0171 D2). Part B runs the real `POST /my-projects/join` and `GET /my-projects` over an in-memory `platform_identity_application_echoes` table with the relay's answer scripted: only an accepted application leaves a trace — a standing membership, either door refusal, a members-only project and an unreachable project leave none — and best-effort is asserted in both directions (a failed echo write still reports the filed application; a failed echo read still lands the memberships listing). DB-free `unit` lane.
|
|
356
357
|
├── scouting_owner_access_and_reach.mjs ← (task 1002290, goal 1000045 C5, ADR 0210): the R14 proof — WHO may read the cross-project recruiting directory, and WHO appears in it. A project owner (an instance-asserted, hub-witnessed `membership_kind='owner'`) reads `GET /scouting` with no rank at all; a builder who is neither owner nor `project.curate` holder gets the unchanged 403; an un-witnessed owner claim is refused; a broken ownership read falls closed onto the permission gate rather than admitting or 500ing. And the recruiting opt-out finally bites: a public, unhidden, active account that opted out is absent from `/scouting` and the hall's `/people` (the same endpoint), returns when it opts back in, and the opt-out grants and denies no permission — an opted-out owner still reads the directory while being absent from their own copy of it. Fake pool filtered by each statement's own WHERE clause, real route, real `requirePermission` over the real government catalog, real HTTP. Mutation-checked three ways. DB-free `unit` lane.
|
|
@@ -1293,5 +1293,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1293
1293
|
landed since 1.19.426 with no explicit bump. run 33271340372. (task 1002620)
|
|
1294
1294
|
1.19.428 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1295
1295
|
landed since 1.19.427 with no explicit bump. run 33274315388. (task 1002620)
|
|
1296
|
+
1.19.429 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1297
|
+
landed since 1.19.428 with no explicit bump. run 33274860035. (task 1002620)
|
|
1296
1298
|
---------------------------------------------------------------------------
|
|
1297
1299
|
```
|
|
@@ -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
|
+
};
|