@bongos/core 1.19.564 → 1.19.566

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/.bongos-core.json +68 -33
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +4 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0253-the-takedown-lever-has-two-writers-and-provenance-is-a-column.md +144 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +91 -4
  10. package/docs/api-reference.md +3 -2
  11. package/docs/module-api-changelog.md +4 -0
  12. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  13. package/modules/government/catalog.js +1 -1
  14. package/modules/platform-identity/migrations/platform_identity_020_visibility_pull.sql +106 -0
  15. package/modules/platform-identity/module.json +1 -1
  16. package/modules/platform-identity/pollers/visibility-pull.js +99 -0
  17. package/modules/platform-identity/project-catalog.js +62 -4
  18. package/modules/platform-identity/project-stats.js +31 -0
  19. package/modules/platform-identity/projects-feed.js +17 -1
  20. package/modules/platform-identity/routes/projects.js +48 -1
  21. package/modules/platform-identity/visibility-pull.js +201 -0
  22. package/modules/public-landing/public/projects.html +89 -53
  23. package/package-lock.json +2 -2
  24. package/package.json +1 -1
  25. package/src/bongos/middleware/rate-limit.js +44 -2
  26. package/src/module-api.js +12 -1
  27. package/tests/manage_settings_shared_read.mjs +146 -0
  28. package/tests/module_api.mjs +1 -0
  29. package/tests/project_door_ui.mjs +16 -2
  30. package/tests/project_takedown_lever.mjs +177 -0
  31. package/tests/projects_hub.mjs +24 -2
  32. package/tests/visibility_pull.mjs +213 -0
  33. package/tests/visible_projects_feed.mjs +12 -1
@@ -0,0 +1,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
+ });
@@ -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
- return answer({ ok: true, settings, options: { visibility: options } });
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
+ });
@@ -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, /\/settings', \{ credentials: 'same-origin' \}/, 'GET /provisioning/instances/:id/settings');
1453
- assert.match(load, /res\.status === 401 \? signInAgain\(\)/, 'a lapsed session is said as sign-in, not as an outage');
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
- await layer.route.stack[0].handle({}, res, () => {});
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;