@bongos/core 1.19.565 → 1.19.567
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 +40 -20
- package/docs/adr/0254-a-relayed-link-is-contained-downward-or-it-is-not-published.md +119 -0
- package/docs/adr/README.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/platform-identity/origin-containment.js +77 -0
- package/modules/platform-identity/platform-identity.js +16 -27
- package/modules/platform-identity/project-stats.js +33 -1
- package/modules/platform-identity/routes/my-projects.js +4 -0
- package/modules/public-landing/public/projects.html +89 -53
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/manage_settings_shared_read.mjs +146 -0
- package/tests/origin_containment.mjs +114 -0
- package/tests/project_door_ui.mjs +16 -2
- package/tests/project_stats.mjs +82 -14
- package/tests/projects_hub.mjs +24 -2
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.567",
|
|
6
|
+
"core_contract": "1.19.567",
|
|
7
|
+
"source_commit": "086c24f62d0d594e3949cda82f35b6c73105e9c8",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-06T20:18:34.727Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 447,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2037,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2508,
|
|
20
|
+
"tree_sha256": "0de4688c7206008f5e7ce542823b8efe4c438fc7debe1dffb116e3cfe63558d4",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -1784,10 +1784,15 @@
|
|
|
1784
1784
|
"mode": "0000644",
|
|
1785
1785
|
"sha256": "1e17e1499be966af136b63f75693547d2e1e46f825af5755310b9096e949054f"
|
|
1786
1786
|
},
|
|
1787
|
+
{
|
|
1788
|
+
"path": "docs/adr/0254-a-relayed-link-is-contained-downward-or-it-is-not-published.md",
|
|
1789
|
+
"mode": "0000644",
|
|
1790
|
+
"sha256": "4e27106f8c9251b788d13bcfc64f29637bb0b009c18102c9b7dd9d70b80400dc"
|
|
1791
|
+
},
|
|
1787
1792
|
{
|
|
1788
1793
|
"path": "docs/adr/README.md",
|
|
1789
1794
|
"mode": "0000644",
|
|
1790
|
-
"sha256": "
|
|
1795
|
+
"sha256": "2e2089caa50d1b7442fee96b14f758e6e29974490a8729cfa9401a59e70077b4"
|
|
1791
1796
|
},
|
|
1792
1797
|
{
|
|
1793
1798
|
"path": "docs/api-reference.md",
|
|
@@ -2672,7 +2677,7 @@
|
|
|
2672
2677
|
{
|
|
2673
2678
|
"path": "docs/module-api-changelog.md",
|
|
2674
2679
|
"mode": "0000644",
|
|
2675
|
-
"sha256": "
|
|
2680
|
+
"sha256": "cb50b3fb2a66dc2e8c4e69f97055192f33e21fb33089f2c77313f43ae6daea09"
|
|
2676
2681
|
},
|
|
2677
2682
|
{
|
|
2678
2683
|
"path": "docs/modules-contract.md",
|
|
@@ -6034,10 +6039,15 @@
|
|
|
6034
6039
|
"mode": "0000644",
|
|
6035
6040
|
"sha256": "cc6f7ab084ee74715241220339411d1a10c8d6697af143df452278ea2c634f79"
|
|
6036
6041
|
},
|
|
6042
|
+
{
|
|
6043
|
+
"path": "modules/platform-identity/origin-containment.js",
|
|
6044
|
+
"mode": "0000644",
|
|
6045
|
+
"sha256": "0195910fd74860aa7b3b54ebfe73875ffba3e59938aac1904c07538f8b607a88"
|
|
6046
|
+
},
|
|
6037
6047
|
{
|
|
6038
6048
|
"path": "modules/platform-identity/platform-identity.js",
|
|
6039
6049
|
"mode": "0000644",
|
|
6040
|
-
"sha256": "
|
|
6050
|
+
"sha256": "5cccd4a776abc25f0e57814963b1dc00b8a7a49b5024d2c0b88c85d8312091f1"
|
|
6041
6051
|
},
|
|
6042
6052
|
{
|
|
6043
6053
|
"path": "modules/platform-identity/pollers/code-sweep.js",
|
|
@@ -6057,7 +6067,7 @@
|
|
|
6057
6067
|
{
|
|
6058
6068
|
"path": "modules/platform-identity/project-stats.js",
|
|
6059
6069
|
"mode": "0000644",
|
|
6060
|
-
"sha256": "
|
|
6070
|
+
"sha256": "5f225b384a70902b9f24ab63fe032c3f256363fccbe116a267958325b85da167"
|
|
6061
6071
|
},
|
|
6062
6072
|
{
|
|
6063
6073
|
"path": "modules/platform-identity/projects-feed.js",
|
|
@@ -6082,7 +6092,7 @@
|
|
|
6082
6092
|
{
|
|
6083
6093
|
"path": "modules/platform-identity/routes/my-projects.js",
|
|
6084
6094
|
"mode": "0000644",
|
|
6085
|
-
"sha256": "
|
|
6095
|
+
"sha256": "ea3f35797eddb12bc50f5bf04585df9a2ca7e223bee69c77e275b5584d9066e9"
|
|
6086
6096
|
},
|
|
6087
6097
|
{
|
|
6088
6098
|
"path": "modules/platform-identity/routes/profile.js",
|
|
@@ -6772,7 +6782,7 @@
|
|
|
6772
6782
|
{
|
|
6773
6783
|
"path": "modules/public-landing/public/projects.html",
|
|
6774
6784
|
"mode": "0000644",
|
|
6775
|
-
"sha256": "
|
|
6785
|
+
"sha256": "7aa74c059670bd351bf8a4295e408aef4a908e3095711aee93f75408ccd837d5"
|
|
6776
6786
|
},
|
|
6777
6787
|
{
|
|
6778
6788
|
"path": "modules/public-landing/public/projects.probes.json",
|
|
@@ -7517,12 +7527,12 @@
|
|
|
7517
7527
|
{
|
|
7518
7528
|
"path": "package-lock.json",
|
|
7519
7529
|
"mode": "0000644",
|
|
7520
|
-
"sha256": "
|
|
7530
|
+
"sha256": "88e992e7dc0acfb523d3c55d84184674c584a95ae5e9b9a7339e1ef8d39954c8"
|
|
7521
7531
|
},
|
|
7522
7532
|
{
|
|
7523
7533
|
"path": "package.json",
|
|
7524
7534
|
"mode": "0000644",
|
|
7525
|
-
"sha256": "
|
|
7535
|
+
"sha256": "ef5db9f2e98f980f0eea7ee5409a29d544ac73edcf33a15f8ecde5863db87e8d"
|
|
7526
7536
|
},
|
|
7527
7537
|
{
|
|
7528
7538
|
"path": "public-docs/index.html",
|
|
@@ -9222,7 +9232,7 @@
|
|
|
9222
9232
|
{
|
|
9223
9233
|
"path": "src/module-api.js",
|
|
9224
9234
|
"mode": "0000644",
|
|
9225
|
-
"sha256": "
|
|
9235
|
+
"sha256": "318a0b749a46e513641ebec57124282b811631fe0037a166f50abd28d3b63f40"
|
|
9226
9236
|
},
|
|
9227
9237
|
{
|
|
9228
9238
|
"path": "src/module-loader/catalog.js",
|
|
@@ -11114,6 +11124,11 @@
|
|
|
11114
11124
|
"mode": "0000644",
|
|
11115
11125
|
"sha256": "9a1813d6036886ea07d1563e71a466e66c28554b75b22605464c795fd9fa2ebb"
|
|
11116
11126
|
},
|
|
11127
|
+
{
|
|
11128
|
+
"path": "tests/manage_settings_shared_read.mjs",
|
|
11129
|
+
"mode": "0000644",
|
|
11130
|
+
"sha256": "68a429b47a1ca36a41433c0d8c3298a0f54745a19b050675aedba658a2cea0ef"
|
|
11131
|
+
},
|
|
11117
11132
|
{
|
|
11118
11133
|
"path": "tests/me_ship_days_bound.mjs",
|
|
11119
11134
|
"mode": "0000644",
|
|
@@ -11349,6 +11364,11 @@
|
|
|
11349
11364
|
"mode": "0000644",
|
|
11350
11365
|
"sha256": "1f8c6924dac2c922a1f88c22693b4386ec16d5f0ae346609b3d1dc0003308a70"
|
|
11351
11366
|
},
|
|
11367
|
+
{
|
|
11368
|
+
"path": "tests/origin_containment.mjs",
|
|
11369
|
+
"mode": "0000644",
|
|
11370
|
+
"sha256": "62ac725d14be8976580824e430e36382dc643900b2dbd0fc65dc47b6725f96fd"
|
|
11371
|
+
},
|
|
11352
11372
|
{
|
|
11353
11373
|
"path": "tests/orphaned_claim_ship.mjs",
|
|
11354
11374
|
"mode": "0000644",
|
|
@@ -11507,7 +11527,7 @@
|
|
|
11507
11527
|
{
|
|
11508
11528
|
"path": "tests/project_door_ui.mjs",
|
|
11509
11529
|
"mode": "0000644",
|
|
11510
|
-
"sha256": "
|
|
11530
|
+
"sha256": "97d570d16414bb5c71d54223702058018a13c58cdf563448c7d28e2fcf80488e"
|
|
11511
11531
|
},
|
|
11512
11532
|
{
|
|
11513
11533
|
"path": "tests/project_invite_ownership.mjs",
|
|
@@ -11547,7 +11567,7 @@
|
|
|
11547
11567
|
{
|
|
11548
11568
|
"path": "tests/project_stats.mjs",
|
|
11549
11569
|
"mode": "0000644",
|
|
11550
|
-
"sha256": "
|
|
11570
|
+
"sha256": "398b598fbfc0717513ff8e5abd641219906972209e4b92404c993cbd68d6dd49"
|
|
11551
11571
|
},
|
|
11552
11572
|
{
|
|
11553
11573
|
"path": "tests/project_takedown_lever.mjs",
|
|
@@ -11562,7 +11582,7 @@
|
|
|
11562
11582
|
{
|
|
11563
11583
|
"path": "tests/projects_hub.mjs",
|
|
11564
11584
|
"mode": "0000644",
|
|
11565
|
-
"sha256": "
|
|
11585
|
+
"sha256": "f93044429430c9fff4fe6860bae87ff8f67bc49a485391e659422e63f209e08c"
|
|
11566
11586
|
},
|
|
11567
11587
|
{
|
|
11568
11588
|
"path": "tests/projects_hub_module_picker.mjs",
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# ADR 0254 — A relayed link is contained downward, or it is not published
|
|
2
|
+
|
|
3
|
+
- **Status:** accepted
|
|
4
|
+
- **Date:** 2026-09-06
|
|
5
|
+
- **Task:** [task 1003670](https://cloudbongos.com/builders#/task/1003670) (BONGOS-V1, goal 1000046 — *Project creation*, criterion `proj-visibility-states` / C1) — the gap [ADR 0252](<redacted>.md) §5 named and filed rather than left
|
|
6
|
+
- **Deciders:** Claude, building the task ADR 0252 §5 opened
|
|
7
|
+
- **Related:** [ADR 0252](<redacted>.md) §3/§5 (auto-appear, and the condition it was granted on) · [ADR 0253](<redacted>.md) §2 (*narrowing is free; widening is what costs* — the asymmetry this applies again) · [ADR 0182](<redacted>.md) D2a (redaction is server-side, because the page cannot be the gate) · [ADR 0171](<redacted>.md) D7 (validation lives next to the data, where no caller can forget it) · [ADR 0114](<redacted>.md) (the discovery index this value arrives on) · [ADR 0083](<redacted>.md) (the module boundary the shared file sits inside)
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 1. The defect: "differs from" is containment inverted
|
|
12
|
+
|
|
13
|
+
`hall_origin` is the project's builders'-hall address on the public projects feed. It comes
|
|
14
|
+
from the REMOTE instance's own discovery index (`GET <origin>/api/bongos` → `index.hall`,
|
|
15
|
+
read in `modules/platform-identity/project-stats.js`), and the hub renders it as an
|
|
16
|
+
`<a href>` on `/projects`, under that project's name.
|
|
17
|
+
|
|
18
|
+
Its only check was that the advertised host **differed** from the project's own site host.
|
|
19
|
+
That is not a weak containment rule; it is containment turned inside out. It admits every
|
|
20
|
+
host on the internet and refuses exactly one — the one that is certainly safe. An instance
|
|
21
|
+
answering
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{ "hall": "https://evil.example/builders" }
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
got that link published on the hub, on the hub's domain, attached to the victim's project.
|
|
28
|
+
|
|
29
|
+
The rule was written for a different question ([task 1003257](https://cloudbongos.com/builders#/task/1003257),
|
|
30
|
+
owner decision (a): *a door only where it opens somewhere else*) and it answers that
|
|
31
|
+
question correctly. Nothing was ever asked to answer the security one.
|
|
32
|
+
|
|
33
|
+
**Why it stopped being survivable.** Under control-plane provisioning the set of origins the
|
|
34
|
+
hub would ever fetch was bounded by whom the operator had provisioned. [ADR 0252](<redacted>.md)
|
|
35
|
+
§3 replaced that bound with *whoever points a domain at the hub*, and §5 named this gap in
|
|
36
|
+
the same breath — auto-appear turns a curated-origin risk into a phishing hop anyone can
|
|
37
|
+
mint. Auto-appear was granted on the condition that its guards ship; this is one of them.
|
|
38
|
+
|
|
39
|
+
## 2. Decision — the same downward-only rule, with one home
|
|
40
|
+
|
|
41
|
+
`hall_origin` is bound by the rule the corpus already had one field over: a relayed URL must
|
|
42
|
+
point **at or below** the origin that reported it. Concretely, a hall link survives only as
|
|
43
|
+
the project's own host or a **dot-anchored subdomain** of it, `http(s)` only, with no
|
|
44
|
+
embedded credentials, and **refused whole at the length cap rather than truncated** —
|
|
45
|
+
slicing a URL changes where it points, so a shortened one that still parses is worse than
|
|
46
|
+
no link.
|
|
47
|
+
|
|
48
|
+
Containment runs **first**; the existing "is this a different door" test runs second and is
|
|
49
|
+
unchanged. The two compare different things on purpose: containment compares **hostnames**,
|
|
50
|
+
because a port is not a trust boundary; the door test compares **hosts with the port**,
|
|
51
|
+
because a hall on another port of the same machine really does open somewhere else. Net
|
|
52
|
+
effect: a hall link is published only for a strict subdomain, or another port, of the
|
|
53
|
+
project's own origin.
|
|
54
|
+
|
|
55
|
+
**The rule now has exactly one home.** `shipUrlWithinClientOrigin` was a private helper
|
|
56
|
+
inside `platform-identity.js`; it is lifted to `modules/platform-identity/origin-containment.js`
|
|
57
|
+
as `urlWithinOrigin(raw, origin, max)` and required by both callers. This is not tidying:
|
|
58
|
+
the defect *was* two surfaces answering one question differently, and leaving the answer
|
|
59
|
+
where only one of them could reach it is what let them differ. (The rename is why ADR 0252
|
|
60
|
+
§5's `shipUrlWithinClientOrigin` no longer greps; ADR 0252 is left as written, per the
|
|
61
|
+
[ADR 0253](<redacted>.md) §1
|
|
62
|
+
convention that a decision record is not edited after the fact.)
|
|
63
|
+
|
|
64
|
+
Its own file, rather than a shared symbol left in place, for the reason a row of siblings in
|
|
65
|
+
this module already give in their headers: `platform-identity.js` is **at** the 1500-line
|
|
66
|
+
file-size ratchet with no headroom, and `project-stats.js` is a different file besides. The
|
|
67
|
+
lift shrinks `platform-identity.js` by 11 lines, so the ratchet is paid rather than fought.
|
|
68
|
+
It is deliberately **not** re-exported from `platform-identity.js` — a re-export would be a
|
|
69
|
+
third name for one answer, and no caller needs it.
|
|
70
|
+
|
|
71
|
+
## 3. Downward only, and the cost of that stated plainly
|
|
72
|
+
|
|
73
|
+
The mirror rule — *the link host is an ANCESTOR of the reporting origin* — looks symmetric
|
|
74
|
+
and is not. On a shared multi-tenant apex it lets an instance registered at `foo.github.io`
|
|
75
|
+
publish links on bare `github.io`, which it does not control, and by extension vouch for
|
|
76
|
+
every neighbour under it. The upward direction is not offered, here or for ship links.
|
|
77
|
+
|
|
78
|
+
**What that costs, named rather than discovered later:** a project whose hall is a *sibling*
|
|
79
|
+
of its site — site `www.example.com`, hall `builders.example.com` — loses the hall glyph on
|
|
80
|
+
the hub. The hall is still reachable and the project is otherwise unaffected; the hub simply
|
|
81
|
+
stops vouching for the address. That is [ADR 0253](<redacted>.md)
|
|
82
|
+
§2's asymmetry applied again — **narrowing is free; widening is what costs** — and the only
|
|
83
|
+
rule that would admit the sibling is the one that hands away the apex.
|
|
84
|
+
|
|
85
|
+
## 4. Rejected
|
|
86
|
+
|
|
87
|
+
- **Mirroring the check in the browser** (`modules/public-landing/public/projects.html`
|
|
88
|
+
already scheme-checks and re-applies the door test client-side). Rejected: the page is not
|
|
89
|
+
the gate ([ADR 0182](<redacted>.md) D2a — a client-side filter still
|
|
90
|
+
ships the value to anyone who opens a network tab), the server is now the only producer of
|
|
91
|
+
the field, and a second copy of a containment rule is precisely the drift this ADR exists
|
|
92
|
+
to end. The existing client-side scheme check stays as cheap defence in depth.
|
|
93
|
+
- **An allow-list of hall hosts per project.** A stored second address the owner would have
|
|
94
|
+
to maintain, to answer a question the origin already answers.
|
|
95
|
+
- **Keeping the rule private and copying it into `project-stats.js`.** The cheapest diff, and
|
|
96
|
+
it re-creates the defect's cause in a new place.
|
|
97
|
+
- **Truncating an over-long URL at the cap** (what `project-stats.js:httpUrl` does for an
|
|
98
|
+
`<img src>`). Fine for a display bound, wrong for a containment decision: the truncated
|
|
99
|
+
string still parses and addresses somewhere nobody reported.
|
|
100
|
+
- **Making the door test compare hostnames too**, which would fold both tests into one
|
|
101
|
+
comparison. It would silently drop a legitimate same-host, different-port hall — a
|
|
102
|
+
behaviour change this task was not asked for and that no security argument supports.
|
|
103
|
+
|
|
104
|
+
## 5. Consequences
|
|
105
|
+
|
|
106
|
+
- **A hall link on the hub can only ever address the project it is filed under.** The one
|
|
107
|
+
free-text URL a remote instance could put in front of hub visitors is now bounded by the
|
|
108
|
+
instance's own domain.
|
|
109
|
+
- **One rule, one file, two callers** (`sanitizeRecentShips` and `hallOrigin`). A third
|
|
110
|
+
surface that relays a third party's URL has somewhere to go, and a test file that already
|
|
111
|
+
states the attack matrix.
|
|
112
|
+
- **`shipUrlWithinClientOrigin` no longer exists by that name.** Its behaviour is unchanged
|
|
113
|
+
— the ship-link proofs pass untouched — but prose and ADRs citing the old name (including
|
|
114
|
+
ADR 0252 §5) now point at `origin-containment.js:urlWithinOrigin`.
|
|
115
|
+
- **One guard was added on the way in:** an origin with no host at all (`file:`, `data:` — a
|
|
116
|
+
stored value is not a validated one) is refused outright. The old suffix test would have
|
|
117
|
+
matched a legal trailing-dot FQDN such as `evil.example.` against the bare `.` such an
|
|
118
|
+
origin leaves behind.
|
|
119
|
+
- **A sibling-hosted hall loses its glyph on the hub** (§3). No project loses its hall.
|
package/docs/adr/README.md
CHANGED
|
@@ -341,6 +341,7 @@ This keeps the decision history honest and traceable.
|
|
|
341
341
|
| 0251 | [**A suggestion of who to invite is a recruiting surface, and owes the recruiting opt-out** ([task 1002347](https://cloudbongos.com/builders#/task/1002347) · goal 1000046, criterion `proj-invite-builders` / C6). BV1.R28 fills the seam [ADR 0239](<redacted>.md) §6 left open: the wizard’s pure rule engine emits a **seek** (`solo` → `null`, `small-team` → 3, `community` → 5) and never a list, because ranking builders is a directory read a pure file may not run and `provisioning` may not import a sibling. Two readers could answer “who are the top builders” and they differ by **one SQL predicate**: `economy`’s `leaderboard()` joins `rollupVisibleSql` and deliberately honours **no** recruiting opt-out, while `platform-identity`’s `listScoutingRollup()` joins `scoutingListableSql`, which carries it. The leaderboard is cheaper in every mechanical sense — already a public route, no port, callable straight from the browser. **Decision: use the recruiting directory anyway**, because `account-visibility.js` says the split in its own words (`hide_stats` suppresses *numbers* everywhere; the recruiting opt-out “removes an account from the RECRUITING SURFACE only”, community spec D3), and a list of people the platform proposes you recruit **is** that surface — sourcing it from the leaderboard would route around the one control built for this exact case and name every builder who asked not to be recruited. The rule for later readers: **the cheapness of a public read is not an argument about who consented to appear in it.** The lookup ships as `listInviteSuggestions` on the existing `platform-identity.hub` port, reached with `resolveOptional` and NOT declared in `consumes` (the `catalog-bridge.js` precedent — platform-identity is hub-only, so its absence is “no suggestions”, never a 500); `github_id` **does not cross the port** (explicit whitelist — login, display name, avatar, disciplines, two counts — so a new rollup column cannot leak by default), and the owner-exclusion is applied inside, over-read by the exclusion count so filtering never returns short. The route `GET /provisioning/instances/:id/invite-suggestions` is own-scoped by the **existing** `instanceAccessDenied` (same 404 as the manage read) and deliberately does NOT reuse `/scouting`’s gate ([ADR 0210](<redacted>.md)): a brand-new owner has no hub-witnessed owner row at the moment the done panel paints, so the correct-looking gate would refuse the exact caller the feature exists for — and reaching the route IS the ownership answer ([ADR 0249](<redacted>.md) §4). On the surface: a **second door in** (`setInviteSuggestions`) beside `setInviteSlug`, so the widget still answers neither “signed in” nor “theirs”; the reason is **always** said including when the answer is nobody (`seeking: null` costs no query — rendering a stated answer as an empty list would make it look like a failed lookup); a picked name fills the field and takes the **same write path** as a typed one, so the refusal vocabulary cannot fork; a filed invite stops being a suggestion; and every failure is silent, because a suggestion is an extra and a just-created project must not look broken over one. Rejected: the browser reading the economy leaderboard (the whole decision — consent, not architecture); reusing `/scouting` (right data, gate that refuses this caller); filling `invites.suggested` inside the engine (I/O in a pure file + a sibling import); a `provisioning → economy` port (legal, still the wrong directory — the boundary was never the objection).](<redacted>.md) | provisioning / platform-identity / privacy |
|
|
342
342
|
| 0252 | [**The self-hosted visibility report is a PULL, and auto-appear buys a retraction lever** ([task 1002321](https://cloudbongos.com/builders#/task/1002321) · goal 1000046, criterion `proj-visibility-states` / C1, BV1.R02 — a spike whose output is this decision; gates [task 1002327](https://cloudbongos.com/builders#/task/1002327) / R08). **The premise failed first:** a self-hosted instance has no channel to the hub and no credential to open one — `registerClient` has exactly one caller in the tree (the operator's own `provision-net.js`), gated `hosting_shape !== 'co-tenant'`, and no route registers a client. So *federated* ⊆ *operator-provisioned co-tenant* and *self-hosted* holds nothing, deliberately: [ADR 0145](<redacted>.md) says do NOT extend federation to `standalone`, and [ADR 0144](<redacted>.md)'s Known Limitation rests on "every secret-holder is an operator-provisioned, trusted instance … the proper fix must land before federation opens to untrusted third-party clients." [ADR 0182](<redacted>.md) D5's "accepts a report from the authenticated `sso_client`" described a client that, for this population, does not exist. **Decision 1 — the channel is a PULL of the instance's already-public manifest** (`GET <origin>/api/gds/instance` → `branding.project.visibility`, a value every core already serves and whose author anticipated this consumer). It buys five things a push must build: no credential (0144's tripwire untouched — the whole reason), **no replay surface** (a pull reads current state, so the `jti` machinery D5 handed R02 is moot — replay is answered by not having a message), no wire change on another instance's upgrade clock ([ADR 0205](<redacted>.md)'s and [ADR 0201](<redacted>.md) D2.2's rejected-required-field problem simply absent), the hub still authorising nothing (0182 D5 satisfied, [ADR 0238](<redacted>.md)'s rejected `PATCH /projects/:id/visibility` still rejected), and a fetch that already exists behind a real SSRF guard (`project-stats.js` — DNS-resolve-all, `redirect: 'error'`, TTL + negative caches). The pull carries `visibility` and NOTHING else, so the property the map has today — a project owner controls no free-text field that reaches the feed — is not spent. **Decision 2 (the Archon's) — auto-appear:** no human stands between a public project and the map; `featured` keeps only the ranking job [ADR 0182](<redacted>.md) D3 left it. Conditional on Decision 4. **Decision 3 (the Archon's) — trust follows provenance:** both populations auto-appear, and the asymmetry is in the GUARDS, not a gate — a control-plane row's `visibility` is *assigned* by the bridge from an own-scoped audited route (authoritative), a self-hosted row's is *pulled* from a manifest the hub does not control (a cached claim with a recency stamp, able to move its own row and nothing else). An instance is authoritative about its own door and about nothing else. **Absent is a THIRD state and must not purge:** `null` (never read) ≠ `false` (read, negative). 0182 D5's fail-closed default holds only for a self-hosted row that has NEVER answered; a row that answered before and is unreachable now keeps its last state with a recency stamp ([ADR 0199](<redacted>.md), [ADR 0226](<redacted>.md)), because [ADR 0247](<redacted>.md) D4 found the live shape of that trap — every catalog row at `description: null`, so naive fail-closed would have emptied the map at once. Narrowing is free; widening is what costs. **Decision 4 — auto-appear buys a retraction lever, in the same task** ([ADR 0047](<redacted>.md): the straight-through path and its abuse guard are one task, treated as binding): `platform_identity_projects.status` is `(active,hidden)` and **no route writes it** — takedown is hand SQL today, and a one-way door onto a public surface is not a door. R08 ships `PATCH /projects/:id/status` (curate floor, audited), a per-IP limit on the unmetered CORS-open feed read ([ADR 0209](<redacted>.md)), and above all **`hidden` STICKY against the pull** — a retracted project that keeps reporting `public` stays hidden, or the next sweep silently undoes every takedown and the lever is decorative. Also named: `hall_origin` reaches the feed from the remote's own discovery index checked only for *differing* from the project host — the opposite of containment — and gets `shipUrlWithinClientOrigin`'s downward-only rule, filed against R08. Rejected: a pushed credentialed report (the literal R08 — needs the credentials 0144/0145 forbid, plus replay, rate and a wire-compatible field, none of which the pull has); `PATCH /projects/:id/visibility` (0238 already); a one-time admission and ongoing curation (both offered to the Archon, both declined); trusting the pull to move `status`; enforcing "unreported ⇒ dark matter" across all rows; letting the pull carry name/tagline/art. The later ANALYTICS feed is deliberately NOT decided here — it carries per-person data, exactly what [ADR 0205](<redacted>.md) proved cannot ride an unwitnessed self-report, and it needs the 0144 credential work first.](<redacted>.md) | platform-identity / provisioning / federation trust |
|
|
343
343
|
| 0253 | [**The takedown lever has two writers, and provenance has to be a column** ([task 1002327](https://cloudbongos.com/builders#/task/1002327) · goal 1000046, criterion `proj-visibility-states` / C1, BV1.R08 — the build). Amends [ADR 0252](<redacted>.md) §4/§5: three premises that did not survive contact with the tree. The decisions stand; what changes is what has to be BUILT. **(1) `hidden` is sticky against TWO writers, and 0252 names the wrong one.** 0252 §5 says "no route writes [`status`]" and defends the takedown against the PULL. But `project-catalog.js:upsertOwnedProject` writes `status = EXCLUDED.status` on every conflict, sourced from `catalog-bridge.js:catalogStatusForInstance` — which returns `active` for any instance not torn down — and [ADR 0246](<redacted>.md) re-takes that projection at EVERY provisioning write. So the live failure is: curator hides a project → its owner saves any setting → it is back on the map. Stickiness therefore belongs to the ROW (`status_pinned`, set by the curated PATCH, honoured by the projection), not to one writer's manners; the pull separately writes no `status` at all, asserted against the statement text. `retireOwnedProject` deliberately does NOT honour the pin — it is a safety retraction for an origin the project no longer answers on ([ADR 0181](0181-teardown-state-gate-and-address-retention.md) §2), and narrowing is always free. **(2) Provenance must be a stored column.** 0252 §4 tabulates control-plane vs self-hosted rows as though the catalog knew which was which; it has no `hosting_shape` (that lives on `provisioning_instances`, in a module platform-identity may not import — [ADR 0083](<redacted>.md)) and `owner_github_id` separates nothing (set by the bridge, set by `registerClient`, null on hand-seeded rows). `visibility_source` (`control-plane`|`pull`|NULL) records it on the row, re-asserted in the UPDATE's own WHERE so a mid-sweep projection cannot lose the race — making "the two never write the same row" an invariant the SQL enforces. Rejected a `provisioning.instanceShape` port: a cross-module read in a background loop, for a value that never changes per row. **(3) The tri-state cannot live in `visibility`** (`NOT NULL DEFAULT 'public'`), so the recency stamp IS the null-carrier: `visibility_reported_at IS NULL` is the never-read state, and a failed read writes nothing — which is what makes "keep the last known state" fall out rather than be implemented. The fail-closed feed predicate needed a BACKFILL for [ADR 0247](<redacted>.md) D4's exact reason: both columns are new, so unbackfilled it would have emptied the public map on deploy. Migration 020 stamps every existing row `control-plane`, which is safe because it is TRUE — no instance has ever been asked. Two smaller corrections: the manifest key is **`brand`**, not `branding` (0252 §2 and the task body both misname it; reading only the documented spelling makes every pull a silent no-verdict), and the pull is a **POLLER, not a passenger on the feed's fan-out** — the feed excludes exactly the rows that need pulling (a never-answered row is dark matter, so it is never enriched, so it never answers), stealth rows are excluded from enrichment by design, and a read-path pull would make a DB write a side effect of an anonymous CORS-open read. Honest limit recorded: 0247 D4's `description: null` problem is NOT fixed here — 0252 §7 refuses to let the pull carry name/tagline/description, so the publish gate stays unmet for self-hosted rows. The pull moves the door; it does not publish the project.](<redacted>.md) | platform-identity / provisioning / abuse guard |
|
|
344
|
+
| 0254 | [**A relayed link is contained downward, or it is not published** ([task 1003670](https://cloudbongos.com/builders#/task/1003670) · goal 1000046, criterion `proj-visibility-states` / C1) — the gap [ADR 0252](<redacted>.md) §5 named and filed. **The defect:** `hall_origin` reaches the public projects feed from the REMOTE instance’s own discovery index (`GET <origin>/api/bongos` → `index.hall`, `project-stats.js`) and the hub renders it as an `<a href>` under that project’s name — checked only for **differing** from the project’s site host, which is containment turned inside out: it admits every host on the internet and refuses the one that is certainly safe. `{"hall": "https://evil.example/builders"}` was published on the hub’s own domain, attached to the victim. The rule answered a DIFFERENT question correctly ([task 1003257](https://cloudbongos.com/builders#/task/1003257), owner decision (a): *a door only where it opens somewhere else*); nobody had asked it the security one. It stopped being survivable when [ADR 0252](<redacted>.md) §3 replaced “whom the operator provisioned” with “whoever points a domain at the hub”. **Decision:** the same **downward-only** rule a pushed ship link already obeys — a relayed URL must point AT OR BELOW the origin that reported it (own host or a **dot-anchored** subdomain; `http(s)` only; no embedded credentials; **refused whole at the length cap, never truncated**, because slicing a URL changes where it points). Containment runs FIRST and compares **hostnames** (a port is not a trust boundary); the unchanged “different door” test runs second and compares **hosts with the port** (a hall on another port really does open elsewhere). Net: a hall link survives only as a strict subdomain — or another port — of the project’s own origin. **One home:** `shipUrlWithinClientOrigin` (a private helper in `platform-identity.js`) is lifted to `origin-containment.js:urlWithinOrigin` and required by BOTH callers — the defect WAS two surfaces answering one question differently, and leaving the answer where only one could reach it is what let them differ. Its own file for the reason its siblings give: `platform-identity.js` is AT the 1500-line ratchet (the lift shrinks it by 11), and deliberately NOT re-exported there — a re-export is a third name for one answer. **Downward only, and the cost stated:** the mirror rule looks symmetric and is not (on a shared apex it lets `foo.github.io` publish links on bare `github.io`, and vouch for every neighbour), so a project whose hall is a SIBLING of its site (site `www.example.com`, hall `builders.example.com`) loses the glyph — the hall stays reachable, the hub stops vouching for the address. [ADR 0253](<redacted>.md) §2’s asymmetry again: **narrowing is free; widening is what costs**. One guard added on the way in: a hostless origin (`file:`, `data:` — a stored value is not a validated one) is refused outright, because the old suffix test would have matched a legal trailing-dot FQDN like `evil.example.` against the bare `.` it leaves behind. **Rejected:** mirroring the check in the browser ([ADR 0182](<redacted>.md) D2a — the page is not the gate, and a second copy of a containment rule is the drift this ends); a per-project allow-list of hall hosts; copying the rule into `project-stats.js` (the cheapest diff, and it re-creates the cause); truncating at the cap (fine for an `<img src>` display bound, wrong for a containment decision); folding both tests into one hostname comparison (it would silently drop a legitimate same-host, different-port hall).](<redacted>.md) | platform-identity / public feed / link containment |
|
|
344
345
|
|
|
345
346
|
| 0246 | [**Catalog membership is a projection of the provisioning row, not a side effect of federation** ([task 1003576](https://cloudbongos.com/builders#/task/1003576) · goal 1000046, criterion `proj-detail-interview` / C3). The BV1.R18 proof ([task 1002337](https://cloudbongos.com/builders#/task/1002337)) demonstrated C3 against live and found its last clause — “then it publishes” — structurally false: the owner’s verdict reads `provisioning_instances` while the public map reads `platform_identity_projects`, and the ONLY bridge was `federateInstance → registerClient → upsertProject`, which `shouldFederate` refuses for anything but `co-tenant` (the wizard hard-codes `standalone`) and which runs ONCE, at provision time, through a COALESCE. So a wizard-made project was never catalogued at all and a co-tenant one that answered later never updated — `publishable: true`, `{"projects": []}`, forever. The same missing row is [task 1003580](https://cloudbongos.com/builders#/task/1003580)’s GAP 1: `POST /projects/invite` resolves against `platform_identity_sso_clients`, whose only writer is that same `registerClient`. Underneath is one conflation — federation mints CREDENTIALS and was incidentally the only thing that minted MEMBERSHIP, while [ADR 0182](<redacted>.md) D4 already fixed the map’s gate at IDENTITY, which has nothing to do with hosting topology. **Decision: the catalog row is a PROJECTION of the provisioning row, re-derived at every write that can change its inputs** — two halves, neither sufficient alone. (1) THE ROW SHAPE: every control-plane project gets both rows whatever its hosting shape, and the client row carries NO credential (`secret_hash = ''`, `redirect_uris = '{}'`), so **the shape is the enforcement** rather than a flag a caller could forget — `redirectUriAllowed` refuses `/sso/authorize`, `clientSecretValid` refuses `/sso/token`, `/sso/membership/check-in` and `/sso/activity/rollup`, and the one leg that checked NEITHER (`/sso/device/start`, which resolves a client from a user-typed origin) grew an explicit `clientIsCredentialed` guard. (2) THE TIMING: re-taken on create, on `PATCH …/detail`, on `PATCH …/settings` when the door changes, and on `POST …/domain` (which re-keys the row, retiring the abandoned origin — [ADR 0181](0181-teardown-state-gate-and-address-retention.md) §2’s leak, in the catalog instead of DNS). **NO MIGRATION** — `secret_hash` is `NOT NULL` and `''` satisfies it while hashing to nothing. `description` and `visibility` are ASSIGNED, not COALESCEd (clearing a description must UN-publish); `featured`/`tagline`/`art_url`/`template` are curation and stay untouched, so [ADR 0182](<redacted>.md) D3 holds. It grants nothing — the row is attribution per [ADR 0238](<redacted>.md), ownership stays `provisioning_instances.owner_builder_id` — and because it writes rows keyed on owner-supplied values (a slug, a BYO domain) each write carries its **claim rule in the ON CONFLICT action’s own WHERE**: the client is refreshed only `WHERE secret_hash = ''` (a real client’s origin, which binds the CLI mint audience, can never be rewritten) and the project only `WHERE owner_github_id = EXCLUDED.owner_github_id` (`NULL = anything` is not true, so the hand-seeded rows cannot be claimed). Writing `visibility` does not contradict [ADR 0182](<redacted>.md) D5: the hub authorizes nothing — the authority already ran on the provisioning plane ([ADR 0238](<redacted>.md)’s own-scoped, audited PATCH), and a project the control plane does NOT own has no provisioning row, so D5’s instance report (R08) stays the only channel for those. `tearing_down`/`torn_down` project `hidden`; a `no_address` instance is not catalogued at all. Travels on the kernel port `project.catalog`, the `project.publishGate` seam in the other direction ([ADR 0083](<redacted>.md) forbids the import). Rejected: extending `shouldFederate` to every shape (hands a real secret to instances whose web.env the platform does not write, and keeps the once-at-standup bug); bridging only at the publish moment (leaves the invite with nothing to resolve); registering only at standup (the bug itself); a `catalog_only` boolean column (needs a migration and is weaker than the shape); letting the bridge write `featured`; and backfilling the three existing catalog rows from code.](<redacted>.md) | provisioning / project creation |
|
|
346
347
|
|
|
@@ -1579,5 +1579,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1579
1579
|
landed since 1.19.563 with no explicit bump. run 34052578659. (task 1002620)
|
|
1580
1580
|
1.19.565 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1581
1581
|
landed since 1.19.564 with no explicit bump. run 34055981223. (task 1002620)
|
|
1582
|
+
1.19.566 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1583
|
+
landed since 1.19.565 with no explicit bump. run 34056809932. (task 1002620)
|
|
1584
|
+
1.19.567 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1585
|
+
landed since 1.19.566 with no explicit bump. run 34057551680. (task 1002620)
|
|
1582
1586
|
---------------------------------------------------------------------------
|
|
1583
1587
|
```
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// modules/platform-identity/origin-containment.js — THE rule deciding whether a URL a
|
|
2
|
+
// REMOTE instance handed us may be published under that instance's name: it has to point
|
|
3
|
+
// AT OR BELOW the origin that reported it. One question, one file, so the two surfaces
|
|
4
|
+
// that ask it cannot answer it differently (the publish-gate.js precedent).
|
|
5
|
+
//
|
|
6
|
+
// TWO CALLERS, which is why this stopped being a private helper (task 1003670, ADR 0254):
|
|
7
|
+
// • a pushed ship link on a builder's public portfolio — platform-identity.js
|
|
8
|
+
// `sanitizeRecentShips`, bounded by the AUTHENTICATED client's registered origin
|
|
9
|
+
// • `hall_origin` on the public projects feed — project-stats.js `hallOrigin`, bounded
|
|
10
|
+
// by the project's own catalog origin
|
|
11
|
+
// The second arrived WITHOUT the rule: it checked only that the advertised hall host
|
|
12
|
+
// DIFFERED from the project's site host, which is the exact opposite of containment — it
|
|
13
|
+
// admits every host on the internet except the one that is certainly safe. A remote
|
|
14
|
+
// instance answering `GET /api/bongos` with `hall: "https://evil.example"` had that link
|
|
15
|
+
// relayed into an `<a href>` on the hub, under the victim project's name.
|
|
16
|
+
//
|
|
17
|
+
// WHY IT GOT URGENT. [ADR 0252](../../docs/adr/0252-the-self-hosted-visibility-report-is-a-pull-and-auto-appear-buys-a-retraction-lever.md)
|
|
18
|
+
// §5 named this gap in the same breath as deciding that a self-hosted project
|
|
19
|
+
// AUTO-APPEARS on the map. Under control-plane provisioning the origin set was bounded by
|
|
20
|
+
// whom the operator had provisioned; auto-appear replaces that bound with "whoever points
|
|
21
|
+
// a domain at the hub", which turns a curated-origin risk into a phishing hop anyone can
|
|
22
|
+
// mint. Containment is what makes the relayed link mean something again: a hall link on
|
|
23
|
+
// the hub can only ever address the project it is filed under.
|
|
24
|
+
//
|
|
25
|
+
// DOWNWARD ONLY, deliberately. The mirror rule ("the link host is an ANCESTOR of the
|
|
26
|
+
// reporting origin") looks symmetric and is not: on a shared multi-tenant apex it would
|
|
27
|
+
// let an instance registered at foo.github.io publish links on bare github.io, which it
|
|
28
|
+
// does not control. Nothing needs the upward direction — a reporting origin is the
|
|
29
|
+
// instance's own public origin — so it is not offered.
|
|
30
|
+
//
|
|
31
|
+
// WHY ITS OWN FILE. The rule could not simply be shared where it sat: `platform-identity.js`
|
|
32
|
+
// is AT its file-size ratchet (scripts/gds/fitness-ratchets.js, 1500 lines, no headroom)
|
|
33
|
+
// and `project-stats.js` is a different file besides — the account-visibility.js /
|
|
34
|
+
// publish-gate.js / projects-feed.js precedent, taken here for the same reason. It is
|
|
35
|
+
// deliberately NOT re-exported from `platform-identity.js`: both callers require it
|
|
36
|
+
// directly, so there is exactly one name for one answer.
|
|
37
|
+
'use strict';
|
|
38
|
+
|
|
39
|
+
// A relayed URL is REFUSED at the cap, never truncated. Slicing a URL changes where it
|
|
40
|
+
// points, and a shortened one that still parses is a worse answer than no link at all —
|
|
41
|
+
// which is why this is a bound and not a `slice()` (contrast `project-stats.js:httpUrl`,
|
|
42
|
+
// whose cap is a display bound on an <img src>, not a containment decision).
|
|
43
|
+
const MAX_CONTAINED_URL_LENGTH = 500;
|
|
44
|
+
|
|
45
|
+
// urlWithinOrigin — `raw` when it is an http(s) URL at or below `origin`'s host, else
|
|
46
|
+
// null. Every refusal is a null: never a throw, never a repaired value. The callers
|
|
47
|
+
// publish the link exactly as reported, or they publish nothing.
|
|
48
|
+
//
|
|
49
|
+
// Each check earns its place, and each is a real reported attack shape:
|
|
50
|
+
// • http(s) only — a `javascript:` href relayed into an anchor is stored XSS, and the
|
|
51
|
+
// hub's clients scheme-check again only because defence in depth is cheap.
|
|
52
|
+
// • no username/password — `https://cloudbongos.com@evil.example/x` reads as the
|
|
53
|
+
// victim's domain to a human and resolves to the attacker's; the host check alone
|
|
54
|
+
// passes it, because the host IS evil.example.
|
|
55
|
+
// • host equals the registered host, or is a DOT-ANCHORED suffix of it. The dot is
|
|
56
|
+
// load-bearing: without it `notcloudbongos.com` ends with `cloudbongos.com`, and
|
|
57
|
+
// every lookalike domain contains the domain it imitates.
|
|
58
|
+
//
|
|
59
|
+
// `max` is a parameter because the two callers carry different display bounds; the
|
|
60
|
+
// default is the ship-link cap this rule was lifted from, so that call site is unchanged.
|
|
61
|
+
function urlWithinOrigin(raw, origin, max = MAX_CONTAINED_URL_LENGTH) {
|
|
62
|
+
if (typeof raw !== 'string' || !raw || raw.length > max) return null;
|
|
63
|
+
let u, o;
|
|
64
|
+
try { u = new URL(raw); o = new URL(String(origin || '')); } catch { return null; }
|
|
65
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
|
|
66
|
+
if (u.username || u.password) return null;
|
|
67
|
+
const host = u.hostname.toLowerCase(), registered = o.hostname.toLowerCase();
|
|
68
|
+
// An origin with no host at all (`file:`, `data:` — a stored value is not a validated
|
|
69
|
+
// one) leaves `registered` empty, and `.` is then the whole suffix: a trailing-dot
|
|
70
|
+
// FQDN like `evil.example.` is legal in a URL and WOULD match it. Refuse first, so a
|
|
71
|
+
// hostless origin can never be the thing that authorises a link.
|
|
72
|
+
if (!registered) return null;
|
|
73
|
+
if (host !== registered && !host.endsWith(`.${registered}`)) return null;
|
|
74
|
+
return raw;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { urlWithinOrigin };
|
|
@@ -19,6 +19,11 @@ const api = require('../../src/module-api');
|
|
|
19
19
|
// re-derives one (see account-visibility.js).
|
|
20
20
|
const visibility = require('./account-visibility');
|
|
21
21
|
const terms = require('./terms-acceptance');
|
|
22
|
+
// THE downward-only containment rule for a URL a remote instance reported (task
|
|
23
|
+
// 1003670, ADR 0254). Lifted out of this file so the projects feed's `hall_origin`
|
|
24
|
+
// reads the SAME rule as a ship link instead of the "different host" check it had —
|
|
25
|
+
// see origin-containment.js for why it is downward only and why it lives there.
|
|
26
|
+
const { urlWithinOrigin } = require('./origin-containment');
|
|
22
27
|
|
|
23
28
|
const { pool: defaultPool } = api;
|
|
24
29
|
const { ACCOUNT_VISIBILITY, accountVisibleSql, rollupVisibleSql, isRollupVisible, scoutingListableSql } = visibility;
|
|
@@ -614,37 +619,12 @@ const MAX_DISCIPLINE_LENGTH = 32;
|
|
|
614
619
|
// buggy or hostile.
|
|
615
620
|
const MAX_RECENT_SHIPS = 3;
|
|
616
621
|
const MAX_SHIP_TITLE_LENGTH = 200;
|
|
617
|
-
const MAX_SHIP_URL_LENGTH = 500;
|
|
618
622
|
// How far into a pushed array the hub will even LOOK. Validating first and
|
|
619
623
|
// capping second is what stops a few malformed entries from crowding the good
|
|
620
624
|
// ones out of the three slots; bounding the scan is what stops an unbounded
|
|
621
625
|
// array from buying unbounded work with one request.
|
|
622
626
|
const MAX_RECENT_SHIPS_SCANNED = 20;
|
|
623
627
|
|
|
624
|
-
// May this ship link be stored? A ship link is the ONLY free-text URL on a public
|
|
625
|
-
// builder portfolio, which makes it the one place a client-secret holder could
|
|
626
|
-
// plant a phishing hop under someone else's name. So it must point AT OR BELOW
|
|
627
|
-
// the instance that reported it: the client's own REGISTERED origin host, or a
|
|
628
|
-
// subdomain of it (an instance's hall may live at builders.<domain> while its
|
|
629
|
-
// registered origin is <domain>). Anything else — another site, a non-http
|
|
630
|
-
// scheme, embedded credentials — stores no link rather than a wrong one.
|
|
631
|
-
//
|
|
632
|
-
// DOWNWARD ONLY, deliberately. The mirror rule ("the link host is an ANCESTOR of
|
|
633
|
-
// the registered origin") looks symmetric and is not: on a shared multi-tenant
|
|
634
|
-
// apex it would let a client registered at foo.github.io publish links on bare
|
|
635
|
-
// github.io, which it does not control. Nothing needs the upward direction — a
|
|
636
|
-
// registered origin is the instance's own public origin — so it is not offered.
|
|
637
|
-
function shipUrlWithinClientOrigin(raw, clientOrigin) {
|
|
638
|
-
if (typeof raw !== 'string' || !raw || raw.length > MAX_SHIP_URL_LENGTH) return null;
|
|
639
|
-
let u, o;
|
|
640
|
-
try { u = new URL(raw); o = new URL(String(clientOrigin || '')); } catch { return null; }
|
|
641
|
-
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
|
|
642
|
-
if (u.username || u.password) return null;
|
|
643
|
-
const host = u.hostname.toLowerCase(), registered = o.hostname.toLowerCase();
|
|
644
|
-
if (host !== registered && !host.endsWith(`.${registered}`)) return null;
|
|
645
|
-
return raw;
|
|
646
|
-
}
|
|
647
|
-
|
|
648
628
|
// Shape and bound the pushed recent_ships[] before it is stored (the
|
|
649
629
|
// upsertActivity rank/disciplines precedent, ADR 0171 D7 — validation lives next
|
|
650
630
|
// to the data where no route can forget it). Returns AT MOST MAX_RECENT_SHIPS
|
|
@@ -652,6 +632,15 @@ function shipUrlWithinClientOrigin(raw, clientOrigin) {
|
|
|
652
632
|
// title is dropped entirely rather than stored half-empty, and a bad date or an
|
|
653
633
|
// off-origin link becomes null on an otherwise good row. Always an array, never
|
|
654
634
|
// null — every reader gets a list without a coalesce.
|
|
635
|
+
//
|
|
636
|
+
// THE LINK IS THE DANGEROUS FIELD. A ship link is the only free-text URL on a public
|
|
637
|
+
// builder portfolio, so it is the one place a client-secret holder could plant a
|
|
638
|
+
// phishing hop under someone else's name. `urlWithinOrigin` is what stops that — it
|
|
639
|
+
// keeps a link only where it points AT OR BELOW the reporting client's registered
|
|
640
|
+
// origin (its own host, or a subdomain: an instance's hall may live at
|
|
641
|
+
// builders.<domain> while its registered origin is <domain>). The rule now lives in
|
|
642
|
+
// origin-containment.js because the projects feed's `hall_origin` needs the identical
|
|
643
|
+
// answer and used to give a different one (task 1003670, ADR 0254).
|
|
655
644
|
function sanitizeRecentShips(raw, clientOrigin) {
|
|
656
645
|
return (Array.isArray(raw) ? raw : [])
|
|
657
646
|
.slice(0, MAX_RECENT_SHIPS_SCANNED)
|
|
@@ -663,7 +652,7 @@ function sanitizeRecentShips(raw, clientOrigin) {
|
|
|
663
652
|
id: Number.isSafeInteger(id) && id > 0 ? id : null,
|
|
664
653
|
title: typeof s.title === 'string' && s.title.trim() ? s.title.slice(0, MAX_SHIP_TITLE_LENGTH) : null,
|
|
665
654
|
shipped_at: Number.isFinite(at) ? new Date(at).toISOString() : null,
|
|
666
|
-
url:
|
|
655
|
+
url: urlWithinOrigin(s.url, clientOrigin),
|
|
667
656
|
};
|
|
668
657
|
})
|
|
669
658
|
.filter((s) => s.id !== null && s.title !== null)
|
|
@@ -674,7 +663,7 @@ function sanitizeRecentShips(raw, clientOrigin) {
|
|
|
674
663
|
// Descriptive analytics — never authority. Refreshes last_active on each report.
|
|
675
664
|
// `clientOrigin` is the AUTHENTICATED client's registered origin (never a
|
|
676
665
|
// caller-named one) and exists only to bound the ship links — see
|
|
677
|
-
//
|
|
666
|
+
// origin-containment.js:urlWithinOrigin.
|
|
678
667
|
async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped = 0, karma = 0, rank = null, disciplines = [], recentShips = [], clientOrigin = null }, { pool = defaultPool } = {}) {
|
|
679
668
|
// api.LIVE_RANK_LADDER is a published doorway export (src/module-api.js, a
|
|
680
669
|
// lazy getter over db-kernel's canonical four-rank ladder).
|
|
@@ -30,6 +30,12 @@ const fs = require('node:fs');
|
|
|
30
30
|
const path = require('node:path');
|
|
31
31
|
const dns = require('node:dns').promises;
|
|
32
32
|
const net = require('node:net');
|
|
33
|
+
// THE downward-only containment rule for a URL a remote instance reported (task
|
|
34
|
+
// 1003670, ADR 0254). `hall_origin` is the only value in this file that becomes an
|
|
35
|
+
// `<a href>` on the hub rather than an `<img src>`, so it is the only one that has to
|
|
36
|
+
// prove it belongs to the project it is filed under; the ship-link path in
|
|
37
|
+
// platform-identity.js reads the identical rule from the identical place.
|
|
38
|
+
const { urlWithinOrigin } = require('./origin-containment');
|
|
33
39
|
|
|
34
40
|
const TTL_MS = 10 * 60 * 1000; // owner's choice, 2026-08-13
|
|
35
41
|
const FETCH_TIMEOUT_MS = 2500; // per request
|
|
@@ -209,8 +215,34 @@ function shippedStats(body) {
|
|
|
209
215
|
// <site>/builders, where the site's own door already leads) and null when the
|
|
210
216
|
// project does not say. Task 1003257, owner decision (a): a door only where it
|
|
211
217
|
// opens somewhere else.
|
|
218
|
+
//
|
|
219
|
+
// TWO TESTS, and the first one is the security one (task 1003670, ADR 0254). This
|
|
220
|
+
// value comes out of a REMOTE instance's own discovery index and the hub relays it
|
|
221
|
+
// into an `<a href>` under that project's name, so it must be CONTAINED — at or
|
|
222
|
+
// below the project's own origin — before it may differ from anything. The check
|
|
223
|
+
// used to be "differs from the site host" alone, which is containment inverted: it
|
|
224
|
+
// admitted every host on the internet except the one that was certainly safe, so a
|
|
225
|
+
// hostile or compromised instance could publish `hall: "https://evil.example"` and
|
|
226
|
+
// have the hub link it. [ADR 0252](../../docs/adr/0252-the-self-hosted-visibility-report-is-a-pull-and-auto-appear-buys-a-retraction-lever.md)
|
|
227
|
+
// §5 named the gap when it decided self-hosted projects AUTO-APPEAR: the origin set
|
|
228
|
+
// stopped being bounded by whom the operator provisioned.
|
|
229
|
+
//
|
|
230
|
+
// Containment first (`urlWithinOrigin` — the SAME rule a pushed ship link obeys,
|
|
231
|
+
// http(s)-only, no embedded credentials, dot-anchored suffix, refused rather than
|
|
232
|
+
// truncated at the cap), and only then the door test. The door test still compares
|
|
233
|
+
// `.host` WITH the port, because a hall on another port of the same machine really
|
|
234
|
+
// does open somewhere else; containment compares hostnames, because a port is not a
|
|
235
|
+
// trust boundary. Net effect: a hall link survives only as a strict SUBDOMAIN (or
|
|
236
|
+
// another port) of the project's own origin.
|
|
237
|
+
//
|
|
238
|
+
// The cost, stated so it is not mistaken for a bug: a project whose hall is a
|
|
239
|
+
// SIBLING of its site (site www.example.com, hall builders.example.com) loses the
|
|
240
|
+
// glyph. The hall is still reachable; the hub just stops vouching for the address.
|
|
241
|
+
// That is the corpus's standing asymmetry — narrowing is free, widening is what
|
|
242
|
+
// costs — and the upward rule that would admit it is the one origin-containment.js
|
|
243
|
+
// refuses to offer, because on a shared apex it hands away every neighbour.
|
|
212
244
|
function hallOrigin(index, origin) {
|
|
213
|
-
const hall =
|
|
245
|
+
const hall = urlWithinOrigin(index?.hall, origin, URL_MAX);
|
|
214
246
|
if (!hall) return null;
|
|
215
247
|
try {
|
|
216
248
|
return new URL(hall).host.toLowerCase() === siteUrl(origin).host.toLowerCase() ? null : hall;
|
|
@@ -106,6 +106,10 @@ module.exports = function myProjectsRoutes() {
|
|
|
106
106
|
// project's API discovery index through the same cached enrichment the
|
|
107
107
|
// catalog uses (no hub table stores it). Best-effort: a project that does
|
|
108
108
|
// not answer gets null, never a failed listing.
|
|
109
|
+
// The address is CONTAINED to the project's own origin before it ever
|
|
110
|
+
// reaches this list (project-stats.js:hallOrigin, task 1003670 / ADR 0254)
|
|
111
|
+
// — a remote instance may advertise any hall it likes, and this route
|
|
112
|
+
// renders one into an href just as the public feed does.
|
|
109
113
|
let stats = {};
|
|
110
114
|
try {
|
|
111
115
|
stats = await projectStats.getProjectStats(rows.map((m) => m.project_origin).filter(Boolean));
|