@bongos/core 1.19.564 → 1.19.566

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.
Files changed (33) hide show
  1. package/.bongos-core.json +68 -33
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +4 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0253-the-takedown-lever-has-two-writers-and-provenance-is-a-column.md +144 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +91 -4
  10. package/docs/api-reference.md +3 -2
  11. package/docs/module-api-changelog.md +4 -0
  12. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  13. package/modules/government/catalog.js +1 -1
  14. package/modules/platform-identity/migrations/platform_identity_020_visibility_pull.sql +106 -0
  15. package/modules/platform-identity/module.json +1 -1
  16. package/modules/platform-identity/pollers/visibility-pull.js +99 -0
  17. package/modules/platform-identity/project-catalog.js +62 -4
  18. package/modules/platform-identity/project-stats.js +31 -0
  19. package/modules/platform-identity/projects-feed.js +17 -1
  20. package/modules/platform-identity/routes/projects.js +48 -1
  21. package/modules/platform-identity/visibility-pull.js +201 -0
  22. package/modules/public-landing/public/projects.html +89 -53
  23. package/package-lock.json +2 -2
  24. package/package.json +1 -1
  25. package/src/bongos/middleware/rate-limit.js +44 -2
  26. package/src/module-api.js +12 -1
  27. package/tests/manage_settings_shared_read.mjs +146 -0
  28. package/tests/module_api.mjs +1 -0
  29. package/tests/project_door_ui.mjs +16 -2
  30. package/tests/project_takedown_lever.mjs +177 -0
  31. package/tests/projects_hub.mjs +24 -2
  32. package/tests/visibility_pull.mjs +213 -0
  33. package/tests/visible_projects_feed.mjs +12 -1
