@bongos/core 1.19.567 → 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 +23 -18
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0255-a-public-lists-ordering-is-part-of-its-payload.md +169 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +7 -3
- package/docs/module-api-changelog.md +2 -0
- package/modules/platform-identity/platform-identity.js +2 -1
- package/modules/platform-identity/projects-feed.js +126 -20
- 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/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
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",
|
|
@@ -1789,10 +1789,15 @@
|
|
|
1789
1789
|
"mode": "0000644",
|
|
1790
1790
|
"sha256": "4e27106f8c9251b788d13bcfc64f29637bb0b009c18102c9b7dd9d70b80400dc"
|
|
1791
1791
|
},
|
|
1792
|
+
{
|
|
1793
|
+
"path": "docs/adr/0255-a-public-lists-ordering-is-part-of-its-payload.md",
|
|
1794
|
+
"mode": "0000644",
|
|
1795
|
+
"sha256": "2c82050a733a0a82dde5079bbc486fc3378b2be954817a811aab92b9e46c9c5b"
|
|
1796
|
+
},
|
|
1792
1797
|
{
|
|
1793
1798
|
"path": "docs/adr/README.md",
|
|
1794
1799
|
"mode": "0000644",
|
|
1795
|
-
"sha256": "
|
|
1800
|
+
"sha256": "44acc139af799118137f253f6eb2a43863b5b33edee05716e073cf4aa8a43859"
|
|
1796
1801
|
},
|
|
1797
1802
|
{
|
|
1798
1803
|
"path": "docs/api-reference.md",
|
|
@@ -1802,7 +1807,7 @@
|
|
|
1802
1807
|
{
|
|
1803
1808
|
"path": "docs/api/openapi.json",
|
|
1804
1809
|
"mode": "0000644",
|
|
1805
|
-
"sha256": "
|
|
1810
|
+
"sha256": "9148616a367b2f7486a8f14c0f7ecf9ed367bd552075b15c4f2b0f3dd40712db"
|
|
1806
1811
|
},
|
|
1807
1812
|
{
|
|
1808
1813
|
"path": "docs/architecture.md",
|
|
@@ -2677,7 +2682,7 @@
|
|
|
2677
2682
|
{
|
|
2678
2683
|
"path": "docs/module-api-changelog.md",
|
|
2679
2684
|
"mode": "0000644",
|
|
2680
|
-
"sha256": "
|
|
2685
|
+
"sha256": "6c7437c85c872c6cece307ea499b855f6fa6b54ce91059168a255c70edf7b5b9"
|
|
2681
2686
|
},
|
|
2682
2687
|
{
|
|
2683
2688
|
"path": "docs/modules-contract.md",
|
|
@@ -6047,7 +6052,7 @@
|
|
|
6047
6052
|
{
|
|
6048
6053
|
"path": "modules/platform-identity/platform-identity.js",
|
|
6049
6054
|
"mode": "0000644",
|
|
6050
|
-
"sha256": "
|
|
6055
|
+
"sha256": "54f44f6292729e822083671913dc89de28ede760dc88028ce4a18219ce006ab3"
|
|
6051
6056
|
},
|
|
6052
6057
|
{
|
|
6053
6058
|
"path": "modules/platform-identity/pollers/code-sweep.js",
|
|
@@ -6072,7 +6077,7 @@
|
|
|
6072
6077
|
{
|
|
6073
6078
|
"path": "modules/platform-identity/projects-feed.js",
|
|
6074
6079
|
"mode": "0000644",
|
|
6075
|
-
"sha256": "
|
|
6080
|
+
"sha256": "9a8a22d21089770ec80f709d27f798eab6101cfaead18942c439f7c8689ef041"
|
|
6076
6081
|
},
|
|
6077
6082
|
{
|
|
6078
6083
|
"path": "modules/platform-identity/publish-gate.js",
|
|
@@ -6102,7 +6107,7 @@
|
|
|
6102
6107
|
{
|
|
6103
6108
|
"path": "modules/platform-identity/routes/projects.js",
|
|
6104
6109
|
"mode": "0000644",
|
|
6105
|
-
"sha256": "
|
|
6110
|
+
"sha256": "7db130e5fe698bc133475d5c7ab90583fbe3683ac1d11b2542c56e76a7da4cfd"
|
|
6106
6111
|
},
|
|
6107
6112
|
{
|
|
6108
6113
|
"path": "modules/platform-identity/routes/public-profile.js",
|
|
@@ -7527,12 +7532,12 @@
|
|
|
7527
7532
|
{
|
|
7528
7533
|
"path": "package-lock.json",
|
|
7529
7534
|
"mode": "0000644",
|
|
7530
|
-
"sha256": "
|
|
7535
|
+
"sha256": "8ada761ecbd4a9f7adad006ce8f1d51fcd120001c4f7cdfd305d03c6e61a72fd"
|
|
7531
7536
|
},
|
|
7532
7537
|
{
|
|
7533
7538
|
"path": "package.json",
|
|
7534
7539
|
"mode": "0000644",
|
|
7535
|
-
"sha256": "
|
|
7540
|
+
"sha256": "b2a6bf7c81f157286bbccad28bbd150aee2a5ee74b79dc1f045028280f0b9c51"
|
|
7536
7541
|
},
|
|
7537
7542
|
{
|
|
7538
7543
|
"path": "public-docs/index.html",
|
|
@@ -9232,7 +9237,7 @@
|
|
|
9232
9237
|
{
|
|
9233
9238
|
"path": "src/module-api.js",
|
|
9234
9239
|
"mode": "0000644",
|
|
9235
|
-
"sha256": "
|
|
9240
|
+
"sha256": "4e9a58070edc3a59a9915b3f4048ad973460ee7a6dda9d647ee3e60f8ef43b92"
|
|
9236
9241
|
},
|
|
9237
9242
|
{
|
|
9238
9243
|
"path": "src/module-loader/catalog.js",
|
|
@@ -12507,7 +12512,7 @@
|
|
|
12507
12512
|
{
|
|
12508
12513
|
"path": "tests/visible_projects_feed.mjs",
|
|
12509
12514
|
"mode": "0000644",
|
|
12510
|
-
"sha256": "
|
|
12515
|
+
"sha256": "a15bfe5f3b664a9daa4f45b80eb7a5bee2b2c630198439352c170fc44c4b2025"
|
|
12511
12516
|
},
|
|
12512
12517
|
{
|
|
12513
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,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,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
|
+
| 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 |
|
|
344
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 |
|
|
345
346
|
|
|
346
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 |
|
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": {
|
|
@@ -1583,5 +1583,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1583
1583
|
landed since 1.19.565 with no explicit bump. run 34056809932. (task 1002620)
|
|
1584
1584
|
1.19.567 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1585
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)
|
|
1586
1588
|
---------------------------------------------------------------------------
|
|
1587
1589
|
```
|
|
@@ -322,7 +322,7 @@ const {
|
|
|
322
322
|
// D1/D2a/D3; R06, task 1002325). Its own file for the publish-gate.js reason: this
|
|
323
323
|
// file is at the size ratchet. Re-exported below, so the surface is unchanged.
|
|
324
324
|
const {
|
|
325
|
-
ORB_BY_STATE, orbForState, isStealthProject, feedShapeFor, listPublicProjects,
|
|
325
|
+
ORB_BY_STATE, orbForState, isStealthProject, feedShapeFor, listPublicProjects, PUBLIC_FEED_MAX_PAGE_SIZE,
|
|
326
326
|
} = require('./projects-feed');
|
|
327
327
|
|
|
328
328
|
// Upsert a catalog row (keyed by origin). Called when a client registers (so every
|
|
@@ -1426,6 +1426,7 @@ module.exports = {
|
|
|
1426
1426
|
PROJECT_SHAPES,
|
|
1427
1427
|
getProject,
|
|
1428
1428
|
listPublicProjects,
|
|
1429
|
+
PUBLIC_FEED_MAX_PAGE_SIZE,
|
|
1429
1430
|
listAllProjects,
|
|
1430
1431
|
setProjectFeatured,
|
|
1431
1432
|
ORB_BY_STATE,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// The in-repo lever the orbs page reads. The publish-gate.js precedent — one question,
|
|
4
4
|
// one file, so no two surfaces can answer it differently.
|
|
5
5
|
//
|
|
6
|
-
//
|
|
6
|
+
// FOUR ANSWERS, and they belong together because each is only safe given the others:
|
|
7
7
|
//
|
|
8
8
|
// • WHO — `listPublicProjects`. A project is on the map iff `status = 'active' AND
|
|
9
9
|
// publishable`, the derived identity gate, taken from publishableSql() so this feed
|
|
@@ -27,6 +27,13 @@
|
|
|
27
27
|
// project to anyone who opens the network tab — and that page is a static site
|
|
28
28
|
// outside this repo, so we could not enforce it there even in principle.
|
|
29
29
|
//
|
|
30
|
+
// • IN WHAT ORDER — the ORDER BY, which is a covert answer and was leaking one until
|
|
31
|
+
// task 1003561: the feed sorted by `updated_at`, a column this projection
|
|
32
|
+
// deliberately withholds, so a black hole visibly MOVED whenever its hidden project
|
|
33
|
+
// was touched. A row is now ordered only by what it publishes, and a row that
|
|
34
|
+
// publishes nothing sits in a keyed-permuted anonymous block. See the ORDERING
|
|
35
|
+
// section below and ADR 0255.
|
|
36
|
+
//
|
|
30
37
|
// TWO DELIBERATE FAIL-CLOSED CHOICES, because this is a privacy boundary, not a
|
|
31
38
|
// rendering detail:
|
|
32
39
|
// 1. AN UNKNOWN STATE DRAWS AS A BLACK HOLE. A fourth enum value added later and not
|
|
@@ -44,6 +51,8 @@
|
|
|
44
51
|
// Proof: ../../tests/visible_projects_feed.mjs
|
|
45
52
|
'use strict';
|
|
46
53
|
|
|
54
|
+
const { randomBytes } = require('node:crypto');
|
|
55
|
+
|
|
47
56
|
const { pool: defaultPool } = require('../../src/module-api');
|
|
48
57
|
const { publishableSql } = require('./publish-gate');
|
|
49
58
|
|
|
@@ -103,21 +112,88 @@ function feedShapeFor(project) {
|
|
|
103
112
|
return anonymous;
|
|
104
113
|
}
|
|
105
114
|
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
115
|
+
// ── HOW THE FEED IS ORDERED, and why ordering is a privacy question ──────────
|
|
116
|
+
//
|
|
117
|
+
// A public list's ORDER BY is a covert column. Every row's position is the result of a
|
|
118
|
+
// comparison, and enough comparisons reconstruct the key they were made on. Until task
|
|
119
|
+
// 1003561 this feed sorted `featured DESC, updated_at DESC` — and `updated_at` is a
|
|
120
|
+
// column the projection below deliberately WITHHOLDS, because as a public "last updated"
|
|
121
|
+
// it would lie. Sorting by a withheld column publishes it anyway, one comparison at a time.
|
|
122
|
+
//
|
|
123
|
+
// For an identified row that is merely redundant. For a BLACK HOLE it defeats
|
|
124
|
+
// STEALTH_ANONYMOUS_KEEPS above. That allow-list refuses to carry ANY stable per-project
|
|
125
|
+
// key, precisely so an observer who kept yesterday's feed cannot diff it against today's
|
|
126
|
+
// to name the project that went stealth — and then the ordering handed them exactly that.
|
|
127
|
+
// A position is stable enough to follow one black hole across snapshots; it sits among
|
|
128
|
+
// NAMED neighbours whose own order is visible, which brackets the hidden row between two
|
|
129
|
+
// known timestamps; and it MOVES the moment the hidden project is touched. Black holes
|
|
130
|
+
// are supposed to be interchangeable and silent. Sorted by their own timestamps they were
|
|
131
|
+
// individually trackable and audibly alive.
|
|
132
|
+
//
|
|
133
|
+
// THE RULE THIS FILE NOW HOLDS: a row may be ordered only by what it publishes.
|
|
134
|
+
// • An IDENTIFIED row publishes `created_at` and `origin`, so it sorts by those, under
|
|
135
|
+
// `featured` (ADR 0182 D3's one remaining job — ranking). That order is a pure
|
|
136
|
+
// function of the wire payload: a reader holding nothing but the response can
|
|
137
|
+
// reproduce it, which is the strongest available statement that the sort leaks nothing
|
|
138
|
+
// the rows did not already say.
|
|
139
|
+
// • An ANONYMOUS row publishes nothing, so it is ordered by nothing about itself. The
|
|
140
|
+
// black holes are collated into ONE TRAILING BLOCK and permuted by a keyed hash: they
|
|
141
|
+
// neighbour no named row, so position carries no correlation, and the permutation
|
|
142
|
+
// re-deals on a fixed period so position is not a durable handle either.
|
|
143
|
+
//
|
|
144
|
+
// The decision, the alternatives weighed, and the two leaks this does NOT close (existence
|
|
145
|
+
// is public by design, and the identified set still CHANGES when a project goes stealth)
|
|
146
|
+
// are in ADR 0255.
|
|
147
|
+
|
|
148
|
+
// The states that render IDENTIFIED — derived from ORB_BY_STATE rather than re-listed, so
|
|
149
|
+
// the SQL and feedShapeFor() can never disagree about who is anonymous. A disagreement is
|
|
150
|
+
// not cosmetic: it would position a row by a `created_at` that its own wire row, redacted
|
|
151
|
+
// moments later, does not carry. Fail-closed in the SAME direction as orbStateOf() — a
|
|
152
|
+
// state this file does not know is not identified.
|
|
153
|
+
const IDENTIFIED_STATES = Object.freeze(
|
|
154
|
+
Object.keys(ORB_BY_STATE).filter((state) => state !== FALLBACK_PROJECT_STATE),
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
// The same fact, as SQL. Built only from string literals this file owns — never a caller's.
|
|
158
|
+
const IDENTIFIED_SQL = `(visibility IN (${IDENTIFIED_STATES.map((s) => `'${s}'`).join(', ')}))`;
|
|
159
|
+
|
|
160
|
+
// How often the anonymous block is re-dealt. Within one period the permutation is stable,
|
|
161
|
+
// so the pages of one listing agree with each other; across periods the whole block is
|
|
162
|
+
// re-permuted, so a rank held today is not the same black hole's rank tomorrow. That
|
|
163
|
+
// rotation is what stops POSITION from quietly becoming the stable per-project key the
|
|
164
|
+
// payload goes to such lengths to refuse.
|
|
165
|
+
const ANONYMOUS_ORDER_EPOCH_MS = 24 * 60 * 60 * 1000;
|
|
166
|
+
|
|
167
|
+
// The permutation key, and its SECRECY is the load-bearing part. Origins are guessable —
|
|
168
|
+
// they are domain names — so a salt an observer could predict would let them hash their
|
|
169
|
+
// candidate origins, match the ranks and de-anonymise the entire block in one pass.
|
|
170
|
+
// Random per process, because this value only ever ORDERS rows: nothing persists it,
|
|
171
|
+
// compares it across restarts or reads it back, so there is no key to configure,
|
|
172
|
+
// distribute or leak, and a restart simply re-deals early.
|
|
173
|
+
const ANONYMOUS_ORDER_KEY = randomBytes(16).toString('hex');
|
|
174
|
+
|
|
175
|
+
// The salt for one listing: the secret, then the current epoch. Bound as a query
|
|
176
|
+
// PARAMETER, never interpolated into SQL. PURE given `now`.
|
|
177
|
+
function anonymousOrderSalt(now = Date.now()) {
|
|
178
|
+
return `${ANONYMOUS_ORDER_KEY}:${Math.floor(now / ANONYMOUS_ORDER_EPOCH_MS)}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// The feed's per-request ceiling AND its default page size — deliberately one number.
|
|
182
|
+
//
|
|
183
|
+
// It bounds FAN-OUT, not merely response size: this route is public, CORS-open and
|
|
184
|
+
// unauthenticated, and each COLD row drives up to four outbound fetches at somebody else's
|
|
185
|
+
// server, so the page size is the bound on how much traffic a single request can aim. The
|
|
186
|
+
// other two bounds live elsewhere and both still hold — the per-IP limiter (120 reads/60s,
|
|
187
|
+
// ADR 0252 §5.3) bounds how OFTEN, and project-stats.js's 10-minute per-origin cache bounds
|
|
188
|
+
// the TOTAL. That cache is why paging deeper cannot multiply outbound traffic: a second
|
|
189
|
+
// page reaches origins the cache would have covered anyway, and re-reading any page is free.
|
|
190
|
+
//
|
|
191
|
+
// DEFAULTING TO THE MAXIMUM IS THE COMPATIBILITY GUARANTEE, not laziness. 500 was the old
|
|
192
|
+
// hard LIMIT, so a caller that sends no `?limit=` gets exactly what it got before — and one
|
|
193
|
+
// consumer of this feed is a static orbs site in ANOTHER REPO that cannot be updated by
|
|
194
|
+
// this commit. A smaller default would have silently shrunk somebody else's map.
|
|
195
|
+
const PUBLIC_FEED_MAX_PAGE_SIZE = 500;
|
|
196
|
+
|
|
121
197
|
// A PULLABLE row is on the map only once it has actually answered (R08, task 1002327;
|
|
122
198
|
// ADR 0182 D5 via ADR 0252 §4). "Unreported => dark matter" is scoped, deliberately and
|
|
123
199
|
// narrowly, to rows whose door the hub has never successfully read: a control-plane row
|
|
@@ -133,13 +209,39 @@ const PUBLIC_FEED_MAX_PROJECTS = 500;
|
|
|
133
209
|
// empty the public map on deploy. Both halves are load-bearing.
|
|
134
210
|
const DOOR_KNOWN_SQL = "(visibility_source = 'control-plane' OR visibility_reported_at IS NOT NULL)";
|
|
135
211
|
|
|
136
|
-
|
|
212
|
+
// Every project ON THE MAP, in the public projection (no owner_github_id, no status) — ONE
|
|
213
|
+
// PAGE of it (task 1003561, which absorbed task 1003560). `visibility` is projected because
|
|
214
|
+
// feedShapeFor() derives each row's state + orb from it; it is spent there and never reaches
|
|
215
|
+
// the wire. created_at is the LISTED date — when the hub first catalogued the row, not when
|
|
216
|
+
// the project began (task 1002778) — and it is now also the identified block's sort key,
|
|
217
|
+
// which is exactly the point: the feed sorts by something it publishes. `description` is
|
|
218
|
+
// the publish BAR, spent in the predicate, never a card field. `updated_at` no longer
|
|
219
|
+
// appears at all — not in the projection, and since task 1003561 not in the ORDER BY either.
|
|
220
|
+
//
|
|
221
|
+
// PAGING IS `?limit=&offset=` (ADR 0119's contract, which this route was the last public
|
|
222
|
+
// list to hold out on). No cursor, and that is a security property as much as a style one:
|
|
223
|
+
// an opaque cursor over this feed would have to encode a sort position, and the obvious
|
|
224
|
+
// encoding — the last row's timestamp — would reintroduce the very side-channel the ORDER BY
|
|
225
|
+
// above was rewritten to close. An offset encodes nothing about any row.
|
|
226
|
+
//
|
|
227
|
+
// The caller's window is clamped HERE as well as at the route, because this is a
|
|
228
|
+
// module-public function and the ceiling is a fan-out bound: it must not depend on every
|
|
229
|
+
// future caller remembering to pass one.
|
|
230
|
+
async function listPublicProjects({ pool = defaultPool, limit = PUBLIC_FEED_MAX_PAGE_SIZE, offset = 0, now = Date.now() } = {}) {
|
|
231
|
+
const size = Number.isInteger(limit) && limit > 0
|
|
232
|
+
? Math.min(limit, PUBLIC_FEED_MAX_PAGE_SIZE)
|
|
233
|
+
: PUBLIC_FEED_MAX_PAGE_SIZE;
|
|
234
|
+
const from = Number.isInteger(offset) && offset > 0 ? offset : 0;
|
|
137
235
|
const { rows } = await pool.query(
|
|
138
236
|
`SELECT origin, name, tagline, art_url, type, template, created_at, visibility
|
|
139
237
|
FROM platform_identity_projects
|
|
140
238
|
WHERE status = 'active' AND ${publishableSql()} AND ${DOOR_KNOWN_SQL}
|
|
141
|
-
ORDER BY
|
|
142
|
-
|
|
239
|
+
ORDER BY ${IDENTIFIED_SQL} DESC,
|
|
240
|
+
(${IDENTIFIED_SQL} AND featured) DESC,
|
|
241
|
+
(CASE WHEN ${IDENTIFIED_SQL} THEN created_at END) DESC,
|
|
242
|
+
(CASE WHEN ${IDENTIFIED_SQL} THEN origin ELSE md5(origin || $1::text) END) ASC
|
|
243
|
+
LIMIT $2::int OFFSET $3::int`,
|
|
244
|
+
[anonymousOrderSalt(now), size, from],
|
|
143
245
|
);
|
|
144
246
|
return rows;
|
|
145
247
|
}
|
|
@@ -148,11 +250,15 @@ module.exports = {
|
|
|
148
250
|
ORB_BY_STATE,
|
|
149
251
|
FALLBACK_PROJECT_STATE,
|
|
150
252
|
STEALTH_ANONYMOUS_KEEPS,
|
|
253
|
+
IDENTIFIED_STATES,
|
|
254
|
+
IDENTIFIED_SQL,
|
|
255
|
+
ANONYMOUS_ORDER_EPOCH_MS,
|
|
256
|
+
anonymousOrderSalt,
|
|
151
257
|
orbStateOf,
|
|
152
258
|
orbForState,
|
|
153
259
|
isStealthProject,
|
|
154
260
|
feedShapeFor,
|
|
155
|
-
|
|
261
|
+
PUBLIC_FEED_MAX_PAGE_SIZE,
|
|
156
262
|
DOOR_KNOWN_SQL,
|
|
157
263
|
listPublicProjects,
|
|
158
264
|
};
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// (active + publishable), each carrying its `state`
|
|
6
6
|
// and `orb` render type, stealth rows anonymised
|
|
7
7
|
// server-side, dark matter excluded (ADR 0182 D2a/D3).
|
|
8
|
+
// Paged `?limit=&offset=` (ADR 0119), defaulting to the
|
|
9
|
+
// whole 500-row ceiling so pre-paging callers are unchanged.
|
|
8
10
|
// The PATH keeps the old word: it is a rank:public,
|
|
9
11
|
// CORS-open URL that a static site and other origins
|
|
10
12
|
// already fetch, and renaming it would break them for
|
|
@@ -30,7 +32,8 @@ const projectCatalog = require('../project-catalog');
|
|
|
30
32
|
const projectStats = require('../project-stats');
|
|
31
33
|
const inviteAuthz = require('../invite-authz');
|
|
32
34
|
|
|
33
|
-
const { pool, requireBuilder, requirePermission, validateOrRespond, parseId, corsPublicGet
|
|
35
|
+
const { pool, requireBuilder, requirePermission, validateOrRespond, parseId, corsPublicGet,
|
|
36
|
+
parsePagination, pageMeta } = api;
|
|
34
37
|
const log = api.logger('platform-identity');
|
|
35
38
|
|
|
36
39
|
// a catalog origin's host, the way the hub's sky joins it to an owned instance
|
|
@@ -233,9 +236,24 @@ module.exports = function projectsRoutes() {
|
|
|
233
236
|
// provisioned) and replaced it with "whoever points a domain at us", which is what makes
|
|
234
237
|
// the ceiling load-bearing rather than prudent. It runs BEFORE the handler, so a refused
|
|
235
238
|
// read costs no query and no fetch.
|
|
236
|
-
|
|
239
|
+
//
|
|
240
|
+
// PAGING DOES NOT RAISE THE CEILING IT SITS UNDER (task 1003561, absorbing task
|
|
241
|
+
// 1003560). `?limit=` only ever SHRINKS a page — parsePagination clamps it to the same
|
|
242
|
+
// 500 that used to be a silent hard truncation — so no request can aim more fan-out
|
|
243
|
+
// than one could before. `?offset=` reaches further down the catalog, and the only
|
|
244
|
+
// thing that costs is origins project-stats.js would have cached anyway: its 10-minute
|
|
245
|
+
// per-origin TTL, not the request count, is what bounds total outbound traffic. What
|
|
246
|
+
// paging BUYS is that the 501st project stops being invisible with nothing on the wire
|
|
247
|
+
// saying so.
|
|
248
|
+
router.get('/projects/featured', api.publicProjectFeedRateLimit, async (req, res) => {
|
|
237
249
|
try {
|
|
238
|
-
|
|
250
|
+
// Default page = the whole ceiling, so a caller that sends no params (the static
|
|
251
|
+
// orbs site in another repo, the apex landing band) gets exactly what it got before.
|
|
252
|
+
const { limit, offset } = parsePagination(req, {
|
|
253
|
+
defaultLimit: pi.PUBLIC_FEED_MAX_PAGE_SIZE,
|
|
254
|
+
maxLimit: pi.PUBLIC_FEED_MAX_PAGE_SIZE,
|
|
255
|
+
});
|
|
256
|
+
const projects = await pi.listPublicProjects({ pool, limit, offset });
|
|
239
257
|
// A stealth project is on the map ANONYMOUSLY (ADR 0182 D2a), so its origin
|
|
240
258
|
// is kept out of both enrichments below: asking its own public API for its
|
|
241
259
|
// builders and their avatars would put identity on the wire, and tell that
|
|
@@ -282,6 +300,13 @@ module.exports = function projectsRoutes() {
|
|
|
282
300
|
// rebuilds the row from an allow-list of the two keys a black hole may carry
|
|
283
301
|
// (ADR 0182 D2a). Redacting any earlier would be silently undone by the
|
|
284
302
|
// spreads above.
|
|
303
|
+
// `page` is ADR 0119's block, added beside the existing `projects` key rather than
|
|
304
|
+
// wrapping it: every consumer destructures `data.projects` (Explore, the projects
|
|
305
|
+
// hub sky, the apex band, the generated client, a static orbs site in another repo),
|
|
306
|
+
// so this is additive on the wire and breaks none of them. A caller learns there is
|
|
307
|
+
// more the standard way — `projects.length === page.limit` means ask for the next
|
|
308
|
+
// offset. No `total`: counting is a second pass over the same predicate for a number
|
|
309
|
+
// nothing reads today, and pageMeta() makes it optional for exactly that reason.
|
|
285
310
|
res.json({
|
|
286
311
|
projects: projects.map((p) => pi.feedShapeFor({
|
|
287
312
|
...p,
|
|
@@ -289,6 +314,7 @@ module.exports = function projectsRoutes() {
|
|
|
289
314
|
...(stats[p.origin] || projectStats.NULL_STATS),
|
|
290
315
|
...planetOf(p),
|
|
291
316
|
})),
|
|
317
|
+
page: pageMeta(limit, offset),
|
|
292
318
|
});
|
|
293
319
|
} catch (err) {
|
|
294
320
|
console.error('[platform-identity] GET /projects/featured', err);
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.568",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.568",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.568",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/src/module-api.js
CHANGED
|
@@ -49,7 +49,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
49
49
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
50
50
|
// the entry to that file. Look for a version's history there, not here.
|
|
51
51
|
// ---------------------------------------------------------------------------
|
|
52
|
-
const CORE_VERSION = '1.19.
|
|
52
|
+
const CORE_VERSION = '1.19.568'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
53
53
|
|
|
54
54
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
55
55
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -16,6 +16,13 @@
|
|
|
16
16
|
// 3. WHAT A BLACK HOLE MAY SAY. Stealth exposes EXISTENCE, never IDENTITY (D2a), and
|
|
17
17
|
// the redaction is at the boundary because the orbs page is a static site outside
|
|
18
18
|
// this repo and could not be trusted to redact even in principle.
|
|
19
|
+
// 4. IN WHAT ORDER, and on what page (task 1003561, which absorbed task 1003560; ADR
|
|
20
|
+
// 0255). The ORDER BY is a covert column, and it was leaking one: sorting by
|
|
21
|
+
// `updated_at` — which this projection deliberately withholds — MOVED a black hole
|
|
22
|
+
// whenever its hidden project was touched, handing an observer the stable,
|
|
23
|
+
// activity-tracking handle STEALTH_ANONYMOUS_KEEPS exists to refuse. A row is now
|
|
24
|
+
// ordered only by what it publishes, anonymous rows are one keyed-permuted trailing
|
|
25
|
+
// block, and the 500-row cap became a page window instead of a silent truncation.
|
|
19
26
|
//
|
|
20
27
|
// The stealth assertions are the security spec: they assert ABSENT FIELDS, not absent
|
|
21
28
|
// pixels, which is what R10 (task 1002329) then demonstrates live.
|
|
@@ -194,20 +201,147 @@ test('featuring no longer GATES presence — it only ranks among projects alread
|
|
|
194
201
|
const sql = pool.calls[0].text;
|
|
195
202
|
const where = (sql.match(/WHERE([\s\S]*?)ORDER BY/i) || [, ''])[1];
|
|
196
203
|
assert.doesNotMatch(where, /featured/, 'featured must not appear in the predicate at all');
|
|
197
|
-
|
|
204
|
+
const order = (sql.match(/ORDER BY([\s\S]*?)LIMIT/i) || [, ''])[1];
|
|
205
|
+
assert.match(order, /AND featured\) DESC/,
|
|
206
|
+
'it keeps the job D3 leaves it: ranking — now guarded, so it ranks only rows that publish a name');
|
|
198
207
|
});
|
|
199
208
|
|
|
200
|
-
test('the
|
|
209
|
+
test('the cap is now a PAGE WINDOW — bound as parameters and clamped to the fan-out ceiling', async () => {
|
|
201
210
|
// Until R06 the listing was bounded by an Archon choosing to feature a project. Making
|
|
202
211
|
// it `publishable` replaced that bound with self-service, on a route that is public,
|
|
203
|
-
// CORS-open, unauthenticated AND fans out
|
|
204
|
-
//
|
|
212
|
+
// CORS-open, unauthenticated AND fans out up to four outbound stats fetches per cold
|
|
213
|
+
// row. Task 1002325 answered with a hard LIMIT 500 — the cheap half. This is the honest
|
|
214
|
+
// half (task 1003560, merged into 1003561): the same 500 still bounds one request's
|
|
215
|
+
// fan-out, but a caller can now reach past it instead of being truncated in silence.
|
|
216
|
+
const pool = mockPool([{ rows: [] }]);
|
|
217
|
+
await pi.listPublicProjects({ pool, limit: 10000, offset: 25 });
|
|
218
|
+
const { text, params } = pool.calls[0];
|
|
219
|
+
assert.match(text, /LIMIT \$2::int OFFSET \$3::int/,
|
|
220
|
+
'the window is BOUND, never interpolated — it is the first caller-supplied value this SQL has ever carried');
|
|
221
|
+
assert.equal(params[1], feed.PUBLIC_FEED_MAX_PAGE_SIZE,
|
|
222
|
+
'a caller cannot ask past the ceiling, so no request can aim more fan-out than one could before');
|
|
223
|
+
assert.equal(params[2], 25);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('no window at all means the OLD hard cap, so every pre-paging consumer is unchanged', async () => {
|
|
227
|
+
// The compatibility guarantee, asserted rather than trusted: one consumer of this feed
|
|
228
|
+
// is a static orbs site in ANOTHER REPO that this commit cannot update. A default page
|
|
229
|
+
// smaller than the old LIMIT would have silently shrunk somebody else's map.
|
|
205
230
|
const pool = mockPool([{ rows: [] }]);
|
|
206
231
|
await pi.listPublicProjects({ pool });
|
|
207
|
-
assert.
|
|
208
|
-
assert.equal(
|
|
209
|
-
|
|
210
|
-
|
|
232
|
+
assert.deepEqual(pool.calls[0].params.slice(1), [feed.PUBLIC_FEED_MAX_PAGE_SIZE, 0]);
|
|
233
|
+
assert.equal(feed.PUBLIC_FEED_MAX_PAGE_SIZE, 500, 'the number the feed was capped at before it could page');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('a nonsense window is CLAMPED, never rejected and never unbounded', async () => {
|
|
237
|
+
// Clamped in listPublicProjects as well as at the route: this is a module-public
|
|
238
|
+
// function and the ceiling is a fan-out bound, so it may not depend on every future
|
|
239
|
+
// caller remembering to pass one.
|
|
240
|
+
const pool = mockPool([{ rows: [] }, { rows: [] }]);
|
|
241
|
+
await pi.listPublicProjects({ pool, limit: -1, offset: -50 });
|
|
242
|
+
assert.deepEqual(pool.calls[0].params.slice(1), [feed.PUBLIC_FEED_MAX_PAGE_SIZE, 0]);
|
|
243
|
+
await pi.listPublicProjects({ pool, limit: 'all', offset: 'next' });
|
|
244
|
+
assert.deepEqual(pool.calls[1].params.slice(1), [feed.PUBLIC_FEED_MAX_PAGE_SIZE, 0]);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// ── the ORDER BY is part of the payload (task 1003561, ADR 0255) ─────────────
|
|
248
|
+
|
|
249
|
+
// The ORDER BY's terms, with the identified-state predicate masked so a naive comma
|
|
250
|
+
// split survives the comma inside its IN-list.
|
|
251
|
+
function orderTerms(sql) {
|
|
252
|
+
const clause = (sql.match(/ORDER BY([\s\S]*?)LIMIT/i) || [, ''])[1];
|
|
253
|
+
return clause.split(feed.IDENTIFIED_SQL).join('<identified>')
|
|
254
|
+
.split(',').map((t) => t.trim().replace(/\s+/g, ' ')).filter(Boolean);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function feedSql(opts = {}) {
|
|
258
|
+
const pool = mockPool([{ rows: [] }]);
|
|
259
|
+
await pi.listPublicProjects({ pool, ...opts });
|
|
260
|
+
return pool.calls[0];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
test('updated_at is gone from the query ENTIRELY — it may no longer even sort', async () => {
|
|
264
|
+
// THE bug (task 1003561). A public list's ORDER BY is a covert column: sorting by a
|
|
265
|
+
// value the projection withholds publishes it anyway, one comparison at a time. For a
|
|
266
|
+
// black hole that meant its position moved the moment its hidden project was touched.
|
|
267
|
+
const { text } = await feedSql();
|
|
268
|
+
assert.doesNotMatch(text, /updated_at/,
|
|
269
|
+
'not in the projection, and since task 1003561 not in the ORDER BY either');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('anonymous rows are collated into ONE TRAILING BLOCK, never interleaved with named ones', async () => {
|
|
273
|
+
// The separation is what makes position meaningless: a black hole neighbours no named
|
|
274
|
+
// row, so it can no longer be bracketed between two rows whose own order is visible.
|
|
275
|
+
const terms = orderTerms((await feedSql()).text);
|
|
276
|
+
assert.equal(terms[0], '<identified> DESC',
|
|
277
|
+
'the FIRST sort term partitions the two blocks, so no later term ever compares across them');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('no identified sort key is ever applied to an anonymous row', async () => {
|
|
281
|
+
// Blanking terms 2-4 is the whole trick. Without it, the trailing block would simply be
|
|
282
|
+
// re-sorted by featured, then created_at, then origin — none of which a black hole
|
|
283
|
+
// publishes, and created_at in particular would narrow WHICH project it is to anyone
|
|
284
|
+
// who watched the catalog fill up.
|
|
285
|
+
const terms = orderTerms((await feedSql()).text);
|
|
286
|
+
for (const [i, key] of [[1, 'featured'], [2, 'created_at'], [3, 'origin']]) {
|
|
287
|
+
assert.ok(terms[i].includes(key), `term ${i} ranks identified rows by ${key}`);
|
|
288
|
+
assert.ok(terms[i].includes('<identified>'),
|
|
289
|
+
`and it is guarded, so ${key} never orders an anonymous row`);
|
|
290
|
+
}
|
|
291
|
+
assert.ok(terms[3].includes('ELSE md5('),
|
|
292
|
+
'the anonymous block gets a permutation where the identified block gets its public keys');
|
|
293
|
+
assert.equal(terms.length, 4, 'and there is no fifth, unguarded term');
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('the anonymous permutation reads a BOUND salt — the key never enters the SQL text', async () => {
|
|
297
|
+
const { text, params } = await feedSql({ now: 0 });
|
|
298
|
+
assert.match(text, /md5\(origin \|\| \$1::text\)/, 'the hash reads its salt from $1');
|
|
299
|
+
assert.equal(params[0], feed.anonymousOrderSalt(0));
|
|
300
|
+
assert.ok(!text.includes(params[0]), 'and the secret is never interpolated into the query');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('the permutation salt is KEYED and re-deals on an epoch — it is not a clock reading', () => {
|
|
304
|
+
// Two properties, and both are load-bearing. SECRET: origins are guessable (they are
|
|
305
|
+
// domain names), so a salt an observer could predict would let them hash their
|
|
306
|
+
// candidates, match the ranks and de-anonymise the whole block in one pass. ROTATING:
|
|
307
|
+
// a permanently stable order would make POSITION the durable per-project key that
|
|
308
|
+
// STEALTH_ANONYMOUS_KEEPS refuses to put on the wire.
|
|
309
|
+
const epoch = feed.ANONYMOUS_ORDER_EPOCH_MS;
|
|
310
|
+
const salt = feed.anonymousOrderSalt(1000);
|
|
311
|
+
assert.equal(feed.anonymousOrderSalt(epoch - 1), salt,
|
|
312
|
+
'stable within one period, so the pages of a single listing agree with each other');
|
|
313
|
+
assert.notEqual(feed.anonymousOrderSalt(epoch + 1), salt,
|
|
314
|
+
're-dealt across periods, so today\'s rank is not tomorrow\'s black hole');
|
|
315
|
+
const [key, period] = salt.split(':');
|
|
316
|
+
assert.equal(period, '0');
|
|
317
|
+
assert.match(key, /^[0-9a-f]{32}$/, 'a random per-process key — not a timestamp, not a constant');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('SQL and feedShapeFor cannot disagree about who is anonymous — both read ORB_BY_STATE', () => {
|
|
321
|
+
// The two halves of one decision, written in two languages. A disagreement would not be
|
|
322
|
+
// cosmetic: SQL calling a state identified while feedShapeFor redacts it would POSITION
|
|
323
|
+
// the row by a created_at that its own wire row does not carry — exactly the leak this
|
|
324
|
+
// ordering closes, reopened one enum value later.
|
|
325
|
+
for (const state of feed.IDENTIFIED_STATES) {
|
|
326
|
+
assert.notEqual(feed.orbForState(state), 'black-hole', `${state} sorts identified, so it must render identified`);
|
|
327
|
+
assert.equal(feed.feedShapeFor({ ...PUBLIC_ROW, visibility: state }).name, 'Mercury');
|
|
328
|
+
assert.ok(feed.IDENTIFIED_SQL.includes(`'${state}'`), 'and the SQL names it too');
|
|
329
|
+
}
|
|
330
|
+
assert.deepEqual([...feed.IDENTIFIED_STATES].sort(), ['private', 'public']);
|
|
331
|
+
assert.ok(!feed.IDENTIFIED_STATES.includes(feed.FALLBACK_PROJECT_STATE),
|
|
332
|
+
'the anonymous fallback is never in the identified set');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test('the identified set is an ALLOW-list, so an unknown state SORTS anonymous too', () => {
|
|
336
|
+
// The same fail-closed direction orbStateOf() already took for rendering. A fourth enum
|
|
337
|
+
// value added upstream and forgotten here draws as a black hole AND sorts into the
|
|
338
|
+
// anonymous block; a deny-list would have drawn it anonymous but sorted it by its
|
|
339
|
+
// created_at, which is the worst of both.
|
|
340
|
+
assert.match(feed.IDENTIFIED_SQL, /^\(visibility IN \(/,
|
|
341
|
+
'an allow-list: identified by naming, never identified by omission');
|
|
342
|
+
assert.doesNotMatch(feed.IDENTIFIED_SQL, /NOT IN/);
|
|
343
|
+
assert.ok(!feed.IDENTIFIED_SQL.includes("'members-only'"));
|
|
344
|
+
assert.equal(feed.orbForState('members-only'), 'black-hole');
|
|
211
345
|
});
|
|
212
346
|
|
|
213
347
|
test('listPublicProjects projects `visibility`, the column the orb is derived from', async () => {
|
|
@@ -229,7 +363,8 @@ test('listPublicProjects stays a PUBLIC projection — no owner, no status, no b
|
|
|
229
363
|
assert.ok(sel.trim(), 'the projection is an explicit SELECT list');
|
|
230
364
|
assert.doesNotMatch(sel, /\*/, 'no star projection on a public route');
|
|
231
365
|
assert.doesNotMatch(sel, /owner_github_id/, 'public projection must not leak owner id');
|
|
232
|
-
assert.doesNotMatch(sel, /updated_at/,
|
|
366
|
+
assert.doesNotMatch(sel, /updated_at/,
|
|
367
|
+
'curation noise: it never projected, and since task 1003561 it does not sort either');
|
|
233
368
|
assert.doesNotMatch(sel, /\bdescription\b/,
|
|
234
369
|
'description is the publish BAR, not a card field — it is spent in the predicate');
|
|
235
370
|
assert.match(sel, /created_at/, 'the LISTED date stays public');
|
|
@@ -241,16 +376,17 @@ test('listPublicProjects stays a PUBLIC projection — no owner, no status, no b
|
|
|
241
376
|
// The real router, driven with a fake req/res. `pi.listPublicProjects` and the stats
|
|
242
377
|
// enrichment are stubbed so the test needs neither Postgres nor the network — what is
|
|
243
378
|
// under test is the ROUTE's composition, not either dependency.
|
|
244
|
-
async function callFeaturedFeed(rows) {
|
|
379
|
+
async function callFeaturedFeed(rows, query) {
|
|
245
380
|
const router = projectsRoutes();
|
|
246
381
|
const layer = router.stack.find((l) => l.route && l.route.path === '/projects/featured'
|
|
247
382
|
&& l.route.methods.get);
|
|
248
383
|
assert.ok(layer, 'GET /projects/featured must still be a route on this router');
|
|
249
384
|
|
|
250
385
|
const askedFor = [];
|
|
386
|
+
const listArgs = [];
|
|
251
387
|
const savedList = pi.listPublicProjects;
|
|
252
388
|
const savedStats = projectStats.getProjectStats;
|
|
253
|
-
pi.listPublicProjects = async () => rows;
|
|
389
|
+
pi.listPublicProjects = async (args) => { listArgs.push(args || {}); return rows; };
|
|
254
390
|
projectStats.getProjectStats = async (origins) => { askedFor.push(...origins); return {}; };
|
|
255
391
|
try {
|
|
256
392
|
let body = null;
|
|
@@ -264,13 +400,13 @@ async function callFeaturedFeed(rows) {
|
|
|
264
400
|
// would exercise the ceiling and never reach the route — and, worse, would keep
|
|
265
401
|
// passing as a silent no-op if the handler were ever removed. A fresh req per call
|
|
266
402
|
// keeps each test's reads out of the previous one's budget.
|
|
267
|
-
const req = { ip: `198.51.100.${Math.floor(Math.random() * 200) + 1}
|
|
403
|
+
const req = { ip: `198.51.100.${Math.floor(Math.random() * 200) + 1}`, query: query || {} };
|
|
268
404
|
for (const entry of layer.route.stack) {
|
|
269
405
|
let advanced = false;
|
|
270
406
|
await entry.handle(req, res, () => { advanced = true; });
|
|
271
407
|
if (!advanced) break; // something short-circuited (a refusal), or it was the handler
|
|
272
408
|
}
|
|
273
|
-
return { body, askedFor };
|
|
409
|
+
return { body, askedFor, listArgs };
|
|
274
410
|
} finally {
|
|
275
411
|
pi.listPublicProjects = savedList;
|
|
276
412
|
projectStats.getProjectStats = savedStats;
|
|
@@ -329,3 +465,38 @@ test('dark matter never reaches the route at all — the exclusion is in SQL', a
|
|
|
329
465
|
const fn = (src.match(/async function listPublicProjects[\s\S]*?\n\}/) || [''])[0];
|
|
330
466
|
assert.ok(fn.includes('publishableSql()'), 'the gate is applied in the query, not after it');
|
|
331
467
|
});
|
|
468
|
+
|
|
469
|
+
// ── the route's half of the page window (task 1003560 → 1003561) ─────────────
|
|
470
|
+
|
|
471
|
+
test('the route pages with ?limit=&offset= and reports back the window it used', async () => {
|
|
472
|
+
// ADR 0119's contract, which this feed was the last public list to hold out on.
|
|
473
|
+
const { body, listArgs } = await callFeaturedFeed([{ ...PUBLIC_ROW }], { limit: '2', offset: '4' });
|
|
474
|
+
assert.equal(listArgs[0].limit, 2);
|
|
475
|
+
assert.equal(listArgs[0].offset, 4);
|
|
476
|
+
assert.deepEqual(body.page, { limit: 2, offset: 4 });
|
|
477
|
+
assert.ok(Array.isArray(body.projects),
|
|
478
|
+
'`projects` keeps its name and its place — every consumer destructures it, so the wire is additive');
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test('an unparamed read still asks for the whole ceiling — the other-repo orbs site is unchanged', async () => {
|
|
482
|
+
const { body, listArgs } = await callFeaturedFeed([{ ...PUBLIC_ROW }]);
|
|
483
|
+
assert.equal(listArgs[0].limit, pi.PUBLIC_FEED_MAX_PAGE_SIZE);
|
|
484
|
+
assert.equal(listArgs[0].offset, 0);
|
|
485
|
+
assert.deepEqual(body.page, { limit: pi.PUBLIC_FEED_MAX_PAGE_SIZE, offset: 0 });
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test('?limit= can only SHRINK a page, so paging cannot aim more fan-out than one request already could', async () => {
|
|
489
|
+
// The constraint that makes paging safe on a public, CORS-open, unauthenticated route
|
|
490
|
+
// whose every cold row costs four outbound fetches at somebody else's server.
|
|
491
|
+
const { listArgs } = await callFeaturedFeed([], { limit: '99999' });
|
|
492
|
+
assert.equal(listArgs[0].limit, pi.PUBLIC_FEED_MAX_PAGE_SIZE);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test('no cursor: the page token is an offset, which encodes nothing about any row', async () => {
|
|
496
|
+
// Deliberate, and a security property as much as a style one. An opaque cursor over this
|
|
497
|
+
// feed would have to encode a sort position, and the obvious encoding — the last row's
|
|
498
|
+
// timestamp — is the very side-channel the ORDER BY above was rewritten to close.
|
|
499
|
+
const { body } = await callFeaturedFeed([{ ...PUBLIC_ROW }], { offset: '3' });
|
|
500
|
+
assert.deepEqual(Object.keys(body.page).sort(), ['limit', 'offset']);
|
|
501
|
+
assert.equal(body.page.offset, 3);
|
|
502
|
+
});
|