@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.
- package/.bongos-core.json +68 -33
- 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/0253-the-takedown-lever-has-two-writers-and-provenance-is-a-column.md +144 -0
- package/docs/adr/README.md +1 -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/modules/public-landing/public/projects.html +89 -53
- 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/manage_settings_shared_read.mjs +146 -0
- package/tests/module_api.mjs +1 -0
- package/tests/project_door_ui.mjs +16 -2
- package/tests/project_takedown_lever.mjs +177 -0
- package/tests/projects_hub.mjs +24 -2
- package/tests/visibility_pull.mjs +213 -0
- 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
|
-
|
|
5944
|
-
.then(function (
|
|
5945
|
-
if (asked !== manageId)
|
|
5946
|
-
|
|
5947
|
-
|
|
5948
|
-
|
|
5949
|
-
|
|
5950
|
-
|
|
5951
|
-
|
|
5952
|
-
|
|
5953
|
-
|
|
5954
|
-
|
|
5955
|
-
|
|
5956
|
-
|
|
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 (
|
|
5962
|
-
|
|
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
|
-
|
|
6317
|
-
.then(function (
|
|
6318
|
-
if (asked !== manageId)
|
|
6319
|
-
|
|
6320
|
-
|
|
6321
|
-
|
|
6322
|
-
|
|
6323
|
-
|
|
6324
|
-
|
|
6325
|
-
|
|
6326
|
-
|
|
6327
|
-
|
|
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 (
|
|
6333
|
-
if (
|
|
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
|
-
|
|
6543
|
-
.then(function (
|
|
6544
|
-
if (asked !== manageId)
|
|
6545
|
-
|
|
6546
|
-
|
|
6547
|
-
|
|
6548
|
-
|
|
6549
|
-
|
|
6550
|
-
|
|
6551
|
-
|
|
6552
|
-
|
|
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 (
|
|
6558
|
-
if (
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
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.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
|