@bongos/core 1.19.566 → 1.19.568
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 +42 -22
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0254-a-relayed-link-is-contained-downward-or-it-is-not-published.md +119 -0
- package/docs/adr/0255-a-public-lists-ordering-is-part-of-its-payload.md +169 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +7 -3
- package/docs/module-api-changelog.md +4 -0
- package/modules/platform-identity/origin-containment.js +77 -0
- package/modules/platform-identity/platform-identity.js +18 -28
- package/modules/platform-identity/project-stats.js +33 -1
- package/modules/platform-identity/projects-feed.js +126 -20
- package/modules/platform-identity/routes/my-projects.js +4 -0
- package/modules/platform-identity/routes/projects.js +29 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/origin_containment.mjs +114 -0
- package/tests/project_stats.mjs +82 -14
- package/tests/visible_projects_feed.mjs +184 -13
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.568",
|
|
6
|
+
"core_contract": "1.19.568",
|
|
7
|
+
"source_commit": "80d5678233ede0a618699eacc63a9a06a0a2a510",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-06T20:
|
|
9
|
+
"built_at": "2026-09-06T20:30:33.304Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 448,
|
|
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": 2509,
|
|
20
|
+
"tree_sha256": "904d5ba5cfe93e9add9a20263710481eb5b6619d9997eae48c9b22c1fc0eee00",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -392,7 +392,7 @@
|
|
|
392
392
|
{
|
|
393
393
|
"path": "clients/bongos-client/index.d.ts",
|
|
394
394
|
"mode": "0000644",
|
|
395
|
-
"sha256": "
|
|
395
|
+
"sha256": "2b37686507b5ea701c19a1086352a2f0c84eae46510ef12b3a601c18812610c6"
|
|
396
396
|
},
|
|
397
397
|
{
|
|
398
398
|
"path": "clients/bongos-client/index.mjs",
|
|
@@ -1784,10 +1784,20 @@
|
|
|
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
|
+
},
|
|
1792
|
+
{
|
|
1793
|
+
"path": "docs/adr/0255-a-public-lists-ordering-is-part-of-its-payload.md",
|
|
1794
|
+
"mode": "0000644",
|
|
1795
|
+
"sha256": "2c82050a733a0a82dde5079bbc486fc3378b2be954817a811aab92b9e46c9c5b"
|
|
1796
|
+
},
|
|
1787
1797
|
{
|
|
1788
1798
|
"path": "docs/adr/README.md",
|
|
1789
1799
|
"mode": "0000644",
|
|
1790
|
-
"sha256": "
|
|
1800
|
+
"sha256": "44acc139af799118137f253f6eb2a43863b5b33edee05716e073cf4aa8a43859"
|
|
1791
1801
|
},
|
|
1792
1802
|
{
|
|
1793
1803
|
"path": "docs/api-reference.md",
|
|
@@ -1797,7 +1807,7 @@
|
|
|
1797
1807
|
{
|
|
1798
1808
|
"path": "docs/api/openapi.json",
|
|
1799
1809
|
"mode": "0000644",
|
|
1800
|
-
"sha256": "
|
|
1810
|
+
"sha256": "9148616a367b2f7486a8f14c0f7ecf9ed367bd552075b15c4f2b0f3dd40712db"
|
|
1801
1811
|
},
|
|
1802
1812
|
{
|
|
1803
1813
|
"path": "docs/architecture.md",
|
|
@@ -2672,7 +2682,7 @@
|
|
|
2672
2682
|
{
|
|
2673
2683
|
"path": "docs/module-api-changelog.md",
|
|
2674
2684
|
"mode": "0000644",
|
|
2675
|
-
"sha256": "
|
|
2685
|
+
"sha256": "6c7437c85c872c6cece307ea499b855f6fa6b54ce91059168a255c70edf7b5b9"
|
|
2676
2686
|
},
|
|
2677
2687
|
{
|
|
2678
2688
|
"path": "docs/modules-contract.md",
|
|
@@ -6034,10 +6044,15 @@
|
|
|
6034
6044
|
"mode": "0000644",
|
|
6035
6045
|
"sha256": "cc6f7ab084ee74715241220339411d1a10c8d6697af143df452278ea2c634f79"
|
|
6036
6046
|
},
|
|
6047
|
+
{
|
|
6048
|
+
"path": "modules/platform-identity/origin-containment.js",
|
|
6049
|
+
"mode": "0000644",
|
|
6050
|
+
"sha256": "0195910fd74860aa7b3b54ebfe73875ffba3e59938aac1904c07538f8b607a88"
|
|
6051
|
+
},
|
|
6037
6052
|
{
|
|
6038
6053
|
"path": "modules/platform-identity/platform-identity.js",
|
|
6039
6054
|
"mode": "0000644",
|
|
6040
|
-
"sha256": "
|
|
6055
|
+
"sha256": "54f44f6292729e822083671913dc89de28ede760dc88028ce4a18219ce006ab3"
|
|
6041
6056
|
},
|
|
6042
6057
|
{
|
|
6043
6058
|
"path": "modules/platform-identity/pollers/code-sweep.js",
|
|
@@ -6057,12 +6072,12 @@
|
|
|
6057
6072
|
{
|
|
6058
6073
|
"path": "modules/platform-identity/project-stats.js",
|
|
6059
6074
|
"mode": "0000644",
|
|
6060
|
-
"sha256": "
|
|
6075
|
+
"sha256": "5f225b384a70902b9f24ab63fe032c3f256363fccbe116a267958325b85da167"
|
|
6061
6076
|
},
|
|
6062
6077
|
{
|
|
6063
6078
|
"path": "modules/platform-identity/projects-feed.js",
|
|
6064
6079
|
"mode": "0000644",
|
|
6065
|
-
"sha256": "
|
|
6080
|
+
"sha256": "9a8a22d21089770ec80f709d27f798eab6101cfaead18942c439f7c8689ef041"
|
|
6066
6081
|
},
|
|
6067
6082
|
{
|
|
6068
6083
|
"path": "modules/platform-identity/publish-gate.js",
|
|
@@ -6082,7 +6097,7 @@
|
|
|
6082
6097
|
{
|
|
6083
6098
|
"path": "modules/platform-identity/routes/my-projects.js",
|
|
6084
6099
|
"mode": "0000644",
|
|
6085
|
-
"sha256": "
|
|
6100
|
+
"sha256": "ea3f35797eddb12bc50f5bf04585df9a2ca7e223bee69c77e275b5584d9066e9"
|
|
6086
6101
|
},
|
|
6087
6102
|
{
|
|
6088
6103
|
"path": "modules/platform-identity/routes/profile.js",
|
|
@@ -6092,7 +6107,7 @@
|
|
|
6092
6107
|
{
|
|
6093
6108
|
"path": "modules/platform-identity/routes/projects.js",
|
|
6094
6109
|
"mode": "0000644",
|
|
6095
|
-
"sha256": "
|
|
6110
|
+
"sha256": "7db130e5fe698bc133475d5c7ab90583fbe3683ac1d11b2542c56e76a7da4cfd"
|
|
6096
6111
|
},
|
|
6097
6112
|
{
|
|
6098
6113
|
"path": "modules/platform-identity/routes/public-profile.js",
|
|
@@ -7517,12 +7532,12 @@
|
|
|
7517
7532
|
{
|
|
7518
7533
|
"path": "package-lock.json",
|
|
7519
7534
|
"mode": "0000644",
|
|
7520
|
-
"sha256": "
|
|
7535
|
+
"sha256": "8ada761ecbd4a9f7adad006ce8f1d51fcd120001c4f7cdfd305d03c6e61a72fd"
|
|
7521
7536
|
},
|
|
7522
7537
|
{
|
|
7523
7538
|
"path": "package.json",
|
|
7524
7539
|
"mode": "0000644",
|
|
7525
|
-
"sha256": "
|
|
7540
|
+
"sha256": "b2a6bf7c81f157286bbccad28bbd150aee2a5ee74b79dc1f045028280f0b9c51"
|
|
7526
7541
|
},
|
|
7527
7542
|
{
|
|
7528
7543
|
"path": "public-docs/index.html",
|
|
@@ -9222,7 +9237,7 @@
|
|
|
9222
9237
|
{
|
|
9223
9238
|
"path": "src/module-api.js",
|
|
9224
9239
|
"mode": "0000644",
|
|
9225
|
-
"sha256": "
|
|
9240
|
+
"sha256": "4e9a58070edc3a59a9915b3f4048ad973460ee7a6dda9d647ee3e60f8ef43b92"
|
|
9226
9241
|
},
|
|
9227
9242
|
{
|
|
9228
9243
|
"path": "src/module-loader/catalog.js",
|
|
@@ -11354,6 +11369,11 @@
|
|
|
11354
11369
|
"mode": "0000644",
|
|
11355
11370
|
"sha256": "1f8c6924dac2c922a1f88c22693b4386ec16d5f0ae346609b3d1dc0003308a70"
|
|
11356
11371
|
},
|
|
11372
|
+
{
|
|
11373
|
+
"path": "tests/origin_containment.mjs",
|
|
11374
|
+
"mode": "0000644",
|
|
11375
|
+
"sha256": "62ac725d14be8976580824e430e36382dc643900b2dbd0fc65dc47b6725f96fd"
|
|
11376
|
+
},
|
|
11357
11377
|
{
|
|
11358
11378
|
"path": "tests/orphaned_claim_ship.mjs",
|
|
11359
11379
|
"mode": "0000644",
|
|
@@ -11552,7 +11572,7 @@
|
|
|
11552
11572
|
{
|
|
11553
11573
|
"path": "tests/project_stats.mjs",
|
|
11554
11574
|
"mode": "0000644",
|
|
11555
|
-
"sha256": "
|
|
11575
|
+
"sha256": "398b598fbfc0717513ff8e5abd641219906972209e4b92404c993cbd68d6dd49"
|
|
11556
11576
|
},
|
|
11557
11577
|
{
|
|
11558
11578
|
"path": "tests/project_takedown_lever.mjs",
|
|
@@ -12492,7 +12512,7 @@
|
|
|
12492
12512
|
{
|
|
12493
12513
|
"path": "tests/visible_projects_feed.mjs",
|
|
12494
12514
|
"mode": "0000644",
|
|
12495
|
-
"sha256": "
|
|
12515
|
+
"sha256": "a15bfe5f3b664a9daa4f45b80eb7a5bee2b2c630198439352c170fc44c4b2025"
|
|
12496
12516
|
},
|
|
12497
12517
|
{
|
|
12498
12518
|
"path": "tests/wandering_prefs.mjs",
|
|
@@ -90,7 +90,7 @@ export interface GetMyProjectsResponse { owned: unknown; building: unknown; appl
|
|
|
90
90
|
export interface GetOverrideRequestsResponse { requests: unknown }
|
|
91
91
|
export interface GetProfilesHandleResponse { account: unknown; cross_project: unknown }
|
|
92
92
|
export interface GetProjectSettingsResponse { rows: unknown }
|
|
93
|
-
export interface GetProjectsFeaturedResponse { projects: unknown }
|
|
93
|
+
export interface GetProjectsFeaturedResponse { projects: unknown; page: PageInfo }
|
|
94
94
|
export interface GetProjectsResponse { projects: unknown }
|
|
95
95
|
export interface GetProvisioningFleetResponse { instances: unknown }
|
|
96
96
|
export interface GetProvisioningGithubAppStatusResponse { ok: boolean; status: unknown; ready: unknown; terminal: unknown; domain: unknown; instance_status: unknown; app_slug: unknown }
|
|
@@ -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.
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# ADR 0255 — A public list's ordering is part of its payload, so an anonymous row must be ordered by nothing
|
|
2
|
+
|
|
3
|
+
- **Status:** accepted
|
|
4
|
+
- **Date:** 2026-09-06
|
|
5
|
+
- **Task:** [task 1003561](https://cloudbongos.com/builders#/task/1003561) (BONGOS-V1, goal 1000046 — *Project creation*), which absorbed [task 1003560](https://cloudbongos.com/builders#/task/1003560)
|
|
6
|
+
- **Deciders:** Claude, building against ADR 0182 D2a
|
|
7
|
+
- **Related:** [ADR 0182](<redacted>.md) D1/D2a/D3 (the join door, the stealth redaction, and `featured`'s remaining ranking job) · [ADR 0252](<redacted>.md) §5.3 (the per-IP ceiling on this route) · [ADR 0253](<redacted>.md) (`visibility_source` / `visibility_reported_at`, the `DOOR_KNOWN_SQL` predicate this feed joins) · [ADR 0119](0119-api-pagination-contract.md) (the `?limit=&offset=` contract this feed was the last public list to hold out on)
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## 1. The leak
|
|
12
|
+
|
|
13
|
+
`modules/platform-identity/projects-feed.js` takes considerable trouble over what a stealth
|
|
14
|
+
project may say. `feedShapeFor()` rebuilds an anonymous row from an allow-list —
|
|
15
|
+
`STEALTH_ANONYMOUS_KEEPS = ['state', 'orb']` — and the constant's own comment states the
|
|
16
|
+
threat model it is defending against:
|
|
17
|
+
|
|
18
|
+
> No stable per-project key appears here on purpose: an id, a slug or a hash of the origin
|
|
19
|
+
> would each let an observer who kept yesterday's public feed name the project that went
|
|
20
|
+
> stealth today. Black holes are interchangeable, which is the whole point of the metaphor.
|
|
21
|
+
|
|
22
|
+
The feed then sorted `ORDER BY featured DESC, updated_at DESC` and handed the observer a
|
|
23
|
+
stable per-project key anyway. **Ordering is a covert column.** Every row's position is the
|
|
24
|
+
result of a comparison, and enough comparisons reconstruct the key the comparisons were made
|
|
25
|
+
on. Three consequences followed, and the third is the one that matters:
|
|
26
|
+
|
|
27
|
+
1. **`updated_at` was published despite being deliberately withheld.** The projection
|
|
28
|
+
excludes it on purpose — the same file notes that feature toggles and client
|
|
29
|
+
re-registration bump it, so as a public "last updated" it would lie. Sorting by it
|
|
30
|
+
published it anyway, one comparison at a time.
|
|
31
|
+
2. **A black hole sat between named neighbours**, whose own relative order is visible. That
|
|
32
|
+
brackets the hidden row's `updated_at` between two rows an observer can identify by name,
|
|
33
|
+
which is a far narrower fact about it than "a project exists."
|
|
34
|
+
3. **A black hole MOVED when its hidden project was touched.** This is the activity oracle.
|
|
35
|
+
Two fetches of a public, CORS-open, unauthenticated feed are enough to learn *that the
|
|
36
|
+
project behind this particular black hole was just active* — and correlating that with
|
|
37
|
+
anything happening in the open (a release, a post, a commit) is how an anonymous row gets
|
|
38
|
+
a name. Position was also stable enough between touches to follow one black hole across
|
|
39
|
+
snapshots, which is precisely the "stable per-project key" the payload refuses to carry.
|
|
40
|
+
|
|
41
|
+
The redaction was doing its job. The `ORDER BY` was undoing it.
|
|
42
|
+
|
|
43
|
+
## 2. Decision — the rule
|
|
44
|
+
|
|
45
|
+
**A row may be ordered only by what it publishes.**
|
|
46
|
+
|
|
47
|
+
- An **identified** row (`public` → planet, `private` → star) publishes `created_at` and
|
|
48
|
+
`origin`, so it is ordered by those, beneath `featured` — ADR 0182 D3's one remaining
|
|
49
|
+
job, ranking among rows already on the map. The resulting order is a **pure function of
|
|
50
|
+
the wire payload**: a reader holding nothing but the response can reproduce it. That is
|
|
51
|
+
the strongest available statement that a sort leaks nothing, and it is worth stating as
|
|
52
|
+
the positive form of the rule rather than as an absence of known leaks.
|
|
53
|
+
- An **anonymous** row (`stealth`, and anything unrecognised) publishes nothing, so it is
|
|
54
|
+
ordered by nothing about itself. The black holes are collated into **one trailing block**,
|
|
55
|
+
permuted by a **keyed, rotating hash**.
|
|
56
|
+
|
|
57
|
+
Concretely, four sort terms, of which the first partitions the two blocks so that no later
|
|
58
|
+
term ever compares across them:
|
|
59
|
+
|
|
60
|
+
```sql
|
|
61
|
+
ORDER BY (visibility IN ('public','private')) DESC,
|
|
62
|
+
((visibility IN ('public','private')) AND featured) DESC,
|
|
63
|
+
(CASE WHEN (visibility IN ('public','private')) THEN created_at END) DESC,
|
|
64
|
+
(CASE WHEN (visibility IN ('public','private')) THEN origin
|
|
65
|
+
ELSE md5(origin || $1::text) END) ASC
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Terms 2–4 are **guarded**, and that is the whole trick. Without the guards the trailing
|
|
69
|
+
block would simply be re-sorted by `featured`, then `created_at`, then `origin` — none of
|
|
70
|
+
which a black hole publishes, and `created_at` in particular would place it by its listing
|
|
71
|
+
date, which narrows *which* project it is for anyone who watched the catalog fill up. The
|
|
72
|
+
"anonymous rows go last" half without the "and are blanked" half is not a fix.
|
|
73
|
+
|
|
74
|
+
The identified-state list is **derived from `ORB_BY_STATE`**, never re-typed, and is an
|
|
75
|
+
**allow-list**: a state this file does not know sorts into the anonymous block, the same
|
|
76
|
+
fail-closed direction `orbStateOf()` already takes for rendering. A deny-list would draw a
|
|
77
|
+
future fourth enum value as a black hole and then sort it by its `created_at` — anonymous in
|
|
78
|
+
the payload, identified in the ordering, which is the worst of both.
|
|
79
|
+
|
|
80
|
+
## 3. Why the anonymous block is keyed, and why it rotates
|
|
81
|
+
|
|
82
|
+
Two separate properties, both load-bearing:
|
|
83
|
+
|
|
84
|
+
**Secret.** Origins are guessable — they are domain names. A permutation an observer could
|
|
85
|
+
compute (`md5(origin)`, `md5(origin || today)`, or plain alphabetical order by `origin`)
|
|
86
|
+
would let them hash their candidate origins, match the resulting ranks against the live
|
|
87
|
+
block, and de-anonymise every black hole in one pass. The salt is therefore a random
|
|
88
|
+
per-process key. Nothing persists it, compares it across restarts, or reads it back — it
|
|
89
|
+
only ever *orders* rows — so there is no key to configure, distribute, rotate by hand, or
|
|
90
|
+
leak from disk.
|
|
91
|
+
|
|
92
|
+
**Rotating.** A permanently stable permutation would still make **position** a durable
|
|
93
|
+
per-project handle: an observer could not name a black hole, but could follow one across
|
|
94
|
+
months and watch it appear and disappear. The salt therefore also carries a coarse epoch
|
|
95
|
+
(24h), so the whole block is re-dealt on a fixed period regardless of process lifetime.
|
|
96
|
+
Within one period the order is stable, which is what lets the pages of a single listing agree
|
|
97
|
+
with each other.
|
|
98
|
+
|
|
99
|
+
`md5` is a permutation here, not a security primitive; the unpredictability comes from the
|
|
100
|
+
secret salt, and it is the most portable hash available without pgcrypto.
|
|
101
|
+
|
|
102
|
+
## 4. Pagination is the same decision, not a neighbouring one
|
|
103
|
+
|
|
104
|
+
Task 1003560 asked for a cap on an "unbounded" feed. **That premise was already stale:**
|
|
105
|
+
`<redacted> = 500` shipped with the feed itself, and its own comment named the
|
|
106
|
+
cap "the cheap half of that trade — the honest half is pagination, which is its own task."
|
|
107
|
+
The surviving work was pagination only, and pagination edits the same single SQL statement
|
|
108
|
+
this ADR rewrites — so the two tasks were built together and 1003560 was merged into 1003561.
|
|
109
|
+
|
|
110
|
+
- **`?limit=&offset=`, per ADR 0119**, whose helper (`parsePagination` / `pageMeta`) this
|
|
111
|
+
route was the last public list not to use.
|
|
112
|
+
- **No cursor, and that is a security property as much as a style one.** An opaque cursor
|
|
113
|
+
over this feed would have to encode a sort position, and the obvious encoding — the last
|
|
114
|
+
row's timestamp — is exactly the side-channel §2 exists to close. An offset encodes nothing
|
|
115
|
+
about any row.
|
|
116
|
+
- **The default page size *is* the maximum (500), deliberately.** A caller that sends no
|
|
117
|
+
parameters gets byte-for-byte what it got before. One consumer of this feed is a static
|
|
118
|
+
orbs site in **another repository** that this commit cannot update; a smaller default would
|
|
119
|
+
have silently shrunk somebody else's map. `page` is added *beside* the existing `projects`
|
|
120
|
+
key rather than wrapping it, so the wire change is additive and every consumer that
|
|
121
|
+
destructures `data.projects` keeps working.
|
|
122
|
+
- **Paging cannot aim more fan-out than one request already could.** `?limit=` only ever
|
|
123
|
+
shrinks a page. `?offset=` reaches deeper, and the cost of that is bounded not by the
|
|
124
|
+
request count but by `project-stats.js`'s 10-minute per-origin cache: a second page reaches
|
|
125
|
+
origins the cache would have covered anyway, and re-reading any page is free. The per-IP
|
|
126
|
+
limiter (120 reads/60s, ADR 0252 §5.3) is unchanged and still bounds how often.
|
|
127
|
+
- **No `total`.** Counting is a second pass over the same predicate for a number nothing
|
|
128
|
+
reads today; `pageMeta()` makes it optional for that reason. A caller learns there is more
|
|
129
|
+
the standard way: `projects.length === page.limit` means ask for the next offset.
|
|
130
|
+
|
|
131
|
+
## 5. Alternatives weighed
|
|
132
|
+
|
|
133
|
+
| Option | Why not |
|
|
134
|
+
|---|---|
|
|
135
|
+
| Sort every row by `created_at DESC` | `created_at` is nulled on anonymous rows, so a black hole would be *positioned* by a date its own row refuses to carry — and its listing date is a strong hint at which project it is. Worse than the bug being fixed. |
|
|
136
|
+
| Order the anonymous block alphabetically by `origin` | Deterministic and timing-free, but position then reveals the alphabetical rank of a hidden domain — an oracle against a candidate set. |
|
|
137
|
+
| `ORDER BY random()` for the anonymous block | Kills both the timing signal and the durable handle, maximally. Rejected because it breaks pagination outright: a black hole could appear on two pages or none, and the count of black holes — which *is* public by design — would stop being stable within one listing. |
|
|
138
|
+
| Drop the anonymous rows from the feed entirely | Contradicts ADR 0182 D2a, which exposes existence on purpose. A black hole is a deliberate feature of the map. |
|
|
139
|
+
| Keep `updated_at` but bucket it coarsely (e.g. by week) | Still a timing channel, only quieter, and it would need a defensible bucket width nobody could justify. A sort key that must be blurred is a sort key that should not be there. |
|
|
140
|
+
|
|
141
|
+
## 6. What this does NOT close
|
|
142
|
+
|
|
143
|
+
Stated plainly, because a privacy fix that oversells itself is worse than none:
|
|
144
|
+
|
|
145
|
+
1. **Existence and count are public by design.** The number of black holes on the map is
|
|
146
|
+
visible and always was — that is ADR 0182 D2a, not a defect.
|
|
147
|
+
2. **The identified set still changes when a project goes stealth.** An observer diffing
|
|
148
|
+
yesterday's feed against today's sees a named project leave the identified block while
|
|
149
|
+
the black-hole count rises by one, and can name it that way. This is a **larger** channel
|
|
150
|
+
than the ordering one and it is untouched here: it is inherent in publishing a named list
|
|
151
|
+
at all, and closing it needs a different mechanism (a delay, a decoy, or accepting that a
|
|
152
|
+
project's *departure* from the public list is public). Filed separately rather than
|
|
153
|
+
silently implied to be fixed.
|
|
154
|
+
3. **Position is stable within one epoch.** Two snapshots taken the same day can still tell
|
|
155
|
+
black holes apart, though not name them. The rotation bounds this to the epoch.
|
|
156
|
+
|
|
157
|
+
## 7. Consequences
|
|
158
|
+
|
|
159
|
+
- The map's identified rows now sort by **listing date**, not last-touched. The feed stops
|
|
160
|
+
churning on `updated_at` bumps that meant nothing to a reader anyway.
|
|
161
|
+
- `<redacted>` is renamed `PUBLIC_FEED_MAX_PAGE_SIZE`, which is what it now is:
|
|
162
|
+
a per-request fan-out ceiling that doubles as the default page size.
|
|
163
|
+
- `listPublicProjects({ pool, limit, offset, now })` clamps its own window as well as the
|
|
164
|
+
route clamping it, because it is a module-public function and the ceiling is a fan-out
|
|
165
|
+
bound — it may not depend on every future caller remembering to pass one.
|
|
166
|
+
- Proof lives in `tests/visible_projects_feed.mjs`, which now pins the guards structurally:
|
|
167
|
+
the first sort term partitions the blocks, every identified key is guarded, there is no
|
|
168
|
+
fifth unguarded term, `updated_at` appears nowhere in the query, and the salt is bound as
|
|
169
|
+
a parameter rather than interpolated.
|
package/docs/adr/README.md
CHANGED
|
@@ -341,6 +341,8 @@ 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
|
+
| 0255 | [**A public list's ordering is part of its payload, so an anonymous row must be ordered by nothing** ([task 1003561](https://cloudbongos.com/builders#/task/1003561) · goal 1000046, which absorbed [task 1003560](https://cloudbongos.com/builders#/task/1003560)). The public projects feed redacts a stealth row down to an allow-list of `state` + `orb` and carries **no stable per-project key on purpose** ([ADR 0182](<redacted>.md) D2a) — then sorted `ORDER BY featured DESC, updated_at DESC` and handed the observer one anyway. **Ordering is a covert column:** a position is a comparison result, and enough comparisons reconstruct the key. Three consequences, the third load-bearing — `updated_at` was published despite being deliberately withheld from the projection (it lies as a public "last updated"); a black hole sat BETWEEN NAMED NEIGHBOURS whose order is visible, bracketing its timestamp between two identifiable rows; and it **MOVED when its hidden project was touched**, which is an activity oracle on a public, CORS-open, unauthenticated route, and stable enough between touches to follow one black hole across snapshots. **Decision: a row may be ordered only by what it publishes.** An identified row publishes `created_at` + `origin`, so it sorts by those under `featured` (D3's remaining ranking job) — an order that is a PURE FUNCTION OF THE WIRE PAYLOAD, reproducible by a reader holding only the response. An anonymous row publishes nothing, so it is ordered by nothing about itself: the black holes collate into ONE TRAILING BLOCK permuted by a keyed hash. Four sort terms; the first partitions the blocks so no later term compares across them, and **terms 2-4 are GUARDED** — the "anonymous rows go last" half without the "and are blanked" half is not a fix, since the trailing block would simply re-sort by `created_at`, which narrows *which* project it is for anyone who watched the catalog fill. The identified set is DERIVED from `ORB_BY_STATE` (never re-typed, so SQL and `feedShapeFor()` cannot drift) and is an ALLOW-list, so an unknown fourth enum value sorts anonymous as well as drawing anonymous. The permutation salt is **secret** (origins are guessable domain names — a predictable salt lets an observer hash candidates and match ranks) and **rotates on a 24h epoch** (a permanently stable permutation still makes POSITION a durable handle); random per process, since nothing persists or reads it back. **Pagination is the same decision, not a neighbouring one** — task 1003560's premise was already stale (`<redacted> = 500` shipped with the feed; its own comment called the cap "the cheap half" and pagination "its own task"), and the surviving half edits this exact statement, so it merged in: `?limit=&offset=` per [ADR 0119](0119-api-pagination-contract.md), **no cursor** because the obvious encoding is the last row's timestamp — the very channel this closes — and the **default page IS the maximum (500)** so a static orbs site in ANOTHER REPO is byte-for-byte unchanged, with `page` added beside `projects` rather than wrapping it. Paging cannot aim more fan-out than one request already could: `?limit=` only shrinks, and `?offset=` is bounded by `project-stats.js`'s 10-min per-origin cache, not the request count ([ADR 0252](<redacted>.md) §5.3's per-IP limiter unchanged). Rejected: sorting everything by `created_at` (positions a black hole by a date its row refuses to carry); alphabetical `origin` order for the block (an oracle against a candidate set); `ORDER BY random()` (breaks paging — a row could appear twice or not at all); coarse `updated_at` buckets (a sort key that must be blurred should not be there). **Honest limits, stated rather than implied fixed:** existence and count stay public by design; **the identified set still CHANGES when a project goes stealth**, a LARGER channel than this one and untouched here; and position is stable within one epoch.](<redacted>.md) | platform-identity / privacy boundary / public API |
|
|
345
|
+
| 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
346
|
|
|
345
347
|
| 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
348
|
|
package/docs/api/openapi.json
CHANGED
|
@@ -11127,7 +11127,7 @@
|
|
|
11127
11127
|
"projects"
|
|
11128
11128
|
],
|
|
11129
11129
|
"summary": "GET /projects/featured",
|
|
11130
|
-
"description": "the public map of projects; no auth by design. THE PER-IP CEILING IS PART OF THE ROUTE, not a decoration on it (ADR 0252 §5.3). This GET is unauthenticated and CORS-open, and it was unmetered by construction: the two global limiters return early for reads, and the /public/* read limiter is mounted on a prefix this route does not sit under. It is also the platform's most expensive anonymous read — a cold cache turns one request into as many as 500 rows × 4 outbound fetches at OTHER PEOPLE's servers, so leaving it open aims our fan-out, not just our database. Auto-appear removed the old bound (the set of projects the operator had provisioned) and replaced it with \"whoever points a domain at us\", which is what makes the ceiling load-bearing rather than prudent. It runs BEFORE the handler, so a refused read costs no query and no fetch.\n\n**Rank:** `public` — No authentication — any caller.",
|
|
11130
|
+
"description": "the public map of projects; no auth by design. THE PER-IP CEILING IS PART OF THE ROUTE, not a decoration on it (ADR 0252 §5.3). This GET is unauthenticated and CORS-open, and it was unmetered by construction: the two global limiters return early for reads, and the /public/* read limiter is mounted on a prefix this route does not sit under. It is also the platform's most expensive anonymous read — a cold cache turns one request into as many as 500 rows × 4 outbound fetches at OTHER PEOPLE's servers, so leaving it open aims our fan-out, not just our database. Auto-appear removed the old bound (the set of projects the operator had provisioned) and replaced it with \"whoever points a domain at us\", which is what makes the ceiling load-bearing rather than prudent. It runs BEFORE the handler, so a refused read costs no query and no fetch. PAGING DOES NOT RAISE THE CEILING IT SITS UNDER (task 1003561, absorbing task 1003560). `?limit=` only ever SHRINKS a page — parsePagination clamps it to the same 500 that used to be a silent hard truncation — so no request can aim more fan-out than one could before. `?offset=` reaches further down the catalog, and the only thing that costs is origins project-stats.js would have cached anyway: its 10-minute per-origin TTL, not the request count, is what bounds total outbound traffic. What paging BUYS is that the 501st project stops being invisible with nothing on the wire saying so.\n\n**Rank:** `public` — No authentication — any caller.",
|
|
11131
11131
|
"x-rank": "public",
|
|
11132
11132
|
"x-source": "modules/platform-identity/routes/projects.js",
|
|
11133
11133
|
"responses": {
|
|
@@ -17503,10 +17503,14 @@
|
|
|
17503
17503
|
"GetProjectsFeaturedResponse": {
|
|
17504
17504
|
"type": "object",
|
|
17505
17505
|
"properties": {
|
|
17506
|
-
"projects": {}
|
|
17506
|
+
"projects": {},
|
|
17507
|
+
"page": {
|
|
17508
|
+
"$ref": "#/components/schemas/PageInfo"
|
|
17509
|
+
}
|
|
17507
17510
|
},
|
|
17508
17511
|
"required": [
|
|
17509
|
-
"projects"
|
|
17512
|
+
"projects",
|
|
17513
|
+
"page"
|
|
17510
17514
|
]
|
|
17511
17515
|
},
|
|
17512
17516
|
"GetProjectsResponse": {
|
|
@@ -1581,5 +1581,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1581
1581
|
landed since 1.19.564 with no explicit bump. run 34055981223. (task 1002620)
|
|
1582
1582
|
1.19.566 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1583
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)
|
|
1586
|
+
1.19.568 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1587
|
+
landed since 1.19.567 with no explicit bump. run 34058174866. (task 1002620)
|
|
1584
1588
|
---------------------------------------------------------------------------
|
|
1585
1589
|
```
|