@bongos/core 1.19.563 → 1.19.565

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 (30) hide show
  1. package/.bongos-core.json +65 -30
  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/0252-the-self-hosted-visibility-report-is-a-pull-and-auto-appear-buys-a-retraction-lever.md +109 -0
  8. package/docs/adr/0253-the-takedown-lever-has-two-writers-and-provenance-is-a-column.md +144 -0
  9. package/docs/adr/README.md +2 -0
  10. package/docs/api/openapi.json +91 -4
  11. package/docs/api-reference.md +3 -2
  12. package/docs/module-api-changelog.md +4 -0
  13. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  14. package/modules/government/catalog.js +1 -1
  15. package/modules/platform-identity/migrations/platform_identity_020_visibility_pull.sql +106 -0
  16. package/modules/platform-identity/module.json +1 -1
  17. package/modules/platform-identity/pollers/visibility-pull.js +99 -0
  18. package/modules/platform-identity/project-catalog.js +62 -4
  19. package/modules/platform-identity/project-stats.js +31 -0
  20. package/modules/platform-identity/projects-feed.js +17 -1
  21. package/modules/platform-identity/routes/projects.js +48 -1
  22. package/modules/platform-identity/visibility-pull.js +201 -0
  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/module_api.mjs +1 -0
  28. package/tests/project_takedown_lever.mjs +177 -0
  29. package/tests/visibility_pull.mjs +213 -0
  30. package/tests/visible_projects_feed.mjs +12 -1
@@ -15,6 +15,9 @@
15
15
  // POST /projects/invite — a `project.curate` holder OR the project's OWN owner:
16
16
  // pre-seed a 'pending' membership (invite-authz.js)
17
17
  // PATCH /projects/:id/featured — Archon: the feature / unfeature control
18
+ // PATCH /projects/:id/status — `project.curate`: the TAKEDOWN lever (R08, ADR 0252
19
+ // §5.1). Setting 'hidden' pins the row so the ADR 0246
20
+ // projection cannot quietly put it back on the map.
18
21
  //
19
22
  // Reaches core only through the doorway (ADR 0083). Archon routes declared first so
20
23
  // route-rank-check's look-ahead never misclassifies the trailing public GET.
@@ -181,12 +184,56 @@ module.exports = function projectsRoutes() {
181
184
  }
182
185
  });
183
186
 
187
+ // rank: metic — THE TAKEDOWN LEVER (R08, task 1002327; ADR 0252 §5.1). Until this
188
+ // route, `platform_identity_projects.status` had no writer on the API at all: the
189
+ // column shipped in migration 004, the public feed requires 'active', and removing a
190
+ // project from the map meant hand-written SQL against production.
191
+ //
192
+ // IT SHIPS IN THE SAME TASK AS AUTO-APPEAR, NOT AFTER IT. ADR 0252 §3 lets a
193
+ // self-hosted project put itself on the public map with no human act, and the Archon
194
+ // took that decision explicitly conditional on retraction landing beside it — ADR 0047's
195
+ // rule that a straight-through path and its abuse guard are one piece of work. A
196
+ // one-way door onto a public surface is not a door.
197
+ //
198
+ // Setting 'hidden' PINS the row (project-catalog.js), which is what makes the
199
+ // retraction survive: the ADR 0246 projection re-takes `status` on every provisioning
200
+ // write, so without the pin an owner's next settings save would quietly undo the
201
+ // takedown. PATCHing back to 'active' releases the pin and returns the row to automatic
202
+ // management, so un-hiding is exactly as deliberate an act as hiding.
203
+ router.patch('/projects/:id/status', requireBuilder, requirePermission('project.curate'), async (req, res) => {
204
+ const id = parseId(req, res, { code: 'bad_project_id', positiveInt: true });
205
+ if (id === null) return;
206
+ if (validateOrRespond(req, res, { status: { required: true, type: 'string' } })) return;
207
+ if (!projectCatalog.CATALOG_STATUSES.includes(req.body.status)) {
208
+ return res.fail('bad_project_status', 400, { valid: projectCatalog.CATALOG_STATUSES });
209
+ }
210
+ try {
211
+ const project = await projectCatalog.setProjectStatus(id, req.body.status, { pool });
212
+ if (!project) return res.fail('project_not_found', 404);
213
+ res.json({ project });
214
+ } catch (err) {
215
+ log.error({ err: err.message }, 'PATCH /projects/:id/status');
216
+ res.fail('status_change_failed', { status: 500, message: 'internal error' });
217
+ }
218
+ });
219
+
184
220
  // The PUBLIC projects feed — every project ON THE MAP (active + publishable),
