@bongos/core 1.19.566 → 1.19.568

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,77 @@
1
+ // modules/platform-identity/origin-containment.js — THE rule deciding whether a URL a
2
+ // REMOTE instance handed us may be published under that instance's name: it has to point
3
+ // AT OR BELOW the origin that reported it. One question, one file, so the two surfaces
4
+ // that ask it cannot answer it differently (the publish-gate.js precedent).
5
+ //
6
+ // TWO CALLERS, which is why this stopped being a private helper (task 1003670, ADR 0254):
7
+ // • a pushed ship link on a builder's public portfolio — platform-identity.js
8
+ // `sanitizeRecentShips`, bounded by the AUTHENTICATED client's registered origin
9
+ // • `hall_origin` on the public projects feed — project-stats.js `hallOrigin`, bounded
10
+ // by the project's own catalog origin
11
+ // The second arrived WITHOUT the rule: it checked only that the advertised hall host
12
+ // DIFFERED from the project's site host, which is the exact opposite of containment — it
13
+ // admits every host on the internet except the one that is certainly safe. A remote
14
+ // instance answering `GET /api/bongos` with `hall: "https://evil.example"` had that link
15
+ // relayed into an `<a href>` on the hub, under the victim project's name.
16
+ //
17
+ // WHY IT GOT URGENT. [ADR 0252](../../docs/adr/0252-the-self-hosted-visibility-report-is-a-pull-and-auto-appear-buys-a-retraction-lever.md)
18
+ // §5 named this gap in the same breath as deciding that a self-hosted project
19
+ // AUTO-APPEARS on the map. Under control-plane provisioning the origin set was bounded by
20
+ // whom the operator had provisioned; auto-appear replaces that bound with "whoever points
21
+ // a domain at the hub", which turns a curated-origin risk into a phishing hop anyone can
22
+ // mint. Containment is what makes the relayed link mean something again: a hall link on
23
+ // the hub can only ever address the project it is filed under.
24
+ //
25
+ // DOWNWARD ONLY, deliberately. The mirror rule ("the link host is an ANCESTOR of the
26
+ // reporting origin") looks symmetric and is not: on a shared multi-tenant apex it would
27
+ // let an instance registered at foo.github.io publish links on bare github.io, which it
28
+ // does not control. Nothing needs the upward direction — a reporting origin is the
29
+ // instance's own public origin — so it is not offered.
30
+ //
31
+ // WHY ITS OWN FILE. The rule could not simply be shared where it sat: `platform-identity.js`
32
+ // is AT its file-size ratchet (scripts/gds/fitness-ratchets.js, 1500 lines, no headroom)
33
+ // and `project-stats.js` is a different file besides — the account-visibility.js /
34
+ // publish-gate.js / projects-feed.js precedent, taken here for the same reason. It is
35
+ // deliberately NOT re-exported from `platform-identity.js`: both callers require it
36
+ // directly, so there is exactly one name for one answer.
37
+ 'use strict';
38
+
39
+ // A relayed URL is REFUSED at the cap, never truncated. Slicing a URL changes where it
40
+ // points, and a shortened one that still parses is a worse answer than no link at all —
41
+ // which is why this is a bound and not a `slice()` (contrast `project-stats.js:httpUrl`,
42
+ // whose cap is a display bound on an <img src>, not a containment decision).
43
+ const MAX_CONTAINED_URL_LENGTH = 500;
44
+
45
+ // urlWithinOrigin — `raw` when it is an http(s) URL at or below `origin`'s host, else
46
+ // null. Every refusal is a null: never a throw, never a repaired value. The callers
47
+ // publish the link exactly as reported, or they publish nothing.
48
+ //
49
+ // Each check earns its place, and each is a real reported attack shape:
50
+ // • http(s) only — a `javascript:` href relayed into an anchor is stored XSS, and the
51
+ // hub's clients scheme-check again only because defence in depth is cheap.
52
+ // • no username/password — `https://cloudbongos.com@evil.example/x` reads as the
53
+ // victim's domain to a human and resolves to the attacker's; the host check alone
54
+ // passes it, because the host IS evil.example.
55
+ // • host equals the registered host, or is a DOT-ANCHORED suffix of it. The dot is
56
+ // load-bearing: without it `notcloudbongos.com` ends with `cloudbongos.com`, and
57
+ // every lookalike domain contains the domain it imitates.
58
+ //
59
+ // `max` is a parameter because the two callers carry different display bounds; the
60
+ // default is the ship-link cap this rule was lifted from, so that call site is unchanged.
61
+ function urlWithinOrigin(raw, origin, max = MAX_CONTAINED_URL_LENGTH) {
62
+ if (typeof raw !== 'string' || !raw || raw.length > max) return null;
63
+ let u, o;
64
+ try { u = new URL(raw); o = new URL(String(origin || '')); } catch { return null; }
65
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
66
+ if (u.username || u.password) return null;
67
+ const host = u.hostname.toLowerCase(), registered = o.hostname.toLowerCase();
68
+ // An origin with no host at all (`file:`, `data:` — a stored value is not a validated
69
+ // one) leaves `registered` empty, and `.` is then the whole suffix: a trailing-dot
70
+ // FQDN like `evil.example.` is legal in a URL and WOULD match it. Refuse first, so a
71
+ // hostless origin can never be the thing that authorises a link.
72
+ if (!registered) return null;
73
+ if (host !== registered && !host.endsWith(`.${registered}`)) return null;
74
+ return raw;
75
+ }
76
+
77
+ module.exports = { urlWithinOrigin };
@@ -19,6 +19,11 @@ const api = require('../../src/module-api');
19
19
  // re-derives one (see account-visibility.js).
