@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,146 @@
|
|
|
1
|
+
// tests/manage_settings_shared_read.mjs — one read of the project's settings
|
|
2
|
+
// row per manage entry, EXECUTED (task 1003582).
|
|
3
|
+
//
|
|
4
|
+
// The defect this pins shut: entering /projects?view=manage fired
|
|
5
|
+
// GET /provisioning/instances/:id/settings three times in a row, because the
|
|
6
|
+
// three cards that render off that row — how the project is reached, how people
|
|
7
|
+
// join, and its door on the map — each fetched it for themselves. Three round
|
|
8
|
+
// trips and three DB reads for one row, growing by one with every settings-
|
|
9
|
+
// backed card added to the page.
|
|
10
|
+
//
|
|
11
|
+
// tests/projects_hub.mjs pins that all three cards go through the shared
|
|
12
|
+
// reader; this file lifts the reader itself out of modules/public-landing/
|
|
13
|
+
// public/projects.html, runs it in a vm under a scripted fetch, and counts the
|
|
14
|
+
// round trips. The vm-harness precedent is tests/project_door_ui.mjs.
|
|
15
|
+
//
|
|
16
|
+
// What it proves, and why each matters:
|
|
17
|
+
// • three callers in one entry cost ONE round trip — the defect itself
|
|
18
|
+
// • forgetSettings() makes the next read real, so entry and tab return each
|
|
19
|
+
// see the row as it is now, never the one from the last visit
|
|
20
|
+
// • that is also what lets the alert's "Try again" retry after a failure: a
|
|
21
|
+
// rejected promise left memoised would answer with the failure forever
|
|
22
|
+
// • switching projects is never served the previous project's row, even with
|
|
23
|
+
// the memo still warm — the cache is keyed on the project, not just present
|
|
24
|
+
// • a refusal RESOLVES carrying its status, so each card can still tell a
|
|
25
|
+
// lapsed session apart from an outage in its own words
|
|
26
|
+
//
|
|
27
|
+
// Run: node --test --test-reporter=tap tests/manage_settings_shared_read.mjs
|
|
28
|
+
|
|
29
|
+
import assert from 'node:assert/strict';
|
|
30
|
+
import { test } from 'node:test';
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
import vm from 'node:vm';
|
|
34
|
+
import { fileURLToPath } from 'node:url';
|
|
35
|
+
|
|
36
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
37
|
+
const HUB = fs.readFileSync(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'), 'utf8');
|
|
38
|
+
const SCRIPT = (HUB.match(/<script>([\s\S]*?)<\/script>\s*<\/body>/) || [, ''])[1];
|
|
39
|
+
|
|
40
|
+
// The reader, sliced from the page verbatim. Both boundaries are asserted so a
|
|
41
|
+
// rename cannot leave this file executing an empty string and passing.
|
|
42
|
+
const START = SCRIPT.indexOf(' var settingsPromise = null;');
|
|
43
|
+
const END = SCRIPT.indexOf(' function loadVisibility(');
|
|
44
|
+
assert.ok(START > 0, 'the shared reader must start at its memo declaration');
|
|
45
|
+
assert.ok(END > START, 'the shared reader must sit above its first consumer');
|
|
46
|
+
const REGION = SCRIPT.slice(START, END);
|
|
47
|
+
for (const fn of ['function ensureSettings(', 'function forgetSettings(']) {
|
|
48
|
+
assert.ok(REGION.includes(fn), `the region must carry ${fn}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── the harness ──────────────────────────────────────────────────────────────
|
|
52
|
+
// Narrow on purpose: the region touches fetch, jsonOrEmpty, API and manageId,
|
|
53
|
+
// and nothing else — so the stub stays honest about what it can prove.
|
|
54
|
+
|
|
55
|
+
function boot({ ok = true, status = 200, body = { settings: { visibility: 'public' } }, reject = false } = {}) {
|
|
56
|
+
const requests = [];
|
|
57
|
+
const sandbox = {
|
|
58
|
+
Promise, JSON, String, Object, Error, encodeURIComponent,
|
|
59
|
+
API: '/api/bongos',
|
|
60
|
+
manageId: '18',
|
|
61
|
+
jsonOrEmpty: (res) => Promise.resolve(res._json),
|
|
62
|
+
fetch: (url, init = {}) => {
|
|
63
|
+
requests.push({ url, credentials: init.credentials });
|
|
64
|
+
return reject ? Promise.reject(new Error('network')) : Promise.resolve({ ok, status, _json: body });
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
sandbox.globalThis = sandbox;
|
|
68
|
+
vm.createContext(sandbox);
|
|
69
|
+
vm.runInContext(REGION, sandbox, { filename: 'projects.html#shared-settings-read' });
|
|
70
|
+
|
|
71
|
+
const settle = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
|
|
72
|
+
return { requests, sandbox, settle };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── the tests ────────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
test('the three cards of one manage entry cost ONE round trip, not three', async () => {
|
|
78
|
+
// Arrange — the page's own entry pass: three readers, back to back
|
|
79
|
+
const ui = boot();
|
|
80
|
+
|
|
81
|
+
// Act
|
|
82
|
+
const answers = await Promise.all([
|
|
83
|
+
ui.sandbox.ensureSettings(),
|
|
84
|
+
ui.sandbox.ensureSettings(),
|
|
85
|
+
ui.sandbox.ensureSettings(),
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
// Assert
|
|
89
|
+
assert.equal(ui.requests.length, 1, 'one GET served all three cards');
|
|
90
|
+
assert.equal(ui.requests[0].url, '/api/bongos/provisioning/instances/18/settings');
|
|
91
|
+
assert.equal(ui.requests[0].credentials, 'same-origin', 'the cookie session is the auth — this page carries no token');
|
|
92
|
+
for (const a of answers) assert.deepEqual(a.data.settings, { visibility: 'public' }, 'every caller got the same row');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('forgetSettings makes the next read real — entry and tab return never reuse the last visit’s row', async () => {
|
|
96
|
+
// Arrange
|
|
97
|
+
const ui = boot();
|
|
98
|
+
await ui.sandbox.ensureSettings();
|
|
99
|
+
|
|
100
|
+
// Act — what loadManage does at the top of every entry + tab return pass
|
|
101
|
+
ui.sandbox.forgetSettings();
|
|
102
|
+
await ui.sandbox.ensureSettings();
|
|
103
|
+
|
|
104
|
+
// Assert
|
|
105
|
+
assert.equal(ui.requests.length, 2, 'the second entry asked the server again');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('a failed read can be retried — the rejection is not memoised past forgetSettings', async () => {
|
|
109
|
+
// Arrange — the outage the manage alert's "Try again" exists for
|
|
110
|
+
const ui = boot({ reject: true });
|
|
111
|
+
await assert.rejects(ui.sandbox.ensureSettings(), /network/);
|
|
112
|
+
|
|
113
|
+
// Act — the retry runs loadManage again, which forgets first
|
|
114
|
+
ui.sandbox.forgetSettings();
|
|
115
|
+
await assert.rejects(ui.sandbox.ensureSettings(), /network/);
|
|
116
|
+
|
|
117
|
+
// Assert
|
|
118
|
+
assert.equal(ui.requests.length, 2, 'the retry was a real second attempt, not the stored failure');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('switching projects is never served the previous project’s row', async () => {
|
|
122
|
+
// Arrange — the memo is warm for project 18
|
|
123
|
+
const ui = boot();
|
|
124
|
+
await ui.sandbox.ensureSettings();
|
|
125
|
+
|
|
126
|
+
// Act — the owner opens a different project; loadManage has not forgotten yet
|
|
127
|
+
ui.sandbox.manageId = '19';
|
|
128
|
+
await ui.sandbox.ensureSettings();
|
|
129
|
+
|
|
130
|
+
// Assert
|
|
131
|
+
assert.equal(ui.requests.length, 2, 'the memo is keyed on the project, not merely present');
|
|
132
|
+
assert.equal(ui.requests[1].url, '/api/bongos/provisioning/instances/19/settings');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('a refusal RESOLVES carrying its status, so each card can still say what happened', async () => {
|
|
136
|
+
// Arrange — a lapsed session: the card must offer sign-in, not report an outage
|
|
137
|
+
const ui = boot({ ok: false, status: 401, body: {} });
|
|
138
|
+
|
|
139
|
+
// Act
|
|
140
|
+
const ans = await ui.sandbox.ensureSettings();
|
|
141
|
+
|
|
142
|
+
// Assert
|
|
143
|
+
assert.equal(ans.ok, false);
|
|
144
|
+
assert.equal(ans.status, 401, 'the status reaches the card — 401 reads differently from 500');
|
|
145
|
+
assert.deepEqual(ans.data, {}, 'an empty body is an object, so a card can read through it without guarding');
|
|
146
|
+
});
|
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)
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
// the page, so the region is sliced out rather than read from a .js file, and the
|
|
14
14
|
// slice boundaries are asserted so a rename can never quietly test nothing.
|
|
15
15
|
//
|
|
16
|
+
// Since task 1003582 the settings row is read once per manage entry by a shared
|
|
17
|
+
// ensureSettings(), declared ABOVE this region — so the sandbox injects it (see
|
|
18
|
+
// tests/manage_settings_shared_read.mjs for that reader's own behaviour).
|
|
19
|
+
//
|
|
16
20
|
// What it proves, and why each matters:
|
|
17
21
|
// • the three choices render with the goal's PLAIN interview labels
|
|
18
22
|
// • the project's CURRENT door is the preselected one — an owner opening the
|
|
@@ -105,13 +109,23 @@ function boot({ settings, options = ['public', 'private', 'stealth'], patchAnswe
|
|
|
105
109
|
const fetch = (url, init = {}) => {
|
|
106
110
|
requests.push({ url, method: init.method || 'GET', body: init.body, credentials: init.credentials, headers: init.headers });
|
|
107
111
|
if ((init.method || 'GET') === 'GET') {
|
|
108
|
-
|
|
112
|
+
// the row is read through ensureSettings() below — a GET arriving here
|
|
113
|
+
// means this section grew a second reader, which is the whole defect
|
|
114
|
+
throw new Error('this section reads the settings row through ensureSettings(), never its own GET');
|
|
109
115
|
}
|
|
110
116
|
return patchAnswer || answer({ ok: true, saved: { ...settings, visibility: JSON.parse(init.body).visibility }, push: { queued: true, message: 'Saved. Your project will restart to pick this up — until then it keeps its current setting.' } });
|
|
111
117
|
};
|
|
118
|
+
// The shared read the three settings cards live off (task 1003582). Answers
|
|
119
|
+
// in ensureSettings()'s own shape — resolved, status carried, never thrown —
|
|
120
|
+
// and still records the round trip, so a card that stopped reading the row
|
|
121
|
+
// would show up here as a missing request rather than as silence.
|
|
122
|
+
const ensureSettings = () => {
|
|
123
|
+
requests.push({ url: '/api/bongos/provisioning/instances/18/settings', method: 'GET', credentials: 'same-origin' });
|
|
124
|
+
return Promise.resolve({ ok: true, status: 200, data: { settings, options: { visibility: options } } });
|
|
125
|
+
};
|
|
112
126
|
|
|
113
127
|
const sandbox = {
|
|
114
|
-
$, document, fetch, console, Promise, JSON, String, Object, Array, Error, RegExp,
|
|
128
|
+
$, document, fetch, ensureSettings, console, Promise, JSON, String, Object, Array, Error, RegExp,
|
|
115
129
|
encodeURIComponent, setTimeout, clearTimeout,
|
|
116
130
|
API: '/api/bongos',
|
|
117
131
|
manageId: '18',
|
|
@@ -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
|
+
});
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -1449,11 +1449,33 @@ test('the in-flight line reads open_intent only, and re-asks the manifest when t
|
|
|
1449
1449
|
test('the settings reads fire on entry + tab return only — never on the in-flight tick', () => {
|
|
1450
1450
|
assert.match(SCRIPT, /if \(!keepRepo\) loadVisibility\(inst\);/);
|
|
1451
1451
|
const load = fnHub('loadVisibility');
|
|
1452
|
-
assert.match(load,
|
|
1453
|
-
assert.match(load, /
|
|
1452
|
+
assert.match(load, /ensureSettings\(\)/, 'the row comes from the shared reader, not a GET of its own');
|
|
1453
|
+
assert.match(load, /ans\.status === 401 \? signInAgain\(\)/, 'a lapsed session is said as sign-in, not as an outage');
|
|
1454
1454
|
assert.match(load, /var asked = manageId;/, 'a stale answer must never describe a newer project');
|
|
1455
1455
|
});
|
|
1456
1456
|
|
|
1457
|
+
/* One read of the settings row per manage entry (task 1003582). Three cards
|
|
1458
|
+
render three different keys out of the SAME row, and each used to fetch it
|
|
1459
|
+
for itself — three identical round trips per entry, one more with every card
|
|
1460
|
+
added. tests/manage_settings_shared_read.mjs proves the reader's behaviour;
|
|
1461
|
+
this pins that all three cards actually go through it. */
|
|
1462
|
+
test('the three settings cards share ONE read of the row per manage entry', () => {
|
|
1463
|
+
const ensure = fnHub('ensureSettings');
|
|
1464
|
+
assert.match(ensure, /\/settings', \{ credentials: 'same-origin' \}/, 'GET /provisioning/instances/:id/settings — the one reader');
|
|
1465
|
+
assert.match(ensure, /if \(settingsPromise && settingsFor === manageId\) return settingsPromise;/,
|
|
1466
|
+
'memoised AND keyed on the project — a switch must never be served the last one’s row');
|
|
1467
|
+
for (const fn of ['loadVisibility', 'loadJoinability', 'loadDoor']) {
|
|
1468
|
+
assert.match(fnHub(fn), /ensureSettings\(\)/, `${fn} reads the row through the shared reader`);
|
|
1469
|
+
assert.doesNotMatch(fnHub(fn), /fetch\(/, `${fn} fires no GET of its own`);
|
|
1470
|
+
}
|
|
1471
|
+
assert.match(SCRIPT, /if \(!keepRepo\) forgetSettings\(\);/,
|
|
1472
|
+
'the memo is dropped on entry + tab return, so neither is ever served a cached row');
|
|
1473
|
+
/* only the READS are shared: each card still writes its own key */
|
|
1474
|
+
for (const fn of ['doVisSave', 'doJoinSave', 'doDoorSave']) {
|
|
1475
|
+
assert.match(fnHub(fn), /method: 'PATCH'/, `${fn} still PATCHes its own key`);
|
|
1476
|
+
}
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1457
1479
|
/* ── how people join (task 1003044): the joinability toggle — the visibility
|
|
1458
1480
|
section's shape, key for key: the choice from the platform's row, what the
|
|
1459
1481
|
project runs from its own manifest (brand.project.joinability), the save's
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// tests/visibility_pull.mjs — the self-hosted visibility PULL (R08, task 1002327;
|
|
2
|
+
// ADR 0252, corrected by ADR 0253).
|
|
3
|
+
//
|
|
4
|
+
// DB-free. The three properties worth a test are the three that are easy to get wrong in
|
|
5
|
+
// the direction that silently breaks the map:
|
|
6
|
+
//
|
|
7
|
+
// 1. THE TRI-STATE DOES NOT PURGE. A project that answered once and is unreachable now
|
|
8
|
+
// keeps its door AND its stamp. The naive version — treat "no answer" as a value —
|
|
9
|
+
// quietly empties the map one outage at a time.
|
|
10
|
+
// 2. THE PULL NEVER TOUCHES A CONTROL-PLANE ROW, at the decision AND in the SQL. Two
|
|
11
|
+
// writers on one column is the drift ADR 0246's projection exists to prevent.
|
|
12
|
+
// 3. THE PULL NEVER MOVES `status`. This is what makes the retraction lever real
|
|
13
|
+
// (ADR 0252 §5.2) — asserted against the statement text, so adding a status write
|
|
14
|
+
// later reds this test rather than silently undoing every takedown.
|
|
15
|
+
|
|
16
|
+
import { test } from 'node:test';
|
|
17
|
+
import assert from 'node:assert/strict';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const vp = require('../modules/platform-identity/visibility-pull.js');
|
|
22
|
+
const feed = require('../modules/platform-identity/projects-feed.js');
|
|
23
|
+
|
|
24
|
+
// A pool that records every statement and answers from a queue.
|
|
25
|
+
function recordingPool(responses = []) {
|
|
26
|
+
const calls = [];
|
|
27
|
+
const queue = [...responses];
|
|
28
|
+
return {
|
|
29
|
+
calls,
|
|
30
|
+
query(text, params) {
|
|
31
|
+
calls.push({ text, params });
|
|
32
|
+
return Promise.resolve(queue.shift() || { rows: [] });
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const manifest = (visibility) => ({ brand: { project: { visibility } } });
|
|
38
|
+
|
|
39
|
+
// ── 1. What a manifest is allowed to say ──
|
|
40
|
+
|
|
41
|
+
test('reads the join door from the live manifest key, which is `brand`, not `branding`', () => {
|
|
42
|
+
// ADR 0252 §2 and task 1002327's body both write `branding.project.visibility`, but
|
|
43
|
+
// buildInstanceManifest projects the pack under `brand`. Reading only the documented
|
|
44
|
+
// spelling would have made every pull a silent no-verdict — the failure that looks
|
|
45
|
+
// exactly like "no self-hosted project ever reports".
|
|
46
|
+
assert.equal(vp.visibilityFromManifest({ brand: { project: { visibility: 'private' } } }), 'private');
|
|
47
|
+
assert.equal(vp.visibilityFromManifest({ branding: { project: { visibility: 'private' } } }), 'private');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('an off-vocabulary or absent answer is NO VERDICT, never a default', () => {
|
|
51
|
+
// Defaulting to 'public' would let a project that said nothing be read as having asked
|
|
52
|
+
// to be on the map — ADR 0252 §2's "no verdict, never a default".
|
|
53
|
+
for (const body of [
|
|
54
|
+
{}, null, undefined,
|
|
55
|
+
{ brand: {} },
|
|
56
|
+
{ brand: { project: {} } },
|
|
57
|
+
{ brand: { project: { visibility: 'unlisted' } } },
|
|
58
|
+
{ brand: { project: { visibility: 'PUBLIC' } } }, // case is not normalised away
|
|
59
|
+
{ brand: { project: { visibility: 42 } } },
|
|
60
|
+
{ brand: { project: { visibility: { public: true } } } },
|
|
61
|
+
]) {
|
|
62
|
+
assert.equal(vp.visibilityFromManifest(body), null, `expected no verdict for ${JSON.stringify(body)}`);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('the channel carries visibility ONLY — no name, tagline or art can ride it', () => {
|
|
67
|
+
// ADR 0252 §7: today no project owner controls a free-text field that reaches the
|
|
68
|
+
// public feed. A visibility report is not the place to spend that property.
|
|
69
|
+
const out = vp.visibilityFromManifest({
|
|
70
|
+
brand: { project: { visibility: 'public' }, identity: { productName: 'Injected' } },
|
|
71
|
+
name: 'Injected', tagline: 'Injected', art_url: 'https://evil.example/x.png',
|
|
72
|
+
});
|
|
73
|
+
assert.equal(out, 'public');
|
|
74
|
+
assert.equal(typeof out, 'string');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ── 2. The tri-state ──
|
|
78
|
+
|
|
79
|
+
test('never read + no answer => stays dark; nothing is written', () => {
|
|
80
|
+
const d = vp.pullDecision({ visibility: 'public', visibility_reported_at: null, visibility_source: null }, null);
|
|
81
|
+
assert.equal(d.write, false);
|
|
82
|
+
assert.equal(d.reason, 'never_reported');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('READ BEFORE + unreachable now => KEEPS its last state and its stamp', () => {
|
|
86
|
+
// The load-bearing half of the tri-state. A project does not leave the map because its
|
|
87
|
+
// server was down for one sweep (ADR 0199 / 0226). Implemented as "write nothing",
|
|
88
|
+
// which is also what keeps `visibility_reported_at` meaning LAST SUCCESSFUL read.
|
|
89
|
+
const row = { visibility: 'public', visibility_reported_at: new Date('2026-09-01'), visibility_source: 'pull' };
|
|
90
|
+
const d = vp.pullDecision(row, null);
|
|
91
|
+
assert.equal(d.write, false);
|
|
92
|
+
assert.equal(d.reason, 'unreachable_keeps_last_state');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('a NARROWING report is applied immediately — hiding yourself needs no trust', () => {
|
|
96
|
+
const row = { visibility: 'public', visibility_reported_at: new Date('2026-09-01'), visibility_source: 'pull' };
|
|
97
|
+
const d = vp.pullDecision(row, 'stealth');
|
|
98
|
+
assert.deepEqual(d, { write: true, visibility: 'stealth' });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('a WIDENING report is applied too — the pull is the door\'s only source for these rows', () => {
|
|
102
|
+
const row = { visibility: 'stealth', visibility_reported_at: new Date('2026-09-01'), visibility_source: 'pull' };
|
|
103
|
+
assert.deepEqual(vp.pullDecision(row, 'public'), { write: true, visibility: 'public' });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('an unchanged answer writes nothing, so a sweep cannot churn feed ordering', () => {
|
|
107
|
+
// listPublicProjects orders on updated_at, so bumping it every 15 minutes would make a
|
|
108
|
+
// row's position on the map track the sweep clock instead of the project.
|
|
109
|
+
const row = { visibility: 'public', visibility_reported_at: new Date('2026-09-01'), visibility_source: 'pull' };
|
|
110
|
+
assert.equal(vp.pullDecision(row, 'public').write, false);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('a first successful read DOES write, even when the value equals the column default', () => {
|
|
114
|
+
// The row starts at the NOT NULL DEFAULT 'public' with no stamp. Treating that as
|
|
115
|
+
// "unchanged" would leave visibility_reported_at NULL forever, so the project would
|
|
116
|
+
// stay dark matter no matter how many times it answered — the bug the `visibility_source
|
|
117
|
+
// === 'pull'` half of the unchanged-check exists to prevent.
|
|
118
|
+
const row = { visibility: 'public', visibility_reported_at: null, visibility_source: null };
|
|
119
|
+
assert.deepEqual(vp.pullDecision(row, 'public'), { write: true, visibility: 'public' });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ── 3. The two writers never share a row ──
|
|
123
|
+
|
|
124
|
+
test('a control-plane row is refused at the decision, whatever it reports', () => {
|
|
125
|
+
for (const reported of ['public', 'private', 'stealth', null]) {
|
|
126
|
+
const d = vp.pullDecision({ visibility: 'private', visibility_source: 'control-plane' }, reported);
|
|
127
|
+
assert.equal(d.write, false);
|
|
128
|
+
assert.equal(d.reason, 'control_plane_row');
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('the exclusion is ALSO in the SQL, at both the read and the write', async () => {
|
|
133
|
+
// A bridge projection can land between the SELECT and the UPDATE, so the invariant has
|
|
134
|
+
// to hold at the write, not merely at the read. `IS DISTINCT FROM` and not `<>`: a NULL
|
|
135
|
+
// source is the unclaimed state, and plain inequality drops exactly those rows.
|
|
136
|
+
assert.match(vp.PULLABLE_SQL, /IS DISTINCT FROM 'control-plane'/);
|
|
137
|
+
|
|
138
|
+
const readPool = recordingPool([{ rows: [] }]);
|
|
139
|
+
await vp.pullableProjects({ pool: readPool });
|
|
140
|
+
assert.ok(readPool.calls[0].text.includes(vp.PULLABLE_SQL), 'the read must filter on PULLABLE_SQL');
|
|
141
|
+
|
|
142
|
+
const writePool = recordingPool([{ rows: [{ id: 1 }] }]);
|
|
143
|
+
await vp.recordVisibilityPull(1, 'public', { pool: writePool });
|
|
144
|
+
assert.ok(writePool.calls[0].text.includes(vp.PULLABLE_SQL), 'the write must re-assert PULLABLE_SQL');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('the write refuses an off-vocabulary value rather than trusting its caller', async () => {
|
|
148
|
+
const pool = recordingPool();
|
|
149
|
+
await assert.rejects(() => vp.recordVisibilityPull(1, 'unlisted', { pool }), /off-vocabulary/);
|
|
150
|
+
assert.equal(pool.calls.length, 0, 'nothing may reach the database');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ── 4. The pull may not move `status` ──
|
|
154
|
+
|
|
155
|
+
test('NO statement in the pull writes `status` — the retraction lever stays real', async () => {
|
|
156
|
+
// ADR 0252 §5.2: a retracted project that keeps reporting `public` STAYS hidden. The
|
|
157
|
+
// cheapest possible proof is that the column never appears in a write this file issues.
|
|
158
|
+
const pool = recordingPool([{ rows: [{ id: 7 }] }]);
|
|
159
|
+
await vp.recordVisibilityPull(7, 'public', { pool });
|
|
160
|
+
const sql = pool.calls.map((c) => c.text).join('\n');
|
|
161
|
+
assert.ok(!/\bSET\b[\s\S]*\bstatus\b/.test(sql), `a pull statement writes status:\n${sql}`);
|
|
162
|
+
assert.ok(!/status_pinned/.test(sql), 'a pull may not touch the takedown latch either');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ── 5. End to end, and the failure isolation the sweep depends on ──
|
|
166
|
+
|
|
167
|
+
test('an unreachable instance is a no-verdict, not a crash and not a value', async () => {
|
|
168
|
+
const pool = recordingPool();
|
|
169
|
+
const out = await vp.pullOne(
|
|
170
|
+
{ id: 1, origin: 'https://a.example', visibility: 'public', visibility_reported_at: new Date(), visibility_source: 'pull' },
|
|
171
|
+
{ pool, fetchVisibility: async () => { throw new Error('ECONNREFUSED'); } },
|
|
172
|
+
);
|
|
173
|
+
assert.equal(out.written, false);
|
|
174
|
+
assert.equal(out.reason, 'unreachable_keeps_last_state');
|
|
175
|
+
assert.equal(pool.calls.length, 0, 'a failed read must not write');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('one hostile instance does not stop the sweep reading the others', async () => {
|
|
179
|
+
const rows = [
|
|
180
|
+
{ id: 1, origin: 'https://bad.example', visibility: 'public', visibility_reported_at: null, visibility_source: null },
|
|
181
|
+
{ id: 2, origin: 'https://good.example', visibility: 'public', visibility_reported_at: null, visibility_source: null },
|
|
182
|
+
];
|
|
183
|
+
const pool = recordingPool([{ rows }, { rows: [{ id: 2 }] }]);
|
|
184
|
+
const out = await vp.sweepVisibilityOnce({
|
|
185
|
+
pool,
|
|
186
|
+
fetchVisibility: async (origin) => {
|
|
187
|
+
if (origin === 'https://bad.example') throw new Error('boom');
|
|
188
|
+
return manifest('stealth');
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
assert.equal(out.scanned, 2);
|
|
192
|
+
assert.equal(out.updated, 1);
|
|
193
|
+
assert.equal(out.results[1].visibility, 'stealth');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ── 6. The feed predicate, and the trap it would be without its scoping ──
|
|
197
|
+
|
|
198
|
+
test('the map predicate admits a control-plane row with no stamp — ADR 0247 D4\'s trap', () => {
|
|
199
|
+
// Every row on the live table has visibility_reported_at NULL, because the column is
|
|
200
|
+
// new. A predicate that required a stamp of EVERY row would empty the public map on
|
|
201
|
+
// deploy — the exact shape ADR 0247 D4 found live, one column over. The
|
|
202
|
+
// `visibility_source = 'control-plane'` disjunct plus migration 020's backfill are what
|
|
203
|
+
// keep the default scoped to rows that have genuinely never answered.
|
|
204
|
+
assert.match(feed.DOOR_KNOWN_SQL, /visibility_source = 'control-plane'/);
|
|
205
|
+
assert.match(feed.DOOR_KNOWN_SQL, /visibility_reported_at IS NOT NULL/);
|
|
206
|
+
assert.match(feed.DOOR_KNOWN_SQL, /\bOR\b/, 'the two halves must be a disjunction, not a conjunction');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('the public feed actually carries the predicate', async () => {
|
|
210
|
+
const pool = recordingPool([{ rows: [] }]);
|
|
211
|
+
await feed.listPublicProjects({ pool });
|
|
212
|
+
assert.ok(pool.calls[0].text.includes(feed.DOOR_KNOWN_SQL), 'listPublicProjects must join DOOR_KNOWN_SQL');
|
|
213
|
+
});
|
|
@@ -256,9 +256,20 @@ async function callFeaturedFeed(rows) {
|
|
|
256
256
|
let body = null;
|
|
257
257
|
const res = {
|
|
258
258
|
json: (b) => { body = b; },
|
|
259
|
+
set: () => {},
|
|
259
260
|
fail: (code, detail) => { throw new Error(`route failed: ${code} ${JSON.stringify(detail)}`); },
|
|
260
261
|
};
|
|
261
|
-
|
|
262
|
+
// Drive the WHOLE stack, not stack[0]. Since task 1002327 the per-IP feed limiter
|
|
263
|
+
// sits in front of the handler (ADR 0252 §5.3), so calling the first entry alone
|
|
264
|
+
// would exercise the ceiling and never reach the route — and, worse, would keep
|
|
265
|
+
// passing as a silent no-op if the handler were ever removed. A fresh req per call
|
|
266
|
+
// keeps each test's reads out of the previous one's budget.
|
|
267
|
+
const req = { ip: `198.51.100.${Math.floor(Math.random() * 200) + 1}` };
|
|
268
|
+
for (const entry of layer.route.stack) {
|
|
269
|
+
let advanced = false;
|
|
270
|
+
await entry.handle(req, res, () => { advanced = true; });
|
|
271
|
+
if (!advanced) break; // something short-circuited (a refusal), or it was the handler
|
|
272
|
+
}
|
|
262
273
|
return { body, askedFor };
|
|
263
274
|
} finally {
|
|
264
275
|
pi.listPublicProjects = savedList;
|