@@ -0,0 +1,201 @@
1
+ // modules/platform-identity/visibility-pull.js — HOW A SELF-HOSTED PROJECT'S JOIN DOOR
2
+ // REACHES THE HUB (BV1.R08, task 1002327; ADR 0252, corrected by ADR 0253).
3
+ //
4
+ // The hub does not receive a pushed report. It READS the instance's already-public
5
+ // manifest — `GET <origin>/api/bongos/instance` → `brand.project.visibility` — and caches
6
+ // the answer. ADR 0252 §2: a self-hosted instance holds no credential and minting one
7
+ // walks into ADR 0144/0145's tripwire, so the channel that does not need a credential
8
+ // is the only one that can be built today. A pull also has no message to replay, adds
9
+ // no required wire field to anyone else's upgrade clock, and authorizes nothing.
10
+ //
11
+ // WHAT THIS FILE OWNS. Three rules, and each is only safe given the others:
12
+ //
13
+ // • WHAT A MANIFEST IS ALLOWED TO SAY — `visibilityFromManifest`. A three-value closed
14
+ // enum and nothing else. No name, tagline or art reaches the catalog through this
15
+ // channel: today no project owner controls a free-text field that reaches the public
16
+ // feed, and ADR 0252 §7 refuses to spend that property on a visibility report. An
17
+ // off-vocabulary or absent answer is NO VERDICT — never a default, because defaulting
18
+ // would let a project that said nothing be read as having said 'public'.
19
+ //
20
+ // • WHAT A READ MAY CHANGE — `pullDecision`. The tri-state, which is the part that is
21
+ // easy to get wrong in the direction that empties the map. See below.
22
+ //
23
+ // • WHICH ROWS MAY BE READ AT ALL — `PULLABLE_SQL`. Rows the control plane has never
24
+ // claimed. The bridge (ADR 0246) assigns `visibility` for the projects the operator
25
+ // provisions, and the two writers must never touch the same row; `visibility_source`
26
+ // is what makes that an invariant the SQL enforces rather than a convention someone
27
+ // has to remember. The catalog row carries no `hosting_shape` and may not import the
28
+ // module that does (ADR 0083's one-way rule), so provenance is recorded here instead
29
+ // of asked for across a seam on every sweep.
30
+ //
31
+ // THE TRI-STATE, AND WHY IT IS THREE STATES AND NOT TWO (ADR 0252 §4). `null` (never
32
+ // read) ≠ `false` (read, and negative). A project does not leave the map because its
33
+ // server was down for one sweep:
34
+ //
35
+ // never successfully read → dark matter. ADR 0182 D5's fail-closed default,
36
+ // doing its job, and scoped to rows that have genuinely
37
+ // never answered.
38
+ // read before, unreachable now → KEEP the last known state and its stamp. Reporting
39
+ // may degrade where enforcement must refuse (ADR 0226);
40
+ // liveness is an overlay, never a lifecycle state
41
+ // (ADR 0199). Implemented by writing NOTHING — the
42
+ // stamp means "last SUCCESSFUL read", so a failed read
43
+ // must not touch it.
44
+ // read, narrower than before → applied immediately and unconditionally. A report
45
+ // that REDUCES exposure needs no trust at all: the
46
+ // worst a lying instance achieves is hiding itself.
47
+ // Narrowing is free; widening is what costs.
48
+ //
49
+ // AND THE ONE THING A PULL MAY NEVER MOVE: `status`. Retraction is a hub decision about
50
+ // the hub's own surface. A project taken down that keeps reporting `public` STAYS down —
51
+ // without that, the next sweep silently undoes every takedown and the lever is
52
+ // decorative (ADR 0252 §5.2). The pull moves `visibility`; `status` is not in any
53
+ // statement in this file, which is the cheapest possible proof of it.
54
+ //
55
+ // Proof: ../../tests/visibility_pull.mjs
56
+ 'use strict';
57
+
58
+ const { pool: defaultPool } = require('../../src/module-api');
59
+ const { ORB_BY_STATE } = require('./projects-feed');
60
+ const projectStats = require('./project-stats');
61
+
62
+ // The manifest route every Cloud Bongos instance already serves, unauthenticated and
63
+ // CORS-open (ADR 0114's discovery surface; `src/bongos/routes/instance.js`). Nothing is
64
+ // added to it by this task — ADR 0252 §2.3: the field already ships in every core, so
65
+ // there is no wire change on anyone else's upgrade schedule. The API router is
66
+ // dual-mounted at `/api/bongos` (canonical) and at the permanent legacy alias ADR 0252
67
+ // happened to write, so either prefix reaches it; the fetch uses the canonical one.
68
+ const MANIFEST_ROUTE = '/api/bongos/instance';
69
+
70
+ // Rows the PULL may write: the ones the ADR 0246 control-plane bridge has never claimed.
71
+ // `IS DISTINCT FROM` rather than `<> `, deliberately — a NULL source is the unclaimed
72
+ // state and plain inequality would drop exactly those rows on a NULL comparison.
73
+ const PULLABLE_SQL = "visibility_source IS DISTINCT FROM 'control-plane'";
74
+
75
+ // visibilityFromManifest — the ONLY value this channel carries, or null for NO VERDICT.
76
+ //
77
+ // Reads `brand.project.visibility`: `buildInstanceManifest` projects the branding pack
78
+ // under the key `brand`, and `clientBranding()` puts the join door at `project.visibility`
79
+ // (`src/branding.js`). `branding` is accepted as a second spelling because ADR 0252 §2 and
80
+ // this task's own body both name it that way — costing one `??` here is cheaper than a
81
+ // silent null on a shape nobody re-checked.
82
+ //
83
+ // Third-party input, so it is validated against the enum rather than trusted: an unknown
84
+ // string, a number, an object, a missing key and a missing `project` all yield null. PURE.
85
+ function visibilityFromManifest(body) {
86
+ const project = body?.brand?.project ?? body?.branding?.project;
87
+ const v = project?.visibility;
88
+ if (typeof v !== 'string') return null;
89
+ const trimmed = v.trim();
90
+ return Object.prototype.hasOwnProperty.call(ORB_BY_STATE, trimmed) ? trimmed : null;
91
+ }
92
+
93
+ // pullDecision — what ONE sweep of ONE row should do. The whole tri-state, in one pure
94
+ // function so it is testable without a database or a network.
95
+ //
96
+ // Returns { write: false, reason } or { write: true, visibility }. There is deliberately
97
+ // no third shape: everything that is not a successful, in-vocabulary read leaves the row
98
+ // exactly as it is, including its stamp.
99
+ function pullDecision(row, reported) {
100
+ if (row?.visibility_source === 'control-plane') {
101
+ return { write: false, reason: 'control_plane_row' };
102
+ }
103
+ if (reported === null || reported === undefined) {
104
+ // No verdict. Either the instance is unreachable, or it answered something this
105
+ // channel does not accept. Both are silence, and silence never moves a row: an
106
+ // unread project stays dark, and a project that answered before keeps what it said.
107
+ return {
108
+ write: false,
109
+ reason: row?.visibility_reported_at ? 'unreachable_keeps_last_state' : 'never_reported',
110
+ };
111
+ }
112
+ if (row?.visibility === reported && row?.visibility_source === 'pull') {
113
+ // Same answer as last time. Skipping the write keeps `updated_at` still, which
114
+ // matters more than it looks: the public feed orders on it, so bumping it every
115
+ // sweep would make a row's position track the sweep clock rather than the project.
116
+ return { write: false, reason: 'unchanged' };
117
+ }
118
+ return { write: true, visibility: reported };
119
+ }
120
+
121
+ // The pullable rows, oldest reading first so a sweep that is cut short by its budget
122
+ // makes progress on the staleness it exists to fix rather than re-reading fresh rows.
123
+ // NULLS FIRST puts the never-read projects — the ones currently dark — at the head.
124
+ async function pullableProjects({ pool = defaultPool, limit = 200 } = {}) {
125
+ const { rows } = await pool.query(
126
+ `SELECT id, origin, visibility, visibility_source, visibility_reported_at
127
+ FROM platform_identity_projects
128
+ WHERE ${PULLABLE_SQL}
129
+ ORDER BY visibility_reported_at ASC NULLS FIRST
130
+ LIMIT $1`,
131
+ [limit],
132
+ );
133
+ return rows;
134
+ }
135
+
136
+ // Record one successful read. The WHERE re-asserts PULLABLE_SQL rather than trusting the
137
+ // row the caller read a moment ago: a bridge projection can land between the SELECT and
138
+ // this UPDATE, and the invariant that the two writers never touch the same row has to
139
+ // hold at the write, not merely at the read. `status` is absent, and so is `featured` —
140
+ // this statement can only move the door.
141
+ async function recordVisibilityPull(id, visibility, { pool = defaultPool } = {}) {
142
+ if (!Object.prototype.hasOwnProperty.call(ORB_BY_STATE, visibility)) {
143
+ throw new Error(`visibility-pull: refusing to write off-vocabulary value ${JSON.stringify(visibility)}`);
144
+ }
145
+ const { rows } = await pool.query(
146
+ `UPDATE platform_identity_projects
147
+ SET visibility = $2,
148
+ visibility_source = 'pull',
149
+ visibility_reported_at = now(),
150
+ updated_at = now()
151
+ WHERE id = $1 AND ${PULLABLE_SQL}
152
+ RETURNING id, origin, visibility, visibility_reported_at`,
153
+ [id, visibility],
154
+ );
155
+ return rows[0] || null;
156
+ }
157
+
158
+ // One row, end to end. Never throws: a sweep runs over projects the hub does not control,
159
+ // so one hostile or broken instance must not stop the others being read.
160
+ async function pullOne(row, { pool = defaultPool, fetchVisibility = projectStats.fetchInstanceVisibility } = {}) {
161
+ let reported = null;
162
+ try {
163
+ reported = visibilityFromManifest(await fetchVisibility(row.origin));
164
+ } catch {
165
+ reported = null; // unreachable, refused by the SSRF guard, timed out, or not JSON
166
+ }
167
+ const decision = pullDecision(row, reported);
168
+ if (!decision.write) return { origin: row.origin, written: false, reason: decision.reason };
169
+ const written = await recordVisibilityPull(row.id, decision.visibility, { pool });
170
+ return {
171
+ origin: row.origin,
172
+ written: !!written,
173
+ reason: written ? 'updated' : 'claim_lost',
174
+ visibility: decision.visibility,
175
+ };
176
+ }
177
+
178
+ // The sweep. Sequential on purpose: this is a background reconciler with no reader
179
+ // waiting on it, and the fan-out it walks is the same population the public feed already
180
+ // fetches from on every cold load. Doing it serially keeps the hub from becoming a
181
+ // burst source against every listed instance at once — the feed's own concurrency is
182
+ // bounded by a page load; a sweep's would be bounded by nothing.
183
+ async function sweepVisibilityOnce({ pool = defaultPool, limit = 200, fetchVisibility } = {}) {
184
+ const rows = await pullableProjects({ pool, limit });
185
+ const results = [];
186
+ for (const row of rows) {
187
+ results.push(await pullOne(row, { pool, fetchVisibility }));
188
+ }
189
+ return { scanned: rows.length, updated: results.filter((r) => r.written).length, results };
190
+ }
191
+
192
+ module.exports = {
193
+ MANIFEST_ROUTE,
194
+ PULLABLE_SQL,
195
+ visibilityFromManifest,
196
+ pullDecision,
197
+ pullableProjects,
198
+ recordVisibilityPull,
199
+ pullOne,
200
+ sweepVisibilityOnce,
201
+ };
@@ -5586,7 +5586,10 @@ summary{min-height:24px;padding:3px 0;}
5586
5586
  return), never from renderManage, which the fetch-free tick calls */
