@bongos/core 1.19.567 → 1.19.569

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.
@@ -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
+ });