20
20
  const visibility = require('./account-visibility');
21
21
  const terms = require('./terms-acceptance');
22
+ // THE downward-only containment rule for a URL a remote instance reported (task
23
+ // 1003670, ADR 0254). Lifted out of this file so the projects feed's `hall_origin`
24
+ // reads the SAME rule as a ship link instead of the "different host" check it had —
25
+ // see origin-containment.js for why it is downward only and why it lives there.
26
+ const { urlWithinOrigin } = require('./origin-containment');
22
27
 
23
28
  const { pool: defaultPool } = api;
24
29
  const { ACCOUNT_VISIBILITY, accountVisibleSql, rollupVisibleSql, isRollupVisible, scoutingListableSql } = visibility;
@@ -317,7 +322,7 @@ const {
317
322
  // D1/D2a/D3; R06, task 1002325). Its own file for the publish-gate.js reason: this
318
323
  // file is at the size ratchet. Re-exported below, so the surface is unchanged.
319
324
  const {
320
- ORB_BY_STATE, orbForState, isStealthProject, feedShapeFor, listPublicProjects,
325
+ ORB_BY_STATE, orbForState, isStealthProject, feedShapeFor, listPublicProjects, PUBLIC_FEED_MAX_PAGE_SIZE,
321
326
  } = require('./projects-feed');
322
327
 
323
328
  // Upsert a catalog row (keyed by origin). Called when a client registers (so every
@@ -614,37 +619,12 @@ const MAX_DISCIPLINE_LENGTH = 32;
614
619
  // buggy or hostile.
615
620
  const MAX_RECENT_SHIPS = 3;
616
621
  const MAX_SHIP_TITLE_LENGTH = 200;
617
- const MAX_SHIP_URL_LENGTH = 500;
618
622
  // How far into a pushed array the hub will even LOOK. Validating first and
619
623
  // capping second is what stops a few malformed entries from crowding the good
620
624
  // ones out of the three slots; bounding the scan is what stops an unbounded
621
625
  // array from buying unbounded work with one request.
622
626
  const MAX_RECENT_SHIPS_SCANNED = 20;
623
627
 
624
- // May this ship link be stored? A ship link is the ONLY free-text URL on a public
625
- // builder portfolio, which makes it the one place a client-secret holder could
626
- // plant a phishing hop under someone else's name. So it must point AT OR BELOW
627
- // the instance that reported it: the client's own REGISTERED origin host, or a
628
- // subdomain of it (an instance's hall may live at builders.<domain> while its
629
- // registered origin is <domain>). Anything else — another site, a non-http
630
- // scheme, embedded credentials — stores no link rather than a wrong one.
631
- //
632
- // DOWNWARD ONLY, deliberately. The mirror rule ("the link host is an ANCESTOR of
633
- // the registered origin") looks symmetric and is not: on a shared multi-tenant
634
- // apex it would let a client registered at foo.github.io publish links on bare
635
- // github.io, which it does not control. Nothing needs the upward direction — a
636
- // registered origin is the instance's own public origin — so it is not offered.
637
- function shipUrlWithinClientOrigin(raw, clientOrigin) {
638
- if (typeof raw !== 'string' || !raw || raw.length > MAX_SHIP_URL_LENGTH) return null;
639
- let u, o;
640
- try { u = new URL(raw); o = new URL(String(clientOrigin || '')); } catch { return null; }
641
- if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
642
- if (u.username || u.password) return null;
643
- const host = u.hostname.toLowerCase(), registered = o.hostname.toLowerCase();
644
- if (host !== registered && !host.endsWith(`.${registered}`)) return null;
645
- return raw;
646
- }
647
-
648
628
  // Shape and bound the pushed recent_ships[] before it is stored (the
649
629
  // upsertActivity rank/disciplines precedent, ADR 0171 D7 — validation lives next
650
630
  // to the data where no route can forget it). Returns AT MOST MAX_RECENT_SHIPS
@@ -652,6 +632,15 @@ function shipUrlWithinClientOrigin(raw, clientOrigin) {
652
632
  // title is dropped entirely rather than stored half-empty, and a bad date or an
653
633
  // off-origin link becomes null on an otherwise good row. Always an array, never
654
634
  // null — every reader gets a list without a coalesce.
635
+ //
636
+ // THE LINK IS THE DANGEROUS FIELD. A ship link is the only free-text URL on a public
637
+ // builder portfolio, so it is the one place a client-secret holder could plant a
638
+ // phishing hop under someone else's name. `urlWithinOrigin` is what stops that — it
639
+ // keeps a link only where it points AT OR BELOW the reporting client's registered
640
+ // origin (its own host, or a subdomain: an instance's hall may live at
641
+ // builders.<domain> while its registered origin is <domain>). The rule now lives in
642
+ // origin-containment.js because the projects feed's `hall_origin` needs the identical
643
+ // answer and used to give a different one (task 1003670, ADR 0254).
655
644
  function sanitizeRecentShips(raw, clientOrigin) {
656
645
  return (Array.isArray(raw) ? raw : [])
657
646
  .slice(0, MAX_RECENT_SHIPS_SCANNED)
@@ -663,7 +652,7 @@ function sanitizeRecentShips(raw, clientOrigin) {
663
652
  id: Number.isSafeInteger(id) && id > 0 ? id : null,
664
653
  title: typeof s.title === 'string' && s.title.trim() ? s.title.slice(0, MAX_SHIP_TITLE_LENGTH) : null,
665
654
  shipped_at: Number.isFinite(at) ? new Date(at).toISOString() : null,
666
- url: shipUrlWithinClientOrigin(s.url, clientOrigin),
655
+ url: urlWithinOrigin(s.url, clientOrigin),
667
656
  };
668
657
  })
669
658
  .filter((s) => s.id !== null && s.title !== null)
@@ -674,7 +663,7 @@ function sanitizeRecentShips(raw, clientOrigin) {
674
663
  // Descriptive analytics — never authority. Refreshes last_active on each report.
675
664
  // `clientOrigin` is the AUTHENTICATED client's registered origin (never a
676
665
  // caller-named one) and exists only to bound the ship links — see
677
- // shipUrlWithinClientOrigin.
666
+ // origin-containment.js:urlWithinOrigin.
678
667
  async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped = 0, karma = 0, rank = null, disciplines = [], recentShips = [], clientOrigin = null }, { pool = defaultPool } = {}) {
679
668
  // api.LIVE_RANK_LADDER is a published doorway export (src/module-api.js, a
680
669
  // lazy getter over db-kernel's canonical four-rank ladder).
@@ -1437,6 +1426,7 @@ module.exports = {
1437
1426
  PROJECT_SHAPES,
1438
1427
  getProject,
1439
1428
  listPublicProjects,
1429
+ PUBLIC_FEED_MAX_PAGE_SIZE,
1440
1430
  listAllProjects,
1441
1431
  setProjectFeatured,
1442
1432
  ORB_BY_STATE,
@@ -30,6 +30,12 @@ const fs = require('node:fs');
30
30
  const path = require('node:path');
31
31
  const dns = require('node:dns').promises;
32
32
  const net = require('node:net');
33
+ // THE downward-only containment rule for a URL a remote instance reported (task
34
+ // 1003670, ADR 0254). `hall_origin` is the only value in this file that becomes an
35
+ // `<a href>` on the hub rather than an `<img src>`, so it is the only one that has to
36
+ // prove it belongs to the project it is filed under; the ship-link path in
37
+ // platform-identity.js reads the identical rule from the identical place.
38
+ const { urlWithinOrigin } = require('./origin-containment');
33
39
 
34
40
  const TTL_MS = 10 * 60 * 1000; // owner's choice, 2026-08-13
35
41
  const FETCH_TIMEOUT_MS = 2500; // per request
@@ -209,8 +215,34 @@ function shippedStats(body) {
209
215
  // <site>/builders, where the site's own door already leads) and null when the
210
216
  // project does not say. Task 1003257, owner decision (a): a door only where it
211
217
  // opens somewhere else.
218
+ //
219
+ // TWO TESTS, and the first one is the security one (task 1003670, ADR 0254). This
220
+ // value comes out of a REMOTE instance's own discovery index and the hub relays it
221
+ // into an `<a href>` under that project's name, so it must be CONTAINED — at or
222
+ // below the project's own origin — before it may differ from anything. The check
223
+ // used to be "differs from the site host" alone, which is containment inverted: it
224
+ // admitted every host on the internet except the one that was certainly safe, so a
225
+ // hostile or compromised instance could publish `hall: "https://evil.example"` and
226
+ // have the hub link it. [ADR 0252](../../docs/adr/0252-the-self-hosted-visibility-report-is-a-pull-and-auto-appear-buys-a-retraction-lever.md)
227
+ // §5 named the gap when it decided self-hosted projects AUTO-APPEAR: the origin set
228
+ // stopped being bounded by whom the operator provisioned.
229
+ //
230
+ // Containment first (`urlWithinOrigin` — the SAME rule a pushed ship link obeys,
231
+ // http(s)-only, no embedded credentials, dot-anchored suffix, refused rather than
232
+ // truncated at the cap), and only then the door test. The door test still compares
233
+ // `.host` WITH the port, because a hall on another port of the same machine really
234
+ // does open somewhere else; containment compares hostnames, because a port is not a
235
+ // trust boundary. Net effect: a hall link survives only as a strict SUBDOMAIN (or
236
+ // another port) of the project's own origin.
237
+ //
238
+ // The cost, stated so it is not mistaken for a bug: a project whose hall is a
239
+ // SIBLING of its site (site www.example.com, hall builders.example.com) loses the
240
+ // glyph. The hall is still reachable; the hub just stops vouching for the address.
241
+ // That is the corpus's standing asymmetry — narrowing is free, widening is what
242
+ // costs — and the upward rule that would admit it is the one origin-containment.js
243
+ // refuses to offer, because on a shared apex it hands away every neighbour.
212
244
  function hallOrigin(index, origin) {
213
- const hall = httpUrl(index?.hall);
245
+ const hall = urlWithinOrigin(index?.hall, origin, URL_MAX);
214
246
  if (!hall) return null;
215
247
  try {
216
248
  return new URL(hall).host.toLowerCase() === siteUrl(origin).host.toLowerCase() ? null : hall;
@@ -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
  };
@@ -106,6 +106,10 @@ module.exports = function myProjectsRoutes() {
106
106
  // project's API discovery index through the same cached enrichment the
107
107
  // catalog uses (no hub table stores it). Best-effort: a project that does
108
108
  // not answer gets null, never a failed listing.
109
+ // The address is CONTAINED to the project's own origin before it ever
110
+ // reaches this list (project-stats.js:hallOrigin, task 1003670 / ADR 0254)
111
+ // — a remote instance may advertise any hall it likes, and this route
112
+ // renders one into an href just as the public feed does.
109
113
  let stats = {};
110
114
  try {
111
115
  stats = await projectStats.getProjectStats(rows.map((m) => m.project_origin).filter(Boolean));
@@ -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.566",
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.566",
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.566",
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.566'; // 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');