185
221
  // public projection. CORS-enabled cross-origin read (a status page / the apex /
186
222
  // the static orbs site fetches it pre-auth).
187
223
  router.use('/projects/featured', corsPublicGet('/projects/featured'));
188
224
  // rank: public — the public map of projects; no auth by design.
189
- router.get('/projects/featured', async (_req, res) => {
225
+ //
226
+ // THE PER-IP CEILING IS PART OF THE ROUTE, not a decoration on it (ADR 0252 §5.3).
227
+ // This GET is unauthenticated and CORS-open, and it was unmetered by construction: the
228
+ // two global limiters return early for reads, and the /public/* read limiter is mounted
229
+ // on a prefix this route does not sit under. It is also the platform's most expensive
230
+ // anonymous read — a cold cache turns one request into as many as 500 rows × 4 outbound
231
+ // fetches at OTHER PEOPLE's servers, so leaving it open aims our fan-out, not just our
232
+ // database. Auto-appear removed the old bound (the set of projects the operator had
233
+ // provisioned) and replaced it with "whoever points a domain at us", which is what makes
234
+ // the ceiling load-bearing rather than prudent. It runs BEFORE the handler, so a refused
235
+ // read costs no query and no fetch.
236
+ router.get('/projects/featured', api.publicProjectFeedRateLimit, async (_req, res) => {
190
237
  try {
191
238
  const projects = await pi.listPublicProjects({ pool });
192
239
  // A stealth project is on the map ANONYMOUSLY (ADR 0182 D2a), so its origin
@@ -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
+ };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.563",
3
+ "version": "1.19.565",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.563",
9
+ "version": "1.19.565",
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.563",
3
+ "version": "1.19.565",
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.563'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
52
+ const CORE_VERSION = '1.19.565'; // 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
@@ -25,6 +25,7 @@ const PUBLISHED_SURFACE = [
25
25
  'pool', 'getPool', 'instanceDbName', 'withTx', 'getBuilderById',
26
26
  'secretBox', // Gemini-key storage relocated to modules/art-pipeline + its 'art.getBuilderGeminiKeyRow' port (ADR 0102)
27
27
  'accountExistenceReadRateLimit', // task 1003339 (ADR 0209): the shared per-IP budget both account-existence probes spend from — one instance, not a factory, so the budget is genuinely shared
28
+ 'publicProjectFeedRateLimit', // task 1002327 (ADR 0252 §5.3): the per-IP ceiling on GET /projects/featured — the platform's most expensive anonymous read (a cold call fans out to up to 500 third-party origins). One instance for the same reason as its neighbour
28
29
  'routeRankCheck', 'permissionPathCheck', // BV1.R73: kernel trust classifiers (1.6.0)
29
30
  'repoInfo', 'moduleScopeMap', 'pathMatch', // BV1.R86: kernel repo-identity + scope-wall + touches-overlap for the carved lifecycle (1.12.0)
30
31
  'goalScopeCheck', // BV1.R60: kernel scope wall for the goal-authored-task create gate (1.13.0; ADR 0086 §3)
@@ -0,0 +1,177 @@
1
+ // tests/project_takedown_lever.mjs — the retraction lever and its per-IP ceiling
2
+ // (R08, task 1002327; ADR 0252 §5, corrected by ADR 0253).
3
+ //
4
+ // ADR 0252 §3 lets a self-hosted project put itself on the public map with NO human act,
5
+ // and the Archon took that decision explicitly conditional on retraction shipping beside
6
+ // it. So these are not follow-up tests — they are the tests for the half of R08 that made
7
+ // auto-appear safe to take at all (ADR 0047: the straight-through path and its abuse
8
+ // guard are one piece of work).
9
+ //
10
+ // The one that matters most is `hidden` surviving the ADR 0246 projection. ADR 0252 §5
11
+ // asserts "no route writes [status]" and defends the takedown against the visibility
12
+ // pull — but the pull is not the writer that undoes takedowns. `upsertOwnedProject` is,
13
+ // and it fires on every provisioning write. Defending against the pull alone would leave
14
+ // the lever decorative for exactly the reason the ADR gives, one writer over.
15
+
16
+ import { test } from 'node:test';
17
+ import assert from 'node:assert/strict';
18
+ import { createRequire } from 'node:module';
19
+ // ESM named imports for the limiter, deliberately, and NOT createRequire: knip does not
20
+ // trace a createRequire() call, so a core export whose only consumer is a MODULE (this one
21
+ // is reached through the doorway, `api.publicProjectFeedRateLimit`) reads as dead code and
22
+ // trips the dead-code ratchet. An ESM import from the test is what credits it — and the
23
+ // test is a real consumer, so this is the honest fix rather than a baseline bump.
24
+ import {
25
+ publicProjectFeedRateLimit,
26
+ accountExistenceReadRateLimit,
27
+ PROJECT_FEED_LIMIT,
28
+ PROJECT_FEED_WINDOW_MS,
29
+ } from '../src/bongos/middleware/rate-limit.js';
30
+
31
+ const require = createRequire(import.meta.url);
32
+ const catalog = require('../modules/platform-identity/project-catalog.js');
33
+ const moduleApi = require('../src/module-api.js');
34
+
35
+ function recordingPool(responses = []) {
36
+ const calls = [];
37
+ const queue = [...responses];
38
+ return {
39
+ calls,
40
+ query(text, params) {
41
+ calls.push({ text, params });
42
+ return Promise.resolve(queue.shift() || { rows: [] });
43
+ },
44
+ };
45
+ }
46
+
47
+ // ── The lever ──
48
+
49
+ test('setProjectStatus writes the status AND pins the row when hiding', async () => {
50
+ const pool = recordingPool([{ rows: [{ id: 4, status: 'hidden', status_pinned: true }] }]);
51
+ const out = await catalog.setProjectStatus(4, 'hidden', { pool });
52
+ assert.equal(out.status, 'hidden');
53
+ const { text, params } = pool.calls[0];
54
+ assert.match(text, /SET status = \$2/);
55
+ assert.match(text, /status_pinned = \(\$2 = 'hidden'\)/);
56
+ assert.deepEqual(params, [4, 'hidden']);
57
+ });
58
+
59
+ test('PATCHing back to active RELEASES the pin — un-hiding is as deliberate as hiding', async () => {
60
+ // The pin is derived from the value in the same statement, so 'active' clears it and
61
+ // the row returns to automatic management. A pin that only ever set would make the
62
+ // first takedown permanent.
63
+ const pool = recordingPool([{ rows: [{ id: 4, status: 'active', status_pinned: false }] }]);
64
+ await catalog.setProjectStatus(4, 'active', { pool });
65
+ assert.match(pool.calls[0].text, /status_pinned = \(\$2 = 'hidden'\)/);
66
+ });
67
+
68
+ test('an unknown status is refused before it reaches the database', async () => {
69
+ const pool = recordingPool();
70
+ await assert.rejects(() => catalog.setProjectStatus(1, 'deleted', { pool }), /unknown status/);
71
+ assert.equal(pool.calls.length, 0);
72
+ });
73
+
74
+ test('a missing row is null, not a silent success', async () => {
75
+ const pool = recordingPool([{ rows: [] }]);
76
+ assert.equal(await catalog.setProjectStatus(999, 'hidden', { pool }), null);
77
+ });
78
+
79
+ // ── The lever survives the writer that actually undoes takedowns ──
80
+
81
+ test('the ADR 0246 projection CANNOT move status on a pinned row', async () => {
82
+ // The regression this exists to catch: hide a project, its owner saves any setting,
83
+ // the bridge re-takes the projection with status 'active', and the takedown is gone.
84
+ const pool = recordingPool([{ rows: [{ id: 1 }] }]);
85
+ await catalog.upsertOwnedProject(
86
+ { origin: 'https://p.example', clientId: 'p', name: 'p', ownerGithubId: 5, visibility: 'public', status: 'active' },
87
+ { pool },
88
+ );
89
+ const { text } = pool.calls[0];
90
+ assert.match(
91
+ text,
92
+ /status\s*=\s*CASE WHEN platform_identity_projects\.status_pinned/,
93
+ 'the projection must honour the pin instead of assigning status outright',
94
+ );
95
+ assert.ok(
96
+ !/\bstatus\s*=\s*EXCLUDED\.status\b/.test(text),
97
+ 'an unconditional status assignment silently reverts every takedown',
98
+ );
99
+ });
100
+
101
+ test('the projection stamps its rows control-plane, so the pull can never reach them', async () => {
102
+ const pool = recordingPool([{ rows: [{ id: 1 }] }]);
103
+ await catalog.upsertOwnedProject(
104
+ { origin: 'https://p.example', clientId: 'p', name: 'p', ownerGithubId: 5, visibility: 'private', status: 'active' },
105
+ { pool },
106
+ );
107
+ const { text } = pool.calls[0];
108
+ assert.match(text, /visibility_source/, 'the insert must record provenance');
109
+ assert.match(text, /visibility_source = 'control-plane'/, 'the UPDATE branch must claim the row too');
110
+ });
111
+
112
+ test('retireOwnedProject deliberately does NOT honour the pin', async () => {
113
+ // A re-point retirement is a SAFETY retraction: an abandoned origin left 'active' keeps
114
+ // drawing an orb at a host the project no longer answers on (the ADR 0181 §2 leak). A
115
+ // curator's 'active' pin must not be able to keep a dead origin on the map. Narrowing
116
+ // is always allowed; it is widening that costs.
117
+ const pool = recordingPool([{ rows: [{ id: 1, status: 'hidden' }] }]);
118
+ await catalog.retireOwnedProject({ origin: 'https://old.example', ownerGithubId: 5 }, { pool });
119
+ assert.ok(!/status_pinned/.test(pool.calls[0].text), 'retirement must stay unconditional');
120
+ });
121
+
122
+ // ── The ceiling ──
123
+
124
+ test('the public feed limiter is ONE exported instance, not a factory (ADR 0209)', () => {
125
+ // Two call sites that each build their own limiter from matching config look identical
126
+ // in review and hand a caller double the rate. Identity is the property.
127
+ assert.equal(typeof publicProjectFeedRateLimit, 'function');
128
+ assert.equal(
129
+ moduleApi.publicProjectFeedRateLimit,
130
+ publicProjectFeedRateLimit,
131
+ 'the doorway must re-export the SAME instance, never a copy',
132
+ );
133
+ });
134
+
135
+ test('the ceiling is 120 reads per 60s — the /public/* posture, stated in both halves', () => {
136
+ // Both numbers are asserted because both are load-bearing: the limit without the window
137
+ // is meaningless, and a later edit that changed only one would otherwise pass.
138
+ assert.equal(PROJECT_FEED_LIMIT, 120);
139
+ assert.equal(PROJECT_FEED_WINDOW_MS, 60 * 1000);
140
+ });
141
+
142
+ test('it refuses past the limit with the ADR 0116 envelope and a Retry-After', () => {
143
+ const limiter = publicProjectFeedRateLimit;
144
+ const headers = {};
145
+ let failed = null;
146
+ const res = {
147
+ set: (k, v) => { headers[k] = v; },
148
+ fail: (code, status, details) => { failed = { code, status, details }; },
149
+ };
150
+ const req = { ip: '203.0.113.77' };
151
+
152
+ let allowed = 0;
153
+ for (let i = 0; i < PROJECT_FEED_LIMIT; i++) limiter(req, res, () => { allowed++; });
154
+ assert.equal(allowed, PROJECT_FEED_LIMIT, 'every read inside the budget passes through');
155
+ assert.equal(failed, null);
156
+
157
+ limiter(req, res, () => { allowed++; });
158
+ assert.equal(allowed, PROJECT_FEED_LIMIT, 'the read past the ceiling must not reach the handler');
159
+ assert.equal(failed.code, 'rate_limited');
160
+ assert.equal(failed.status, 429);
161
+ assert.equal(headers['X-RateLimit-Scope'], 'public-project-feed');
162
+ assert.ok(Number(headers['Retry-After']) >= 1);
163
+ });
164
+
165
+ test('the budget is per-IP — one abusive address cannot close the map for anyone else', () => {
166
+ const limiter = publicProjectFeedRateLimit;
167
+ const res = { set: () => {}, fail: () => { throw new Error('refused'); } };
168
+ let ok = false;
169
+ limiter({ ip: '198.51.100.9' }, res, () => { ok = true; });
170
+ assert.equal(ok, true);
171
+ });
172
+
173
+ test('the feed limiter does NOT share the account-existence budget', () => {
174
+ // Separate questions get separate budgets; the ADR 0209 sharing rule is about two
175
+ // routes answering ONE question, which these are not.
176
+ assert.notEqual(publicProjectFeedRateLimit, accountExistenceReadRateLimit);
177
+ });