@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.
- package/.bongos-core.json +65 -30
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +4 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0252-the-self-hosted-visibility-report-is-a-pull-and-auto-appear-buys-a-retraction-lever.md +109 -0
- package/docs/adr/0253-the-takedown-lever-has-two-writers-and-provenance-is-a-column.md +144 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +91 -4
- package/docs/api-reference.md +3 -2
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/government/catalog.js +1 -1
- package/modules/platform-identity/migrations/platform_identity_020_visibility_pull.sql +106 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/pollers/visibility-pull.js +99 -0
- package/modules/platform-identity/project-catalog.js +62 -4
- package/modules/platform-identity/project-stats.js +31 -0
- package/modules/platform-identity/projects-feed.js +17 -1
- package/modules/platform-identity/routes/projects.js +48 -1
- package/modules/platform-identity/visibility-pull.js +201 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/middleware/rate-limit.js +44 -2
- package/src/module-api.js +12 -1
- package/tests/module_api.mjs +1 -0
- package/tests/project_takedown_lever.mjs +177 -0
- package/tests/visibility_pull.mjs +213 -0
- 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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
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 #
|
|
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.
|
|
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
|
package/tests/module_api.mjs
CHANGED
|
@@ -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
|
+
});
|