5587
5587
  if (!keepRepo) loadRepoVisibility(inst);
5588
5588
  /* the settings row + the project's own manifest — the same cadence as
5589
- the GitHub read: entry + tab return, never the in-flight tick */
5589
+ the GitHub read: entry + tab return, never the in-flight tick. The
5590
+ three cards below share ONE read of the row (task 1003582), so
5591
+ dropping the memo here is what keeps each entry a fresh read */
5592
+ if (!keepRepo) forgetSettings();
5590
5593
  if (!keepRepo) loadVisibility(inst);
5591
5594
  if (!keepRepo) loadJoinability(inst);
5592
5595
  if (!keepRepo) loadDoor();
@@ -5936,30 +5939,67 @@ summary{min-height:24px;padding:3px 0;}
5936
5939
  .then(function () { if (timer) clearTimeout(timer); });
5937
5940
  }
5938
5941
 
5942
+ /* ── the settings row, read ONCE per manage entry (task 1003582) ─────────
5943
+ Three cards render off the SAME row — how the project is reached
5944
+ (platform_visibility), how people join (joinability), and its door on the
5945
+ map (visibility) — and each used to GET it for itself: three identical
5946
+ round trips on every entry, growing by one with each card added.
5947
+
5948
+ Memoised like ensureModsCatalog()/ensureBundles(), with the one difference
5949
+ that matters: those hold a catalog nothing rewrites, this holds a
5950
+ per-project row every card's Save rewrites. So the memo is keyed on the
5951
+ project AND dropped by forgetSettings() at the top of each entry pass —
5952
+ nothing else may reuse it, or a card would paint a row the owner has since
5953
+ changed. The saves stay separate: each card still PATCHes its own key.
5954
+
5955
+ It RESOLVES with the whole answer, status included, rather than throwing on
5956
+ a refusal — each card keeps writing its own line for a lapsed session vs.
5957
+ an outage. A transport failure still rejects, and every consumer catches it
5958
+ privately, so one card's failure never silences its siblings. */
5959
+ var settingsPromise = null;
5960
+ var settingsFor = null; /* which project the memo holds — a switch must not reuse it */
5961
+
5962
+ function ensureSettings() {
5963
+ if (settingsPromise && settingsFor === manageId) return settingsPromise;
5964
+ settingsFor = manageId;
5965
+ settingsPromise = fetch(API + '/provisioning/instances/' + encodeURIComponent(manageId) + '/settings', { credentials: 'same-origin' })
5966
+ .then(function (res) {
5967
+ return jsonOrEmpty(res).then(function (data) {
5968
+ return { ok: res.ok, status: res.status, data: data || {} };
5969
+ });
5970
+ });
5971
+ return settingsPromise;
5972
+ }
5973
+
5974
+ /* Drop the memo so the next ensureSettings() is a real read. This is also
5975
+ what makes the alert's "Try again" work: a rejected promise left memoised
5976
+ would keep answering with the failure forever. */
5977
+ function forgetSettings() { settingsPromise = null; settingsFor = null; }
5978
+
5939
5979
  function loadVisibility(inst) {
5940
5980
  mVisSettings = null; mVisRunsHtml = ''; mVisPushSeen = false;
5941
5981
  visMsg('<p class="finePrint">Reading the project’s settings…</p>');
5942
5982
  var asked = manageId; /* a stale answer must never describe a newer project */
5943
- fetch(API + '/provisioning/instances/' + encodeURIComponent(manageId) + '/settings', { credentials: 'same-origin' })
5944
- .then(function (res) {
5945
- if (asked !== manageId) throw new Error('stale');
5946
- return jsonOrEmpty(res).then(function (data) {
5947
- if (res.ok) {
5948
- mVisSettings = {
5949
- value: (data.settings || {}).platform_visibility,
5950
- options: (data.options || {}).platform_visibility || Object.keys(VIS_OPTIONS)
5951
- };
5952
- renderVis();
5953
- /* while a push is open the line is the intent's — the manifest is
5954
- asked the moment it settles (renderVisState), not now */
5955
- if (!visPushOpen(inst)) loadVisRuns(inst);
5956
- return;
5957
- }
5958
- visMsg('<p class="finePrint">' + (res.status === 401 ? signInAgain() : 'Couldn’t read this project’s settings right now — refresh in a moment.') + '</p>');
5959
- });
5983
+ ensureSettings()
5984
+ .then(function (ans) {
5985
+ if (asked !== manageId) return;
5986
+ if (ans.ok) {
5987
+ mVisSettings = {
5988
+ value: (ans.data.settings || {}).platform_visibility,
5989
+ options: (ans.data.options || {}).platform_visibility || Object.keys(VIS_OPTIONS)
5990
+ };
5991
+ renderVis();
5992
+ /* while a push is open the line is the intent's — the manifest is
5993
+ asked the moment it settles (renderVisState), not now */
5994
+ if (!visPushOpen(inst)) loadVisRuns(inst);
5995
+ return;
5996
+ }
5997
+ visMsg('<p class="finePrint">' + (ans.status === 401 ? signInAgain() : 'Couldn’t read this project’s settings right now — refresh in a moment.') + '</p>');
5960
5998
  })
5961
- .catch(function (err) {
5962
- if (String(err && err.message) === 'stale') return;
5999
+ .catch(function () {
6000
+ /* the read is shared now, so its rejection reaches all three cards —
6001
+ each one still only speaks for the project it was asked about */
6002
+ if (asked !== manageId) return;
5963
6003
  visMsg('<p class="finePrint">Couldn’t read this project’s settings right now — refresh in a moment.</p>');
5964
6004
  });
5965
6005
  }
@@ -6313,24 +6353,22 @@ summary{min-height:24px;padding:3px 0;}
6313
6353
  mJoinSettings = null; mJoinRunsHtml = ''; mJoinPushSeen = false;
6314
6354
  joinMsg('<p class="finePrint">Reading the project’s settings…</p>');
6315
6355
  var asked = manageId; /* a stale answer must never describe a newer project */
6316
- fetch(API + '/provisioning/instances/' + encodeURIComponent(manageId) + '/settings', { credentials: 'same-origin' })
6317
- .then(function (res) {
6318
- if (asked !== manageId) throw new Error('stale');
6319
- return jsonOrEmpty(res).then(function (data) {
6320
- if (res.ok) {
6321
- mJoinSettings = {
6322
- value: (data.settings || {}).joinability,
6323
- options: (data.options || {}).joinability || Object.keys(JOIN_OPTIONS)
6324
- };
6325
- renderJoin();
6326
- if (!visPushOpen(inst)) loadJoinRuns(inst);
6327
- return;
6328
- }
6329
- joinMsg('<p class="finePrint">' + (res.status === 401 ? signInAgain() : 'Couldn’t read this project’s settings right now — refresh in a moment.') + '</p>');
6330
- });
6356
+ ensureSettings()
6357
+ .then(function (ans) {
6358
+ if (asked !== manageId) return;
6359
+ if (ans.ok) {
6360
+ mJoinSettings = {
6361
+ value: (ans.data.settings || {}).joinability,
6362
+ options: (ans.data.options || {}).joinability || Object.keys(JOIN_OPTIONS)
6363
+ };
6364
+ renderJoin();
6365
+ if (!visPushOpen(inst)) loadJoinRuns(inst);
6366
+ return;
6367
+ }
6368
+ joinMsg('<p class="finePrint">' + (ans.status === 401 ? signInAgain() : 'Couldn’t read this project’s settings right now — refresh in a moment.') + '</p>');
6331
6369
  })
6332
- .catch(function (err) {
6333
- if (String(err && err.message) === 'stale') return;
6370
+ .catch(function () {
6371
+ if (asked !== manageId) return;
6334
6372
  joinMsg('<p class="finePrint">Couldn’t read this project’s settings right now — refresh in a moment.</p>');
6335
6373
  });
6336
6374
  }
@@ -6539,23 +6577,21 @@ summary{min-height:24px;padding:3px 0;}
6539
6577
  mDoorSettings = null;
6540
6578
  doorMsg('<p class="finePrint">Reading the project’s settings…</p>');
6541
6579
  var asked = manageId; /* a stale answer must never describe a newer project */
6542
- fetch(API + '/provisioning/instances/' + encodeURIComponent(manageId) + '/settings', { credentials: 'same-origin' })
6543
- .then(function (res) {
6544
- if (asked !== manageId) throw new Error('stale');
6545
- return jsonOrEmpty(res).then(function (data) {
6546
- if (res.ok) {
6547
- mDoorSettings = {
6548
- value: (data.settings || {}).visibility,
6549
- options: (data.options || {}).visibility || Object.keys(DOOR_OPTIONS)
6550
- };
6551
- renderDoor();
6552
- return;
6553
- }
6554
- doorMsg('<p class="finePrint">' + (res.status === 401 ? signInAgain() : 'Couldn’t read this project’s settings right now — refresh in a moment.') + '</p>');
6555
- });
6580
+ ensureSettings()
6581
+ .then(function (ans) {
6582
+ if (asked !== manageId) return;
6583
+ if (ans.ok) {
6584
+ mDoorSettings = {
6585
+ value: (ans.data.settings || {}).visibility,
6586
+ options: (ans.data.options || {}).visibility || Object.keys(DOOR_OPTIONS)
6587
+ };
6588
+ renderDoor();
6589
+ return;
6590
+ }
6591
+ doorMsg('<p class="finePrint">' + (ans.status === 401 ? signInAgain() : 'Couldn’t read this project’s settings right now — refresh in a moment.') + '</p>');
6556
6592
  })
6557
- .catch(function (err) {
6558
- if (String(err && err.message) === 'stale') return;
6593
+ .catch(function () {
6594
+ if (asked !== manageId) return;
6559
6595
  doorMsg('<p class="finePrint">Couldn’t read this project’s settings right now — refresh in a moment.</p>');
6560
6596
  });
6561
6597
  }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.564",
3
+ "version": "1.19.566",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.564",
9
+ "version": "1.19.566",
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.564",
3
+ "version": "1.19.566",
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",
@@ -1,8 +1,8 @@
1
1
  // src/bongos/middleware/rate-limit.js — V3.R34 (#255).
2
2
  //
3
- // Four sliding-window in-memory limiters. The first two are the GLOBAL pair and
3
+ // Five sliding-window in-memory limiters. The first two are the GLOBAL pair and
4
4
  // protect write methods on /api/gds/* only — read methods (GET, HEAD, OPTIONS)
5
- // bypass them entirely; #3 and #4 are mounted per-surface to close exactly that
5
+ // bypass them entirely; #3, #4 and #5 are mounted per-surface to close exactly that
6
6
  // gap where an unauthenticated READ is itself the abuse.
7
7
  //
8
8
  // 1. Per-IP limiter — 30 writes / 60s. Anchors on req.ip (which honors
@@ -50,6 +50,26 @@
50
50
  // the access-pending page polls every 3s (20/min) —
51
51
  // while capping a scraper at ~2 logins/s.
52
52
  //
53
+ // 5. Public-project-feed limiter — 120 reads / 60s per IP on GET /projects/featured
54
+ // (task 1002327, ADR 0252 §5.3). That route is
55
+ // unauthenticated, CORS-open and, until this limiter,
56
+ // unmetered by construction: the two write limiters
57
+ // bypass reads, and #3 is mounted only under /public/*,
58
+ // which this route is not. It is also the most
59
+ // EXPENSIVE public read on the platform — a cold cache
60
+ // drives up to 500 catalog rows × 4 outbound fetches to
61
+ // other people's servers, so an unmetered caller is not
62
+ // just scraping us, they are aiming our fan-out at
63
+ // third parties. ADR 0252 opened the catalog to
64
+ // auto-appear, which replaced "bounded by who the
65
+ // operator provisioned" with "bounded by whoever points
66
+ // a domain at us" — this ceiling is the counterweight
67
+ // that made that decision safe to take. 120/min clears
68
+ // any real consumer (the static orbs site and the apex
69
+ // fetch it once per page load, and both honour the
70
+ // route's Cache-Control) while capping a
71
+ // cache-ignoring scraper at ~2 req/s.
72
+ //
53
73
  // Any limiter tripping → 429 with Retry-After (seconds rounded up to the
54
74
  // nearest whole second). Audit middleware still fires on a write 429 — the row
55
75
  // in audit_log is forensic value for "who tried too hard." (Reads are never
@@ -74,6 +94,8 @@ const PUBLIC_READ_WINDOW_MS = 60 * 1000;
74
94
  const PUBLIC_READ_LIMIT = 120;
75
95
  const ACCOUNT_EXISTENCE_WINDOW_MS = 60 * 1000;
76
96
  const ACCOUNT_EXISTENCE_LIMIT = 120;
97
+ const PROJECT_FEED_WINDOW_MS = 60 * 1000;
98
+ const PROJECT_FEED_LIMIT = 120;
77
99
  const SWEEP_MS = 5 * 60 * 1000;
78
100
 
79
101
  const ipBuckets = new Map();
@@ -82,6 +104,8 @@ const publicReadBuckets = new Map();
82
104
  // ONE bucket map for limiter #4 — both account-existence probes key into it, so
83
105
  // the budget is genuinely shared rather than merely identically configured.
84
106
  const accountExistenceBuckets = new Map();
107
+ // Limiter #5's bucket map — the public projects feed (task 1002327, ADR 0252 §5.3).
108
+ const projectFeedBuckets = new Map();
85
109
 
86
110
  const WRITE_METHODS = new Set(['POST', 'PATCH', 'PUT', 'DELETE']);
87
111
  // HEAD is metered too: Express runs the GET handler (incl. the DB query) for a
@@ -132,6 +156,10 @@ function sweep() {
132
156
  pruneAndCount(arr, now, ACCOUNT_EXISTENCE_WINDOW_MS);
133
157
  if (arr.length === 0) accountExistenceBuckets.delete(k);
134
158
  }
159
+ for (const [k, arr] of projectFeedBuckets) {
160
+ pruneAndCount(arr, now, PROJECT_FEED_WINDOW_MS);
161
+ if (arr.length === 0) projectFeedBuckets.delete(k);
162
+ }
135
163
  }
136
164
 
137
165
  // Schedule the sweep. unref() so it doesn't keep the process alive during
@@ -245,10 +273,22 @@ const accountExistenceReadRateLimit = perIpSlidingWindow(accountExistenceBuckets
245
273
  scope: 'account-existence',
246
274
  });
247
275
 
276
+ // publicProjectFeedRateLimit — limiter #5 in the header. ONE instance, mounted on the
277
+ // public projects feed (task 1002327, ADR 0252 §5.3). Exported as an instance rather
278
+ // than a factory for ADR 0209's reason: two call sites that each build their own from
279
+ // matching config get two budgets, which looks identical in review and hands a caller
280
+ // double the rate. A second surface answering "what is on the map" mounts THIS.
281
+ const publicProjectFeedRateLimit = perIpSlidingWindow(projectFeedBuckets, {
282
+ limit: PROJECT_FEED_LIMIT,
283
+ windowMs: PROJECT_FEED_WINDOW_MS,
284
+ scope: 'public-project-feed',
285
+ });
286
+
248
287
  module.exports = {
249
288
  rateLimitMiddleware,
250
289
  publicReadRateLimitMiddleware,
251
290
  accountExistenceReadRateLimit,
291
+ publicProjectFeedRateLimit,
252
292
  // exported for tests + the security-baseline-check script
253
293
  PER_IP_WINDOW_MS,
254
294
  PER_IP_LIMIT,
@@ -258,4 +298,6 @@ module.exports = {
258
298
  PUBLIC_READ_LIMIT,
259
299
  ACCOUNT_EXISTENCE_WINDOW_MS,
260
300
  ACCOUNT_EXISTENCE_LIMIT,
301
+ PROJECT_FEED_WINDOW_MS,
302
+ PROJECT_FEED_LIMIT,
261
303
  };
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.564'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
52
+ const CORE_VERSION = '1.19.566'; // 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');
@@ -158,6 +158,17 @@ module.exports = {
158
158
  // ADR 0209). Vendoring a second copy in a module is the failure this
159
159
  // export exists to prevent.
160
160
  accountExistenceReadRateLimit: require('./bongos/middleware/rate-limit').accountExistenceReadRateLimit,
161
+ // publicProjectFeedRateLimit — the per-IP ceiling (120 reads/60s) on the PUBLIC
162
+ // projects feed (task 1002327, ADR 0252 §5.3). Same reasoning as its
163
+ // neighbour, and the same shape — ONE middleware instance, never a factory,
164
+ // so a second surface answering "what is on the map" spends the same budget
165
+ // instead of quietly doubling it. It guards the most expensive
166
+ // unauthenticated read the platform has: a cold call fans out to as many as
167
+ // 500 third-party origins, so the ceiling protects other people's servers as
168
+ // much as ours. ADR 0252 made this a condition of auto-appear, not a
169
+ // follow-up — the straight-through path and its abuse guard ship together
170
+ // (ADR 0047).
171
+ publicProjectFeedRateLimit: require('./bongos/middleware/rate-limit').publicProjectFeedRateLimit,
161
172
 
162
173
  // --- per-builder secrets at rest (AES-256-GCM box) — a kernel trust
163
174
  // primitive; the full secretBox object is exposed so a module can call