@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.
- package/.bongos-core.json +42 -27
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0255-a-public-lists-ordering-is-part-of-its-payload.md +169 -0
- package/docs/adr/0256-the-visibility-ceiling-is-the-sign-in-gate-too-and-the-reader-moves-to-the-composer.md +204 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +7 -3
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/platform-identity/platform-identity.js +2 -1
- package/modules/platform-identity/projects-feed.js +126 -20
- package/modules/platform-identity/routes/projects.js +29 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +5 -3
- package/src/bongos/auth-admission.js +48 -50
- package/src/bongos/project-door.js +75 -2
- package/src/bongos/routes/auth.js +17 -4
- package/src/bongos/routes/instance.js +7 -0
- package/src/branding.js +2 -1
- package/src/module-api.js +1 -1
- package/tests/admission_ceiling_gate.mjs +203 -0
- package/tests/joinability_invite_only.mjs +7 -1
- package/tests/visible_projects_feed.mjs +184 -13
|
@@ -16,6 +16,13 @@
|
|
|
16
16
|
// 3. WHAT A BLACK HOLE MAY SAY. Stealth exposes EXISTENCE, never IDENTITY (D2a), and
|
|
17
17
|
// the redaction is at the boundary because the orbs page is a static site outside
|
|
18
18
|
// this repo and could not be trusted to redact even in principle.
|
|
19
|
+
// 4. IN WHAT ORDER, and on what page (task 1003561, which absorbed task 1003560; ADR
|
|
20
|
+
// 0255). The ORDER BY is a covert column, and it was leaking one: sorting by
|
|
21
|
+
// `updated_at` — which this projection deliberately withholds — MOVED a black hole
|
|
22
|
+
// whenever its hidden project was touched, handing an observer the stable,
|
|
23
|
+
// activity-tracking handle STEALTH_ANONYMOUS_KEEPS exists to refuse. A row is now
|
|
24
|
+
// ordered only by what it publishes, anonymous rows are one keyed-permuted trailing
|
|
25
|
+
// block, and the 500-row cap became a page window instead of a silent truncation.
|
|
19
26
|
//
|
|
20
27
|
// The stealth assertions are the security spec: they assert ABSENT FIELDS, not absent
|
|
21
28
|
// pixels, which is what R10 (task 1002329) then demonstrates live.
|
|
@@ -194,20 +201,147 @@ test('featuring no longer GATES presence — it only ranks among projects alread
|
|
|
194
201
|
const sql = pool.calls[0].text;
|
|
195
202
|
const where = (sql.match(/WHERE([\s\S]*?)ORDER BY/i) || [, ''])[1];
|
|
196
203
|
assert.doesNotMatch(where, /featured/, 'featured must not appear in the predicate at all');
|
|
197
|
-
|
|
204
|
+
const order = (sql.match(/ORDER BY([\s\S]*?)LIMIT/i) || [, ''])[1];
|
|
205
|
+
assert.match(order, /AND featured\) DESC/,
|
|
206
|
+
'it keeps the job D3 leaves it: ranking — now guarded, so it ranks only rows that publish a name');
|
|
198
207
|
});
|
|
199
208
|
|
|
200
|
-
test('the
|
|
209
|
+
test('the cap is now a PAGE WINDOW — bound as parameters and clamped to the fan-out ceiling', async () => {
|
|
201
210
|
// Until R06 the listing was bounded by an Archon choosing to feature a project. Making
|
|
202
211
|
// it `publishable` replaced that bound with self-service, on a route that is public,
|
|
203
|
-
// CORS-open, unauthenticated AND fans out
|
|
204
|
-
//
|
|
212
|
+
// CORS-open, unauthenticated AND fans out up to four outbound stats fetches per cold
|
|
213
|
+
// row. Task 1002325 answered with a hard LIMIT 500 — the cheap half. This is the honest
|
|
214
|
+
// half (task 1003560, merged into 1003561): the same 500 still bounds one request's
|
|
215
|
+
// fan-out, but a caller can now reach past it instead of being truncated in silence.
|
|
216
|
+
const pool = mockPool([{ rows: [] }]);
|
|
217
|
+
await pi.listPublicProjects({ pool, limit: 10000, offset: 25 });
|
|
218
|
+
const { text, params } = pool.calls[0];
|
|
219
|
+
assert.match(text, /LIMIT \$2::int OFFSET \$3::int/,
|
|
220
|
+
'the window is BOUND, never interpolated — it is the first caller-supplied value this SQL has ever carried');
|
|
221
|
+
assert.equal(params[1], feed.PUBLIC_FEED_MAX_PAGE_SIZE,
|
|
222
|
+
'a caller cannot ask past the ceiling, so no request can aim more fan-out than one could before');
|
|
223
|
+
assert.equal(params[2], 25);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('no window at all means the OLD hard cap, so every pre-paging consumer is unchanged', async () => {
|
|
227
|
+
// The compatibility guarantee, asserted rather than trusted: one consumer of this feed
|
|
228
|
+
// is a static orbs site in ANOTHER REPO that this commit cannot update. A default page
|
|
229
|
+
// smaller than the old LIMIT would have silently shrunk somebody else's map.
|
|
205
230
|
const pool = mockPool([{ rows: [] }]);
|
|
206
231
|
await pi.listPublicProjects({ pool });
|
|
207
|
-
assert.
|
|
208
|
-
assert.equal(
|
|
209
|
-
|
|
210
|
-
|
|
232
|
+
assert.deepEqual(pool.calls[0].params.slice(1), [feed.PUBLIC_FEED_MAX_PAGE_SIZE, 0]);
|
|
233
|
+
assert.equal(feed.PUBLIC_FEED_MAX_PAGE_SIZE, 500, 'the number the feed was capped at before it could page');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('a nonsense window is CLAMPED, never rejected and never unbounded', async () => {
|
|
237
|
+
// Clamped in listPublicProjects as well as at the route: this is a module-public
|
|
238
|
+
// function and the ceiling is a fan-out bound, so it may not depend on every future
|
|
239
|
+
// caller remembering to pass one.
|
|
240
|
+
const pool = mockPool([{ rows: [] }, { rows: [] }]);
|
|
241
|
+
await pi.listPublicProjects({ pool, limit: -1, offset: -50 });
|
|
242
|
+
assert.deepEqual(pool.calls[0].params.slice(1), [feed.PUBLIC_FEED_MAX_PAGE_SIZE, 0]);
|
|
243
|
+
await pi.listPublicProjects({ pool, limit: 'all', offset: 'next' });
|
|
244
|
+
assert.deepEqual(pool.calls[1].params.slice(1), [feed.PUBLIC_FEED_MAX_PAGE_SIZE, 0]);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// ── the ORDER BY is part of the payload (task 1003561, ADR 0255) ─────────────
|
|
248
|
+
|
|
249
|
+
// The ORDER BY's terms, with the identified-state predicate masked so a naive comma
|
|
250
|
+
// split survives the comma inside its IN-list.
|
|
251
|
+
function orderTerms(sql) {
|
|
252
|
+
const clause = (sql.match(/ORDER BY([\s\S]*?)LIMIT/i) || [, ''])[1];
|
|
253
|
+
return clause.split(feed.IDENTIFIED_SQL).join('<identified>')
|
|
254
|
+
.split(',').map((t) => t.trim().replace(/\s+/g, ' ')).filter(Boolean);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function feedSql(opts = {}) {
|
|
258
|
+
const pool = mockPool([{ rows: [] }]);
|
|
259
|
+
await pi.listPublicProjects({ pool, ...opts });
|
|
260
|
+
return pool.calls[0];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
test('updated_at is gone from the query ENTIRELY — it may no longer even sort', async () => {
|
|
264
|
+
// THE bug (task 1003561). A public list's ORDER BY is a covert column: sorting by a
|
|
265
|
+
// value the projection withholds publishes it anyway, one comparison at a time. For a
|
|
266
|
+
// black hole that meant its position moved the moment its hidden project was touched.
|
|
267
|
+
const { text } = await feedSql();
|
|
268
|
+
assert.doesNotMatch(text, /updated_at/,
|
|
269
|
+
'not in the projection, and since task 1003561 not in the ORDER BY either');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('anonymous rows are collated into ONE TRAILING BLOCK, never interleaved with named ones', async () => {
|
|
273
|
+
// The separation is what makes position meaningless: a black hole neighbours no named
|
|
274
|
+
// row, so it can no longer be bracketed between two rows whose own order is visible.
|
|
275
|
+
const terms = orderTerms((await feedSql()).text);
|
|
276
|
+
assert.equal(terms[0], '<identified> DESC',
|
|
277
|
+
'the FIRST sort term partitions the two blocks, so no later term ever compares across them');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('no identified sort key is ever applied to an anonymous row', async () => {
|
|
281
|
+
// Blanking terms 2-4 is the whole trick. Without it, the trailing block would simply be
|
|
282
|
+
// re-sorted by featured, then created_at, then origin — none of which a black hole
|
|
283
|
+
// publishes, and created_at in particular would narrow WHICH project it is to anyone
|
|
284
|
+
// who watched the catalog fill up.
|
|
285
|
+
const terms = orderTerms((await feedSql()).text);
|
|
286
|
+
for (const [i, key] of [[1, 'featured'], [2, 'created_at'], [3, 'origin']]) {
|
|
287
|
+
assert.ok(terms[i].includes(key), `term ${i} ranks identified rows by ${key}`);
|
|
288
|
+
assert.ok(terms[i].includes('<identified>'),
|
|
289
|
+
`and it is guarded, so ${key} never orders an anonymous row`);
|
|
290
|
+
}
|
|
291
|
+
assert.ok(terms[3].includes('ELSE md5('),
|
|
292
|
+
'the anonymous block gets a permutation where the identified block gets its public keys');
|
|
293
|
+
assert.equal(terms.length, 4, 'and there is no fifth, unguarded term');
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('the anonymous permutation reads a BOUND salt — the key never enters the SQL text', async () => {
|
|
297
|
+
const { text, params } = await feedSql({ now: 0 });
|
|
298
|
+
assert.match(text, /md5\(origin \|\| \$1::text\)/, 'the hash reads its salt from $1');
|
|
299
|
+
assert.equal(params[0], feed.anonymousOrderSalt(0));
|
|
300
|
+
assert.ok(!text.includes(params[0]), 'and the secret is never interpolated into the query');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('the permutation salt is KEYED and re-deals on an epoch — it is not a clock reading', () => {
|
|
304
|
+
// Two properties, and both are load-bearing. SECRET: origins are guessable (they are
|
|
305
|
+
// domain names), so a salt an observer could predict would let them hash their
|
|
306
|
+
// candidates, match the ranks and de-anonymise the whole block in one pass. ROTATING:
|
|
307
|
+
// a permanently stable order would make POSITION the durable per-project key that
|
|
308
|
+
// STEALTH_ANONYMOUS_KEEPS refuses to put on the wire.
|
|
309
|
+
const epoch = feed.ANONYMOUS_ORDER_EPOCH_MS;
|
|
310
|
+
const salt = feed.anonymousOrderSalt(1000);
|
|
311
|
+
assert.equal(feed.anonymousOrderSalt(epoch - 1), salt,
|
|
312
|
+
'stable within one period, so the pages of a single listing agree with each other');
|
|
313
|
+
assert.notEqual(feed.anonymousOrderSalt(epoch + 1), salt,
|
|
314
|
+
're-dealt across periods, so today\'s rank is not tomorrow\'s black hole');
|
|
315
|
+
const [key, period] = salt.split(':');
|
|
316
|
+
assert.equal(period, '0');
|
|
317
|
+
assert.match(key, /^[0-9a-f]{32}$/, 'a random per-process key — not a timestamp, not a constant');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('SQL and feedShapeFor cannot disagree about who is anonymous — both read ORB_BY_STATE', () => {
|
|
321
|
+
// The two halves of one decision, written in two languages. A disagreement would not be
|
|
322
|
+
// cosmetic: SQL calling a state identified while feedShapeFor redacts it would POSITION
|
|
323
|
+
// the row by a created_at that its own wire row does not carry — exactly the leak this
|
|
324
|
+
// ordering closes, reopened one enum value later.
|
|
325
|
+
for (const state of feed.IDENTIFIED_STATES) {
|
|
326
|
+
assert.notEqual(feed.orbForState(state), 'black-hole', `${state} sorts identified, so it must render identified`);
|
|
327
|
+
assert.equal(feed.feedShapeFor({ ...PUBLIC_ROW, visibility: state }).name, 'Mercury');
|
|
328
|
+
assert.ok(feed.IDENTIFIED_SQL.includes(`'${state}'`), 'and the SQL names it too');
|
|
329
|
+
}
|
|
330
|
+
assert.deepEqual([...feed.IDENTIFIED_STATES].sort(), ['private', 'public']);
|
|
331
|
+
assert.ok(!feed.IDENTIFIED_STATES.includes(feed.FALLBACK_PROJECT_STATE),
|
|
332
|
+
'the anonymous fallback is never in the identified set');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test('the identified set is an ALLOW-list, so an unknown state SORTS anonymous too', () => {
|
|
336
|
+
// The same fail-closed direction orbStateOf() already took for rendering. A fourth enum
|
|
337
|
+
// value added upstream and forgotten here draws as a black hole AND sorts into the
|
|
338
|
+
// anonymous block; a deny-list would have drawn it anonymous but sorted it by its
|
|
339
|
+
// created_at, which is the worst of both.
|
|
340
|
+
assert.match(feed.IDENTIFIED_SQL, /^\(visibility IN \(/,
|
|
341
|
+
'an allow-list: identified by naming, never identified by omission');
|
|
342
|
+
assert.doesNotMatch(feed.IDENTIFIED_SQL, /NOT IN/);
|
|
343
|
+
assert.ok(!feed.IDENTIFIED_SQL.includes("'members-only'"));
|
|
344
|
+
assert.equal(feed.orbForState('members-only'), 'black-hole');
|
|
211
345
|
});
|
|
212
346
|
|
|
213
347
|
test('listPublicProjects projects `visibility`, the column the orb is derived from', async () => {
|
|
@@ -229,7 +363,8 @@ test('listPublicProjects stays a PUBLIC projection — no owner, no status, no b
|
|
|
229
363
|
assert.ok(sel.trim(), 'the projection is an explicit SELECT list');
|
|
230
364
|
assert.doesNotMatch(sel, /\*/, 'no star projection on a public route');
|
|
231
365
|
assert.doesNotMatch(sel, /owner_github_id/, 'public projection must not leak owner id');
|
|
232
|
-
assert.doesNotMatch(sel, /updated_at/,
|
|
366
|
+
assert.doesNotMatch(sel, /updated_at/,
|
|
367
|
+
'curation noise: it never projected, and since task 1003561 it does not sort either');
|
|
233
368
|
assert.doesNotMatch(sel, /\bdescription\b/,
|
|
234
369
|
'description is the publish BAR, not a card field — it is spent in the predicate');
|
|
235
370
|
assert.match(sel, /created_at/, 'the LISTED date stays public');
|
|
@@ -241,16 +376,17 @@ test('listPublicProjects stays a PUBLIC projection — no owner, no status, no b
|
|
|
241
376
|
// The real router, driven with a fake req/res. `pi.listPublicProjects` and the stats
|
|
242
377
|
// enrichment are stubbed so the test needs neither Postgres nor the network — what is
|
|
243
378
|
// under test is the ROUTE's composition, not either dependency.
|
|
244
|
-
async function callFeaturedFeed(rows) {
|
|
379
|
+
async function callFeaturedFeed(rows, query) {
|
|
245
380
|
const router = projectsRoutes();
|
|
246
381
|
const layer = router.stack.find((l) => l.route && l.route.path === '/projects/featured'
|
|
247
382
|
&& l.route.methods.get);
|
|
248
383
|
assert.ok(layer, 'GET /projects/featured must still be a route on this router');
|
|
249
384
|
|
|
250
385
|
const askedFor = [];
|
|
386
|
+
const listArgs = [];
|
|
251
387
|
const savedList = pi.listPublicProjects;
|
|
252
388
|
const savedStats = projectStats.getProjectStats;
|
|
253
|
-
pi.listPublicProjects = async () => rows;
|
|
389
|
+
pi.listPublicProjects = async (args) => { listArgs.push(args || {}); return rows; };
|
|
254
390
|
projectStats.getProjectStats = async (origins) => { askedFor.push(...origins); return {}; };
|
|
255
391
|
try {
|
|
256
392
|
let body = null;
|
|
@@ -264,13 +400,13 @@ async function callFeaturedFeed(rows) {
|
|
|
264
400
|
// would exercise the ceiling and never reach the route — and, worse, would keep
|
|
265
401
|
// passing as a silent no-op if the handler were ever removed. A fresh req per call
|
|
266
402
|
// keeps each test's reads out of the previous one's budget.
|
|
267
|
-
const req = { ip: `198.51.100.${Math.floor(Math.random() * 200) + 1}
|
|
403
|
+
const req = { ip: `198.51.100.${Math.floor(Math.random() * 200) + 1}`, query: query || {} };
|
|
268
404
|
for (const entry of layer.route.stack) {
|
|
269
405
|
let advanced = false;
|
|
270
406
|
await entry.handle(req, res, () => { advanced = true; });
|
|
271
407
|
if (!advanced) break; // something short-circuited (a refusal), or it was the handler
|
|
272
408
|
}
|
|
273
|
-
return { body, askedFor };
|
|
409
|
+
return { body, askedFor, listArgs };
|
|
274
410
|
} finally {
|
|
275
411
|
pi.listPublicProjects = savedList;
|
|
276
412
|
projectStats.getProjectStats = savedStats;
|
|
@@ -329,3 +465,38 @@ test('dark matter never reaches the route at all — the exclusion is in SQL', a
|
|
|
329
465
|
const fn = (src.match(/async function listPublicProjects[\s\S]*?\n\}/) || [''])[0];
|
|
330
466
|
assert.ok(fn.includes('publishableSql()'), 'the gate is applied in the query, not after it');
|
|
331
467
|
});
|
|
468
|
+
|
|
469
|
+
// ── the route's half of the page window (task 1003560 → 1003561) ─────────────
|
|
470
|
+
|
|
471
|
+
test('the route pages with ?limit=&offset= and reports back the window it used', async () => {
|
|
472
|
+
// ADR 0119's contract, which this feed was the last public list to hold out on.
|
|
473
|
+
const { body, listArgs } = await callFeaturedFeed([{ ...PUBLIC_ROW }], { limit: '2', offset: '4' });
|
|
474
|
+
assert.equal(listArgs[0].limit, 2);
|
|
475
|
+
assert.equal(listArgs[0].offset, 4);
|
|
476
|
+
assert.deepEqual(body.page, { limit: 2, offset: 4 });
|
|
477
|
+
assert.ok(Array.isArray(body.projects),
|
|
478
|
+
'`projects` keeps its name and its place — every consumer destructures it, so the wire is additive');
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test('an unparamed read still asks for the whole ceiling — the other-repo orbs site is unchanged', async () => {
|
|
482
|
+
const { body, listArgs } = await callFeaturedFeed([{ ...PUBLIC_ROW }]);
|
|
483
|
+
assert.equal(listArgs[0].limit, pi.PUBLIC_FEED_MAX_PAGE_SIZE);
|
|
484
|
+
assert.equal(listArgs[0].offset, 0);
|
|
485
|
+
assert.deepEqual(body.page, { limit: pi.PUBLIC_FEED_MAX_PAGE_SIZE, offset: 0 });
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test('?limit= can only SHRINK a page, so paging cannot aim more fan-out than one request already could', async () => {
|
|
489
|
+
// The constraint that makes paging safe on a public, CORS-open, unauthenticated route
|
|
490
|
+
// whose every cold row costs four outbound fetches at somebody else's server.
|
|
491
|
+
const { listArgs } = await callFeaturedFeed([], { limit: '99999' });
|
|
492
|
+
assert.equal(listArgs[0].limit, pi.PUBLIC_FEED_MAX_PAGE_SIZE);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test('no cursor: the page token is an offset, which encodes nothing about any row', async () => {
|
|
496
|
+
// Deliberate, and a security property as much as a style one. An opaque cursor over this
|
|
497
|
+
// feed would have to encode a sort position, and the obvious encoding — the last row's
|
|
498
|
+
// timestamp — is the very side-channel the ORDER BY above was rewritten to close.
|
|
499
|
+
const { body } = await callFeaturedFeed([{ ...PUBLIC_ROW }], { offset: '3' });
|
|
500
|
+
assert.deepEqual(Object.keys(body.page).sort(), ['limit', 'offset']);
|
|
501
|
+
assert.equal(body.page.offset, 3);
|
|
502
|
+
});
|