@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.
- package/.bongos-core.json +42 -22
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0254-a-relayed-link-is-contained-downward-or-it-is-not-published.md +119 -0
- package/docs/adr/0255-a-public-lists-ordering-is-part-of-its-payload.md +169 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +7 -3
- package/docs/module-api-changelog.md +4 -0
- package/modules/platform-identity/origin-containment.js +77 -0
- package/modules/platform-identity/platform-identity.js +18 -28
- package/modules/platform-identity/project-stats.js +33 -1
- package/modules/platform-identity/projects-feed.js +126 -20
- package/modules/platform-identity/routes/my-projects.js +4 -0
- package/modules/platform-identity/routes/projects.js +29 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/origin_containment.mjs +114 -0
- package/tests/project_stats.mjs +82 -14
- package/tests/visible_projects_feed.mjs +184 -13
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// tests/origin_containment.mjs — the downward-only containment rule (task 1003670,
|
|
2
|
+
// ADR 0254): may a URL a REMOTE instance reported be published under that instance's
|
|
3
|
+
// name?
|
|
4
|
+
//
|
|
5
|
+
// Two surfaces relay a third party's URL into an anchor on the hub — a builder's ship
|
|
6
|
+
// links and a project's `hall_origin` — and until this task they answered the question
|
|
7
|
+
// differently: the ship link demanded containment, the hall address demanded only that
|
|
8
|
+
// the host DIFFER from the project's site. This file is the rule's own proof, so the
|
|
9
|
+
// two callers cannot drift again.
|
|
10
|
+
//
|
|
11
|
+
// EVERY CASE HERE IS AN ATTACK OR THE SHAPE THAT MAKES ONE POSSIBLE. What is asserted
|
|
12
|
+
// is what is REFUSED; the accept list is deliberately short, because a containment rule
|
|
13
|
+
// that is generous is not one.
|
|
14
|
+
//
|
|
15
|
+
// Run: node --test tests/origin_containment.mjs
|
|
16
|
+
|
|
17
|
+
import test from 'node:test';
|
|
18
|
+
import assert from 'node:assert/strict';
|
|
19
|
+
// A REAL ESM import, not createRequire: knip does not trace createRequire(), and this
|
|
20
|
+
// is the export's only test (learning: knip-createrequire-test-imports).
|
|
21
|
+
import { urlWithinOrigin } from '../modules/platform-identity/origin-containment.js';
|
|
22
|
+
|
|
23
|
+
const ORIGIN = 'https://project.example';
|
|
24
|
+
|
|
25
|
+
test('a link at the reporting origin itself is kept, verbatim', () => {
|
|
26
|
+
assert.equal(urlWithinOrigin(`${ORIGIN}/builders#/task/1`, ORIGIN), `${ORIGIN}/builders#/task/1`);
|
|
27
|
+
assert.equal(urlWithinOrigin(ORIGIN, ORIGIN), ORIGIN);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('a link BELOW the reporting origin is kept — the hall on a subdomain is the whole point', () => {
|
|
31
|
+
const hall = 'https://builders.project.example/';
|
|
32
|
+
assert.equal(urlWithinOrigin(hall, ORIGIN), hall);
|
|
33
|
+
assert.equal(urlWithinOrigin('https://a.b.c.project.example/x', ORIGIN), 'https://a.b.c.project.example/x',
|
|
34
|
+
'depth is not bounded — every label below the origin is still the origin\'s own');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('the host comparison ignores case, and a port is not a trust boundary', () => {
|
|
38
|
+
assert.equal(urlWithinOrigin('https://BUILDERS.Project.Example/x', ORIGIN), 'https://BUILDERS.Project.Example/x');
|
|
39
|
+
assert.equal(urlWithinOrigin('https://project.example:8443/x', ORIGIN), 'https://project.example:8443/x');
|
|
40
|
+
assert.equal(urlWithinOrigin('http://project.example/x', ORIGIN), 'http://project.example/x',
|
|
41
|
+
'http is allowed — the scheme gate is about javascript:, not about TLS');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/* ── the refusals: each row is a way a hostile instance tries to publish a link that
|
|
45
|
+
is not its own, under a name that is ─────────────────────────────────────────── */
|
|
46
|
+
|
|
47
|
+
for (const [attack, raw, origin] of [
|
|
48
|
+
// The plain one this task exists for: a remote instance advertising a hall it does
|
|
49
|
+
// not own, which the hub then renders as an <a href> under the victim's project.
|
|
50
|
+
['an unrelated host', 'https://evil.example/phish', ORIGIN],
|
|
51
|
+
['an unrelated host with a convincing path', 'https://evil.example/project.example/builders', ORIGIN],
|
|
52
|
+
|
|
53
|
+
// A shared multi-tenant apex, the shape origin-containment.js's header names: the
|
|
54
|
+
// neighbour is NOT the neighbour's to link, and neither is the apex above both.
|
|
55
|
+
['a SIBLING under a shared apex', 'https://bar.pages.example/x', 'https://foo.pages.example'],
|
|
56
|
+
['an ANCESTOR of its own host (the github.io shape)', 'https://pages.example/x', 'https://foo.pages.example'],
|
|
57
|
+
['the bare apex of a deep origin', 'https://example/x', 'https://a.b.example'],
|
|
58
|
+
|
|
59
|
+
// Suffix matching without the dot anchor is a lookalike-domain generator: every
|
|
60
|
+
// registrable name that ENDS in the victim's name would contain it.
|
|
61
|
+
['a lookalike domain that merely ends in the origin', 'https://notproject.example/x', ORIGIN],
|
|
62
|
+
['a lookalike with the origin as a bare substring', 'https://project.example.evil.test/x', ORIGIN],
|
|
63
|
+
|
|
64
|
+
// The host check alone passes this: the host IS evil.example. What a human reads is
|
|
65
|
+
// the victim's domain, which is the entire trick.
|
|
66
|
+
['credentials that read as the victim\'s domain', 'https://project.example@evil.example/x', ORIGIN],
|
|
67
|
+
['user:password credentials on the origin itself', 'https://user:pw@project.example/x', ORIGIN],
|
|
68
|
+
|
|
69
|
+
// A relayed href is a script sink if the scheme is not constrained here.
|
|
70
|
+
['a javascript: scheme', 'javascript:alert(1)', ORIGIN],
|
|
71
|
+
['a data: URL', 'data:text/html,<script>alert(1)</script>', ORIGIN],
|
|
72
|
+
['a file: URL', 'file:///etc/passwd', ORIGIN],
|
|
73
|
+
['a protocol-relative URL', '//evil.example/x', ORIGIN],
|
|
74
|
+
|
|
75
|
+
// Not a URL, or not a string at all — a third-party JSON field is any shape.
|
|
76
|
+
['a bare path', '/builders', ORIGIN],
|
|
77
|
+
['an empty string', '', ORIGIN],
|
|
78
|
+
['a number', 12345, ORIGIN],
|
|
79
|
+
['an object', { href: `${ORIGIN}/x` }, ORIGIN],
|
|
80
|
+
['null', null, ORIGIN],
|
|
81
|
+
['undefined', undefined, ORIGIN],
|
|
82
|
+
|
|
83
|
+
// The ORIGIN side is stored data, not validated data. A hostless one leaves the
|
|
84
|
+
// suffix as a bare ".", which a legal trailing-dot FQDN would match.
|
|
85
|
+
['a trailing-dot FQDN against a hostless origin', 'https://evil.example./x', 'data:text/plain,x'],
|
|
86
|
+
['any link against a hostless origin', 'https://evil.example/x', 'file:///tmp/x'],
|
|
87
|
+
['any link against an unparseable origin', 'https://evil.example/x', 'not-a-url'],
|
|
88
|
+
['any link against an empty origin', 'https://evil.example/x', ''],
|
|
89
|
+
['any link against a null origin', 'https://evil.example/x', null],
|
|
90
|
+
]) {
|
|
91
|
+
test(`REFUSED: ${attack}`, () => {
|
|
92
|
+
assert.equal(urlWithinOrigin(raw, origin), null);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
test('REFUSED: a URL over the cap — refused whole, never truncated', () => {
|
|
97
|
+
// Truncation is the tempting alternative and it is worse: a sliced URL still parses
|
|
98
|
+
// and points somewhere else, so the hub would publish a link nobody wrote.
|
|
99
|
+
const long = `${ORIGIN}/${'x'.repeat(600)}`;
|
|
100
|
+
assert.equal(urlWithinOrigin(long, ORIGIN), null, 'contained, but too long — still refused');
|
|
101
|
+
assert.equal(urlWithinOrigin(long, ORIGIN, 1000), long, 'the cap is the only thing refusing it');
|
|
102
|
+
assert.equal(urlWithinOrigin(`${ORIGIN}/x`, ORIGIN, 5), null, 'a caller may bound it tighter');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('a refusal is always null — never a throw, and never a repaired URL', () => {
|
|
106
|
+
for (const raw of ['https://evil.example/x', 'javascript:alert(1)', 'http://', 7, [], true]) {
|
|
107
|
+
const out = urlWithinOrigin(raw, ORIGIN);
|
|
108
|
+
assert.equal(out, null, `${String(raw)} → null`);
|
|
109
|
+
}
|
|
110
|
+
// The kept value is the INPUT, byte for byte: normalising it here would mean the hub
|
|
111
|
+
// publishes a URL the instance never reported.
|
|
112
|
+
const odd = `${ORIGIN}/a//b?q=1#f`;
|
|
113
|
+
assert.equal(urlWithinOrigin(odd, ORIGIN), odd);
|
|
114
|
+
});
|
package/tests/project_stats.mjs
CHANGED
|
@@ -21,7 +21,17 @@ const require = createRequire(import.meta.url);
|
|
|
21
21
|
const stats = require('../modules/platform-identity/project-stats.js');
|
|
22
22
|
|
|
23
23
|
const realFetch = globalThis.fetch;
|
|
24
|
-
|
|
24
|
+
// The SSRF guard's resolver. Most fixtures below dodge it with literal IPs, but the
|
|
25
|
+
// hall-containment group needs real HOSTNAMES — containment is a question about
|
|
26
|
+
// domain labels, and an IP origin cannot express "a subdomain of my own site". The
|
|
27
|
+
// module captured `require('node:dns').promises` (the object) at load, so replacing
|
|
28
|
+
// `.lookup` on it is what the guard actually calls.
|
|
29
|
+
const dnsPromises = require('node:dns').promises;
|
|
30
|
+
const realLookup = dnsPromises.lookup;
|
|
31
|
+
function stubDns() {
|
|
32
|
+
dnsPromises.lookup = () => Promise.resolve([{ address: '93.184.216.34', family: 4 }]);
|
|
33
|
+
}
|
|
34
|
+
afterEach(() => { globalThis.fetch = realFetch; dnsPromises.lookup = realLookup; });
|
|
25
35
|
beforeEach(() => { stats._reset(); });
|
|
26
36
|
|
|
27
37
|
function json(body) {
|
|
@@ -359,28 +369,86 @@ test('the shipped feed is asked for the window explicitly', async () => {
|
|
|
359
369
|
assert.ok(urls.includes('https://8.8.8.8/api/bongos'), 'the discovery index answers the hall address');
|
|
360
370
|
});
|
|
361
371
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
372
|
+
/* ── hall_origin: CONTAINMENT first, the door test second ───────────────────────
|
|
373
|
+
This is the one value in this file that becomes an <a href> on the hub rather
|
|
374
|
+
than an <img src>, and it arrives from a REMOTE instance's own discovery index.
|
|
375
|
+
It used to be checked only for DIFFERING from the project's site host — which is
|
|
376
|
+
containment inverted: it admitted every host on the internet except the one that
|
|
377
|
+
was certainly safe. Task 1003670 / ADR 0254 put it on the same downward-only rule
|
|
378
|
+
a pushed ship link obeys (origin-containment.js, whose own attack matrix is
|
|
379
|
+
tests/origin_containment.mjs). What is asserted here is the WIRING: that a sweep
|
|
380
|
+
through getProjectStats really refuses these.
|
|
381
|
+
|
|
382
|
+
Hostname fixtures, not the file's usual literal IPs — see stubDns() above. ── */
|
|
383
|
+
|
|
384
|
+
// each row: [why, origin, the hall the remote advertises, what the hub may publish]
|
|
385
|
+
const HALL_CASES = [
|
|
386
|
+
['a hall on a subdomain of the project\'s own origin is the legitimate shape',
|
|
387
|
+
'https://good.example', 'https://builders.good.example', 'https://builders.good.example'],
|
|
388
|
+
['another PORT on the same host is a real second door — a port is not a trust boundary',
|
|
389
|
+
'https://port.example', 'https://port.example:8443', 'https://port.example:8443'],
|
|
390
|
+
['a hall AT the site host is the site\'s own door, not a second one',
|
|
391
|
+
'https://solo.example', 'https://solo.example', null],
|
|
392
|
+
// THE ATTACK. Under ADR 0252's auto-appear anyone who points a domain at the hub
|
|
393
|
+
// can report this, and the old rule published it BECAUSE it differed.
|
|
394
|
+
['an unrelated host is a phishing hop, and differing is exactly what used to admit it',
|
|
395
|
+
'https://victim.example', 'https://evil.example/builders', null],
|
|
396
|
+
['a SIBLING under a shared apex belongs to the neighbour',
|
|
397
|
+
'https://foo.pages.example', 'https://bar.pages.example', null],
|
|
398
|
+
['an ANCESTOR of the project\'s own host is the github.io shape — never upward',
|
|
399
|
+
'https://foo.pages.example', 'https://pages.example', null],
|
|
400
|
+
['a lookalike domain that merely ENDS in the origin (the dot anchor)',
|
|
401
|
+
'https://look.example', 'https://notlook.example', null],
|
|
402
|
+
['embedded credentials that read as the project\'s own domain',
|
|
403
|
+
'https://creds.example', 'https://creds.example@evil.example/builders', null],
|
|
404
|
+
['a non-http scheme never becomes an href',
|
|
405
|
+
'https://scheme.example', 'javascript:alert(1)', null],
|
|
406
|
+
['a hall over the URL cap is refused whole, never truncated into a different address',
|
|
407
|
+
'https://long.example', `https://a.long.example/${'x'.repeat(400)}`, null],
|
|
408
|
+
['a hall the project does not name',
|
|
409
|
+
'https://quiet.example', undefined, null],
|
|
410
|
+
];
|
|
411
|
+
|
|
412
|
+
test('hall_origin publishes a hall only at or BELOW the project\'s own origin, and only where it is a different door', async () => {
|
|
413
|
+
stubDns();
|
|
414
|
+
const fixtures = {};
|
|
415
|
+
for (const [, origin, hall] of HALL_CASES) {
|
|
416
|
+
fixtures[origin] = { leaderboard: [], enabled: {}, index: hall === undefined ? {} : { hall } };
|
|
417
|
+
}
|
|
418
|
+
stub(fixtures);
|
|
419
|
+
const out = await stats.getProjectStats(HALL_CASES.map(([, origin]) => origin));
|
|
420
|
+
for (const [why, origin, , expected] of HALL_CASES) {
|
|
421
|
+
assert.equal(out[origin].hall_origin, expected, why);
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test('a project with no discovery index at all (an older core) reports no hall', async () => {
|
|
426
|
+
stub({ 'https://4.4.4.4': { leaderboard: [], enabled: {} } });
|
|
427
|
+
const out = await stats.getProjectStats(['https://4.4.4.4']);
|
|
373
428
|
assert.equal(out['https://4.4.4.4'].hall_origin, null);
|
|
374
429
|
});
|
|
375
430
|
|
|
431
|
+
test('containment is read from the ONE shared rule, not re-implemented here', async () => {
|
|
432
|
+
// The defect this replaced was a second, weaker answer to a question the corpus had
|
|
433
|
+
// already answered one field over. A local re-implementation would drift again.
|
|
434
|
+
const src = await import('node:fs').then((fs) =>
|
|
435
|
+
fs.readFileSync(new URL('../modules/platform-identity/project-stats.js', import.meta.url), 'utf8'));
|
|
436
|
+
assert.match(src, /require\('\.\/origin-containment'\)/, 'the rule is required, not copied');
|
|
437
|
+
const fn = (src.match(/function hallOrigin\([\s\S]*?\n\}/) || [''])[0];
|
|
438
|
+
assert.match(fn, /urlWithinOrigin\(index\?\.hall, origin, URL_MAX\)/, 'the hall is contained before anything else looks at it');
|
|
439
|
+
assert.doesNotMatch(fn, /httpUrl\(/, 'httpUrl only scheme-checks and TRUNCATES — it is not a containment rule');
|
|
440
|
+
});
|
|
441
|
+
|
|
376
442
|
test('a dead shipped feed leaves the other quarters standing', async () => {
|
|
377
|
-
|
|
443
|
+
// the hall is on this project's OWN host (another port) — anything else is refused
|
|
444
|
+
// by containment now, which would make this test prove the wrong thing
|
|
445
|
+
stub({ 'https://8.8.8.8': { leaderboard: [{ github_login: 'a' }], enabled: { game: true }, index: { hall: 'https://8.8.8.8:8443' } } });
|
|
378
446
|
const out = await stats.getProjectStats(['https://8.8.8.8']);
|
|
379
447
|
assert.equal(out['https://8.8.8.8'].shipped_30d, null, 'the dead quarter is unknown');
|
|
380
448
|
assert.equal(out['https://8.8.8.8'].shipped_30d_capped, null);
|
|
381
449
|
assert.equal(out['https://8.8.8.8'].builders, 1);
|
|
382
450
|
assert.deepEqual(out['https://8.8.8.8'].tags, ['game']);
|
|
383
|
-
assert.equal(out['https://8.8.8.8'].hall_origin, 'https://
|
|
451
|
+
assert.equal(out['https://8.8.8.8'].hall_origin, 'https://8.8.8.8:8443');
|
|
384
452
|
});
|
|
385
453
|
|
|
386
454
|
test('the origin\'s host is resolved once per sweep, not once per feed', async () => {
|
|
@@ -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
|
+
});
|