@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,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:
|
|
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
|
-
//
|
|
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 =
|
|
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
|
-
//
|
|
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
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
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
|
-
|
|
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
|
|
142
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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');
|