@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 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.567",
6
- "core_contract": "1.19.567",
7
- "source_commit": "086c24f62d0d594e3949cda82f35b6c73105e9c8",
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:18:34.727Z",
9
+ "built_at": "2026-09-06T20:30:33.304Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 447,
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": 2508,
20
- "tree_sha256": "0de4688c7206008f5e7ce542823b8efe4c438fc7debe1dffb116e3cfe63558d4",
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": "f7c16a633c0f5c3ba13421815d08f5fb935b8737433acffe72fef018adffbf25"
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": "2e2089caa50d1b7442fee96b14f758e6e29974490a8729cfa9401a59e70077b4"
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": "b4c2891985427587d5a065b6e7d69638c5cd554d42d5eb22572b9c82f898431f"
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": "cb50b3fb2a66dc2e8c4e69f97055192f33e21fb33089f2c77313f43ae6daea09"
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": "5cccd4a776abc25f0e57814963b1dc00b8a7a49b5024d2c0b88c85d8312091f1"
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": "fac8b2f750a5a9fb3c05290223292449b04a45c33b6976da285126616a8e9e91"
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": "af098885cd3d7e83a9302c7eb8213c3448d73ab79be53b2b5ff1467fbab3685a"
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": "88e992e7dc0acfb523d3c55d84184674c584a95ae5e9b9a7339e1ef8d39954c8"
7535
+ "sha256": "8ada761ecbd4a9f7adad006ce8f1d51fcd120001c4f7cdfd305d03c6e61a72fd"
7531
7536
  },
7532
7537
  {
7533
7538
  "path": "package.json",
7534
7539
  "mode": "0000644",
7535
- "sha256": "ef5db9f2e98f980f0eea7ee5409a29d544ac73edcf33a15f8ecde5863db87e8d"
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": "318a0b749a46e513641ebec57124282b811631fe0037a166f50abd28d3b63f40"
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": "c5d4a17ba9765069a7850fef5d3843e076fbd08edbc7d690a36e2ff4299d10c2"
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.
@@ -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 |
@@ -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
- // THREE ANSWERS, and they belong together because each is only safe given the others:
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
- // The feed's hard ceiling. Until R06 the listing was bounded by an Archon choosing to
107
- // feature a project; the swap to `publishable` replaced that human bound with self-service,
108
- // on a route that is public, CORS-open, unauthenticated AND drives one outbound stats fetch
109
- // per row. A cap is the cheap half of that trade — the honest half is pagination, which is
110
- // its own task; this constant only stops the tail from being unbounded meanwhile. Set far
111
- // above the live catalog, so it truncates nothing today.
112
- const PUBLIC_FEED_MAX_PROJECTS = 500;
113
-
114
- // Every project ON THE MAP, in the public projection (no owner_github_id, no status).
115
- // `visibility` is projected because feedShapeFor() derives each row's state + orb from it;
116
- // it is spent there and never reaches the wire. created_at is the LISTED date — when the
117
- // hub first catalogued the row, not when the project began (task 1002778). updated_at may
118
- // sort but never projects: feature toggles and client re-registration bump it, so as a
119
- // public "last updated" it would lie. `description` is the publish BAR, spent in the
120
- // predicate, never a card field.
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
- async function listPublicProjects({ pool = defaultPool } = {}) {
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 featured DESC, updated_at DESC
142
- LIMIT ${PUBLIC_FEED_MAX_PROJECTS}`,
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
- PUBLIC_FEED_MAX_PROJECTS,
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 } = api;
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
- router.get('/projects/featured', api.publicProjectFeedRateLimit, async (_req, res) => {
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
- const projects = await pi.listPublicProjects({ pool });
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.567",
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.567",
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.567",
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.567'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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
- assert.match(sql, /ORDER BY featured DESC/, 'it keeps the job D3 leaves it: ranking');
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 public feed is CAPPED — the swap to self-service removed the human bound on its size', async () => {
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 one outbound stats fetch per row. The cap is
204
- // the cheap half of that trade; pagination is its own task.
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.match(pool.calls[0].text, new RegExp(`LIMIT ${feed.PUBLIC_FEED_MAX_PROJECTS}\\b`));
208
- assert.equal(Number.isInteger(feed.PUBLIC_FEED_MAX_PROJECTS), true,
209
- 'the cap is interpolated into SQL, so it must be a literal integer and never a caller value');
210
- assert.ok(feed.PUBLIC_FEED_MAX_PROJECTS > 0);
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/, 'curation noise — it may sort, it may never project');
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
+ });