@bongos/core 1.19.507 → 1.19.509

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.
@@ -47,6 +47,13 @@ const ORACLE = {
47
47
  '/builders/modules': 'requireBuilderPage',
48
48
  '/builders/goals': 'requireBuilderPage',
49
49
  '/builders/roadmap': 'requireBuilderPage',
50
+ // Gated at this tier all along, but with no row until task 1003448 walked the
51
+ // module's pages: copy-desk (task 1003113) and ideas (task 1002928) are open to
52
+ // every rank by design, and collab's reads (task 1003172) are scoped to the
53
+ // caller by the server — so for all three, sign-in IS the whole gate.
54
+ '/builders/copy-desk': 'requireBuilderPage',
55
+ '/builders/ideas': 'requireBuilderPage',
56
+ '/builders/collab': 'requireBuilderPage',
50
57
  // task 1002710: the orientation reading rooms moved DOWN to this tier from
51
58
  // requireNonXenosPage (thetes+). They are the onboarding documents, so gating
52
59
  // them above xenos turned away the exact reader they were written for. The
@@ -92,10 +99,33 @@ const ORACLE = {
92
99
  '/builders/drachmae': null,
93
100
  '/builders/profile': null,
94
101
  '/builders/not-ready': null,
102
+
103
+ // NOT deliberate — recorded because it is true, and filed as idea 1000907
104
+ // (task 1003448). The detail pages are addressed by a second segment, and
105
+ // /builders/task/:id and /builders/idea/:id are both gated at signed-in; the
106
+ // id-less slugs still resolve to the same shells through BUILDERS_PAGE_RE and
107
+ // match no gate. No data rides the shell and every read behind it is gated
108
+ // server-side, so this is an inconsistency rather than an exposure — but it is
109
+ // a row that should turn into a real gate (or a redirect) when that is decided.
110
+ '/builders/task': null,
111
+ '/builders/idea': null,
112
+
113
+ // Gone, not public — the hall ships no my-projects page at all since task
114
+ // 1003308 (owner's decision: "my projects" is a user-level page the hub owns,
115
+ // so the hall ships no page, no link and no redirect). Nothing is served here,
116
+ // so there is nothing to gate. Recorded rather than left out, because "absent
117
+ // from the map" is indistinguishable from the oversight this file exists to
118
+ // catch. GONE_PAGES below keeps the row honest if the file ever returns.
119
+ '/builders/my-projects': null,
95
120
  };
96
121
 
97
122
  // ─── reconstruct the URL→gate map from the source ───────────────────────────
98
123
  // 1. every `const NAME_RE = /…/;` (the page-matching regexes)
124
+ // The eval below turns a matched regex LITERAL back into a RegExp. Its only
125
+ // input is this repo's own serve-internal.js, read from a path fixed at the top
126
+ // of this file — never a caller's string, never anything fetched. Keep it that
127
+ // way: sourcing SRC from anywhere an outsider can influence makes this arbitrary
128
+ // code execution rather than a parser.
99
129
  function readRegexes(src) {
100
130
  const out = new Map();
101
131
  const re = /const\s+([A-Z0-9_]+_RE)\s*=\s*(\/(?:\\.|\[(?:\\.|[^\]])*\]|[^/\\])+\/[gimsuy]*)\s*;/g;
@@ -170,6 +200,47 @@ test('path-normalization tricks do not slip past a gate', () => {
170
200
  }
171
201
  });
172
202
 
203
+ // ═══ exhaustiveness — the reason a page can go missing from the oracle ═══════
204
+ // The ORACLE above is hand-written, so until now a page could ship with NO gate
205
+ // and NO row and nothing would notice — the same blind spot that let /sessions
206
+ // and /scouting ship ungated, and that left /my-projects out of the map entirely
207
+ // (task 1003448). Walking the module's own pages turns "someone remembered to
208
+ // add a row" into "the tree is walked", which is the half this test was missing.
209
+ const HALL_PAGES_DIR = path.join(ROOT, 'modules/hall-ui/public');
210
+
211
+ function hallPageUrls() {
212
+ return fs.readdirSync(HALL_PAGES_DIR)
213
+ .filter((f) => f.endsWith('.html'))
214
+ .map((f) => (f === 'index.html' ? '/builders/' : `/builders/${f.replace(/\.html$/, '')}`));
215
+ }
216
+
217
+ test('every hall page the module ships has a row in the oracle', () => {
218
+ const missing = hallPageUrls().filter((url) => !(url in ORACLE));
219
+ assert.deepEqual(missing, [],
220
+ `hall pages with no ORACLE row: ${missing.join(', ')} — give each a gate, or a deliberate null with the reason`);
221
+ });
222
+
223
+ // A page can also LEAVE the hall, and then `null` claims something weaker than
224
+ // "the shell is public": nothing is served at that URL at all. Spelling those out
225
+ // keeps the row honest — if the file ever comes back, the null stops describing
226
+ // reality, and this fails instead of quietly waving an ungated shell through.
227
+ const GONE_PAGES = {
228
+ // Removed by an owner decision in task 1003308 — "my projects" is a user-level
229
+ // page the hub already owns, so the hall ships no page, no link and no
230
+ // redirect. tests/hall_settings_world.mjs pins the deletion itself; this row
231
+ // pins what the deletion means for the gate map.
232
+ '/builders/my-projects': 'task 1003308 — the hub owns "my projects"',
233
+ };
234
+
235
+ test('a row that is null because the page left the hall serves no page', () => {
236
+ for (const [url, why] of Object.entries(GONE_PAGES)) {
237
+ assert.equal(ORACLE[url], null, `${url} is gone (${why}), so it can carry no gate`);
238
+ const file = path.join(HALL_PAGES_DIR, `${url.replace('/builders/', '')}.html`);
239
+ assert.ok(!fs.existsSync(file),
240
+ `${url} is back, so "${why}" no longer holds — give it a real gate and a real row`);
241
+ }
242
+ });
243
+
173
244
  test('no gated page is left matching only a REMOVED gate function', () => {
174
245
  // requireNonXenosPage still EXISTS in auth.js (page.view.builder is a live
175
246
  // catalog atom an Archon can attach to a custom role) but task 1002710 left it
@@ -0,0 +1,112 @@
1
+ // tests/my_projects_surface.mjs
2
+ //
3
+ // GUARD (task 1003448): pins the three answers the "/my-projects is unreachable"
4
+ // audit finding actually has, so that none of them is a claim in a session log
5
+ // that the next reader has to take on faith.
6
+ //
7
+ // The audit (2026-08-30) reported the hall's my-projects page as unreachable and
8
+ // ungated, and flagged the second half as "NOT CONFIRMED AS A LEAK — check the
9
+ // API gates first, then decide whether the fix is a gate, a nav entry, or both".
10
+ // By the time the task was worked, the answer to "a gate or a nav entry" had
11
+ // become "neither":
12
+ //
13
+ // 1. WHERE THE PAGE WENT. Task 1003308 deleted it on an owner decision — "my
14
+ // projects" is a user-level page the hub already owns, so the hall ships no
15
+ // page, no link and no redirect. A nav entry would reverse that decision and
16
+ // a page gate would guard a URL that resolves to no file. (The deletion
17
+ // itself is pinned by tests/hall_settings_world.mjs; the gate map's row for
18
+ // it by tests/hall_page_gate_map.mjs.)
19
+ // 2. WHETHER ANYTHING LEAKED. No: every /my-projects* route is sign-in-gated
20
+ // and reads the caller's OWN identity off the session. This is the half the
21
+ // audit could not confirm, so it is asserted here rather than described.
22
+ // 3. WHAT IS STILL MISSING. The invitations half has no caller anywhere — no
23
+ // surface fetches the invite list, so a builder holding an invitation still
24
+ // cannot act on it. That is tracked work (task 1002970, idea 1000903), and
25
+ // pinning it keeps a known gap from decaying back into an unknown one.
26
+ //
27
+ // Static reads only (no import of the app, no DB) → runs in the `unit` gate.
28
+ //
29
+ // Run: node --test --test-reporter=tap tests/my_projects_surface.mjs
30
+
31
+ import { test } from 'node:test';
32
+ import assert from 'node:assert/strict';
33
+ import fs from 'node:fs';
34
+ import path from 'node:path';
35
+ import { fileURLToPath } from 'node:url';
36
+
37
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
38
+ const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
39
+
40
+ const ROUTES = 'modules/platform-identity/routes/my-projects.js';
41
+
42
+ // ── 1. the page is gone from the hall, by decision ──────────────────────────
43
+ // Kept to the ONE fact this file's other assertions lean on: there is no hall
44
+ // page, so "unreachable from the hall" is a decision to honour and not a defect
45
+ // to fix. tests/hall_settings_world.mjs owns the full deletion pin.
46
+ test('the hall ships no my-projects page, so there is nothing to link or gate', () => {
47
+ const dir = path.join(ROOT, 'modules/hall-ui/public');
48
+ const strays = fs.readdirSync(dir).filter((f) => f.startsWith('my-projects'));
49
+ assert.deepEqual(strays, [],
50
+ 'task 1003308 removed the hall page (the hub owns "my projects") — if it is back, that decision changed and this file, the nav and the page gate all need revisiting');
51
+ });
52
+
53
+ // ── 2. the "not confirmed as a leak" half, confirmed ────────────────────────
54
+ // The audit's worry was a shell served to anyone who asked. The shell is gone,
55
+ // but the routes behind it outlived it, so the gate is what matters now.
56
+ const MY_PROJECTS_ROUTES = [
57
+ "router.get('/my-projects'",
58
+ "router.post('/my-projects/join'",
59
+ "router.get('/my-projects/invites'",
60
+ "router.post('/my-projects/invites/:clientId/accept'",
61
+ "router.post('/my-projects/invites/:clientId/decline'",
62
+ "router.post('/my-projects/:clientId/leave'",
63
+ ];
64
+
65
+ test('every /my-projects route is sign-in gated', () => {
66
+ const src = read(ROUTES);
67
+ for (const decl of MY_PROJECTS_ROUTES) {
68
+ const at = src.indexOf(decl);
69
+ assert.notEqual(at, -1, `${decl} — route missing; this list is stale`);
70
+ // The gate is the argument immediately after the path, so read the rest of
71
+ // that line rather than the whole file: a requireBuilder somewhere else in
72
+ // the module must not vouch for a route that carries no gate of its own.
73
+ const line = src.slice(at, src.indexOf('\n', at));
74
+ assert.match(line, /,\s*requireBuilder\s*,/,
75
+ `${decl} must carry requireBuilder — an ungated route here exposes cross-project membership`);
76
+ }
77
+ });
78
+
79
+ test('every /my-projects handler reads the caller identity from the session', () => {
80
+ // The gate only proves SOMEONE is signed in. What keeps one builder out of
81
+ // another's projects is that the github_id comes off req.builder and is never
82
+ // accepted from the caller — no :githubId param, no body-supplied account.
83
+ const src = read(ROUTES);
84
+ assert.ok(src.includes('req.builder.github_id'), 'identity must come off the session');
85
+ assert.doesNotMatch(src, /\/my-projects[^']*:githubId/,
86
+ 'no /my-projects route may take an account id as a path parameter');
87
+ assert.doesNotMatch(src, /githubId:\s*(?:req\.body|req\.query)/,
88
+ 'no /my-projects handler may take the account id from the request body or query');
89
+ });
90
+
91
+ // ── 3. the gap that is left, named and tracked ─────────────────────────────
92
+ test('the invitations API still has no caller — the tracked gap, pinned', () => {
93
+ // Deliberately asserts a MISSING feature. Task 1003308 removed the only
94
+ // surface that fetched invitations, so a builder who holds one has nowhere to
95
+ // accept or decline it. Task 1002970 (blocked on task 1002969) and idea
96
+ // 1000903 own the replacement; this pin exists so the gap stays visible until
97
+ // one of them lands, and so it is impossible to close this quietly.
98
+ const surfaces = [];
99
+ const modules = path.join(ROOT, 'modules');
100
+ for (const key of fs.readdirSync(modules)) {
101
+ const pub = path.join(modules, key, 'public');
102
+ if (!fs.existsSync(pub)) continue;
103
+ for (const f of fs.readdirSync(pub)) {
104
+ if (!/\.(js|html)$/.test(f)) continue;
105
+ if (fs.readFileSync(path.join(pub, f), 'utf8').includes('/my-projects/invites')) {
106
+ surfaces.push(`modules/${key}/public/${f}`);
107
+ }
108
+ }
109
+ }
110
+ assert.deepEqual(surfaces, [],
111
+ `an invitations surface exists now (${surfaces.join(', ')}) — good: delete this pin and close task 1002970 / idea 1000903`);
112
+ });
@@ -1813,6 +1813,68 @@ test('one row grammar: the every-project row and the Your-projects row lay the S
1813
1813
  assert.match(COSMOS, /#sub-table-body\{overflow-x:auto;\}/, 'the wrap scrolls, never the page');
1814
1814
  });
1815
1815
 
1816
+ // ── the publish gate on the owner's own pages (task 1002335, BV1.R16) ────────
1817
+
1818
+ test('the manage page carries an "on the public map" card, and it ships as real markup', () => {
1819
+ const card = (HUB.match(/<div class="card mSec" id="mPublish"[\s\S]*?\n <\/div>/) || [''])[0];
1820
+ assert.ok(card, '#mPublish ships as markup, not built by script (C7)');
1821
+ assert.match(card, /hidden/, 'hidden until the server sends a verdict — a platform with no map says nothing');
1822
+ assert.match(card, /id="mPub"/, 'the volatile slot the verdict renders into');
1823
+ // the explanation is a SIBLING of the volatile slot, the #mRepoVisWhy shape, so
1824
+ // no re-render of the slot can drop it
1825
+ assert.match(card, /<details class="finePrint" id="mPubWhat">/);
1826
+ assert.match(card, /keep empty shells off the map/, 'it says why the bar exists, not just what it is');
1827
+ });
1828
+
1829
+ test('the verdict rendered is the SERVER’s — the browser never re-derives the gate', () => {
1830
+ const fn = (SCRIPT.match(/function renderPublish\(inst\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
1831
+ assert.ok(fn, 'renderPublish exists');
1832
+ assert.match(fn, /inst && inst\.publish/, 'it reads the verdict off the instance');
1833
+ assert.match(fn, /v\.missing_phrase/, 'and names what is missing in the server’s own words (ADR 0182 D4)');
1834
+ // no second copy of the two-field rule anywhere in the page
1835
+ assert.doesNotMatch(SCRIPT, /publishable\s*=\s*[^;]*description/,
1836
+ 'the page must never compute publishability itself — that is how two surfaces disagree');
1837
+ // a missing verdict HIDES the card rather than guessing one
1838
+ assert.match(fn, /if \(!v\) \{ \$\('mPublish'\)\.hidden = true; return; \}/);
1839
+ });
1840
+
1841
+ test('the owner’s prose is set as a property, never interpolated into markup', () => {
1842
+ const fn = (SCRIPT.match(/function renderPublish\(inst\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
1843
+ assert.match(fn, /\$\('mPubDesc'\)\.value = inst\.description \|\| '';/,
1844
+ 'what someone typed about their project is not HTML');
1845
+ assert.doesNotMatch(fn, /esc\(inst\.description/, 'and it never rides the innerHTML string at all');
1846
+ });
1847
+
1848
+ test('the publish card is rendered on entry only — the in-flight tick must not eat a half-typed sentence', () => {
1849
+ // renderManage() is called by the fetch-free minute tick AND the 5s in-flight
1850
+ // watch. A textarea repainted from either loses the caret and the text.
1851
+ const renderManage = (SCRIPT.match(/function renderManage\(inst\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
1852
+ assert.doesNotMatch(renderManage, /renderPublish/,
1853
+ 'renderPublish must NOT hang off the repaint path');
1854
+ assert.match(SCRIPT, /if \(!keepRepo\) renderPublish\(inst\);/,
1855
+ 'it rides the entry + tab-return guard the other fetching sections use');
1856
+ });
1857
+
1858
+ test('saving the description takes the server’s verdict whole, and clearing it is a real answer', () => {
1859
+ const fn = (SCRIPT.match(/function doPubSave\(\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
1860
+ assert.match(fn, /\/detail'/, 'it PATCHes the detail route R15 built as the way back');
1861
+ assert.match(fn, /method: 'PATCH'/);
1862
+ assert.match(fn, /manageInst\.publish = data\.publish \|\| manageInst\.publish/,
1863
+ 'the verdict comes back from the server, not from what was typed');
1864
+ assert.match(fn, /stays off the public map/,
1865
+ 'clearing the description un-publishes the project, and the receipt says so');
1866
+ });
1867
+
1868
+ test('a dark-matter project says so on its list card too, and only then', () => {
1869
+ const fn = (SCRIPT.match(/function publishRow\(inst\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
1870
+ assert.ok(fn, 'publishRow exists');
1871
+ assert.match(fn, /if \(!v \|\| v\.publishable\) return '';/,
1872
+ 'being on the map is the expected state — announcing it on every card is noise');
1873
+ assert.match(fn, /torn_down/, 'a torn-down project has no business being told about the public map');
1874
+ assert.match(fn, /view=manage&id=/, 'the refusal points at the one page that fixes it');
1875
+ assert.match(SCRIPT, /var pub = publishRow\(inst\);/, 'and the card actually renders it');
1876
+ });
1877
+
1816
1878
  test('the sort left the column head for a control the keyboard and a screen reader can both read (task 1003356)', () => {
1817
1879
  // aria-sort is only valid on a columnheader; with no header row the order has
1818
1880
  // to be stated by the control itself and by a sentence in a live region
@@ -0,0 +1,167 @@
1
+ // tests/publish_gate.mjs — the PUBLISH GATE (task 1002335, BV1.R16; ADR 0182 D3/D4).
2
+ //
3
+ // A project is on the public map if and only if it has a name AND a description.
4
+ // That is the whole bar — identity only, deliberately not a completeness check —
5
+ // and until it is met the project is DARK MATTER: off the map and un-joinable.
6
+ //
7
+ // R04 (task 1002323) landed the DEFINITION, dormant. This file pins what R16 does
8
+ // with it, which is three things:
9
+ //
10
+ // 1. the gate BLOCKS the one publish action that could bypass it — an Archon
11
+ // featuring a row. ADR 0182 D3: `featured` may rank or promote among projects
12
+ // already on the map, it may never put one there;
13
+ // 2. every refusal NAMES what is absent (D4), in one shared phrase, because a
14
+ // project that cannot be published and is not told why is indistinguishable
15
+ // from a broken one;
16
+ // 3. the OTHER plane — `provisioning`, where an owner stands before their project
17
+ // is ever catalogued — asks the same gate through the kernel seam instead of
18
+ // keeping a second copy of the rule. The ADR 0083 one-way rule forbids the
19
+ // import; a duplicated two-field predicate is exactly the drift that the
20
+ // single SQL definition (publishableSql) exists to prevent.
21
+ //
22
+ // Pure + DB-free: the pool is a recording fake, and the seam is the real port object.
23
+ //
24
+ // Run: node --test tests/publish_gate.mjs
25
+
26
+ import assert from 'node:assert/strict';
27
+ import { test } from 'node:test';
28
+ import { createRequire } from 'node:module';
29
+
30
+ const require = createRequire(import.meta.url);
31
+ const pi = require('../modules/platform-identity/platform-identity.js');
32
+ const api = require('../src/module-api.js');
33
+
34
+ // The REAL port object, registered exactly as routes/projects.js registers it at
35
+ // boot — so provisioning below is answered by platform-identity's own gate, not by
36
+ // a stub that could agree with a rule nobody ships.
37
+ if (!api.hasProvider('project.publishGate')) {
38
+ api.registerProvider('project.publishGate', pi.publishGatePort);
39
+ }
40
+ const provRoutes = require('../modules/provisioning/routes/provisioning.js');
41
+
42
+ // A recording fake pool: returns queued responses in order, records every call.
43
+ function mockPool(responses = []) {
44
+ const calls = [];
45
+ let i = 0;
46
+ return {
47
+ calls,
48
+ query: async (text, params) => {
49
+ calls.push({ text: String(text), params: params || [] });
50
+ return responses[i++] || { rows: [], rowCount: 0 };
51
+ },
52
+ };
53
+ }
54
+
55
+ // ── what is missing, said the same way everywhere ────────────────────────────
56
+
57
+ test('missingPhrase names every absent field, so no surface invents its own wording', () => {
58
+ assert.equal(pi.missingPhrase(['description']), 'no description');
59
+ assert.equal(pi.missingPhrase(['name', 'description']), 'no name and no description');
60
+ // a met bar has nothing to name — the caller renders the "on the map" sentence
61
+ assert.equal(pi.missingPhrase([]), '');
62
+ assert.equal(pi.missingPhrase(undefined), '');
63
+ });
64
+
65
+ // ── the gate as a kernel seam port ───────────────────────────────────────────
66
+
67
+ test('the gate is published as a PORT, so another module can ask without importing', () => {
68
+ const port = pi.publishGatePort;
69
+ assert.deepEqual([...port.fields].sort(), ['description', 'name']);
70
+ assert.deepEqual(port.evaluate({ name: 'Mercury', description: 'A weather sim.' }),
71
+ { publishable: true, missing: [] });
72
+ assert.deepEqual(port.evaluate({ name: 'Mercury' }), { publishable: false, missing: ['description'] });
73
+ assert.equal(typeof port.missingPhrase, 'function');
74
+ // The port answers with the SAME function the SQL predicate mirrors — a caller
75
+ // that reached a private copy could drift from the feed's own definition.
76
+ assert.deepEqual(port.evaluate({ name: 'x', description: ' ' }),
77
+ pi.projectPublishability({ name: 'x', description: ' ' }));
78
+ });
79
+
80
+ test('the module manifest DECLARES the port — an undeclared provider is invisible to the boot check', () => {
81
+ const manifest = require('../modules/platform-identity/module.json');
82
+ assert.ok(manifest.provides.includes('project.publishGate'));
83
+ });
84
+
85
+ // ── featuring cannot publish an unpublishable project (ADR 0182 D3) ──────────
86
+
87
+ test('featuring is bounded IN THE UPDATE, not by a read-then-write', async () => {
88
+ const pool = mockPool([{ rows: [{ id: '7', origin: 'https://p', name: 'P', featured: true, status: 'active' }] }]);
89
+ const out = await pi.setProjectFeatured(7, true, { pool });
90
+ assert.equal(out.project.featured, true);
91
+ assert.equal(pool.calls.length, 1, 'the happy path is ONE statement — no separate check to race');
92
+ const sql = pool.calls[0].text;
93
+ assert.match(sql, /UPDATE platform_identity_projects SET featured = \$2/);
94
+ // the bound is the same predicate the public feed reads
95
+ assert.ok(sql.includes(pi.publishableSql()), 'the gate rides in the WHERE, from the one SQL definition');
96
+ assert.deepEqual(pool.calls[0].params, [7, true]);
97
+ });
98
+
99
+ test('featuring a DARK-MATTER project is refused, and the refusal names what is missing', async () => {
100
+ const pool = mockPool([
101
+ { rows: [] }, // the guarded UPDATE matched nothing
102
+ { rows: [{ id: '7', origin: 'https://p', name: 'P', description: null }] }, // …because of the gate
103
+ ]);
104
+ const out = await pi.setProjectFeatured(7, true, { pool });
105
+ assert.equal(out.blocked, true);
106
+ assert.deepEqual(out.missing, ['description'], 'ADR 0182 D4 — never merely refuse');
107
+ assert.ok(!out.project, 'nothing was featured');
108
+ });
109
+
110
+ test('a missing row is NOT reported as a gate refusal — the two answers are different', async () => {
111
+ const pool = mockPool([{ rows: [] }, { rows: [] }]);
112
+ const out = await pi.setProjectFeatured(999, true, { pool });
113
+ assert.equal(out.notFound, true);
114
+ assert.ok(!out.blocked, 'a curator told to "add a description" for a row that does not exist is sent chasing nothing');
115
+ });
116
+
117
+ test('UN-featuring is never gate-bound — a husk that is already featured must stay removable', async () => {
118
+ const pool = mockPool([{ rows: [{ id: '7', featured: false }] }]);
119
+ const out = await pi.setProjectFeatured(7, false, { pool });
120
+ assert.equal(out.project.featured, false);
121
+ // the predicate short-circuits on the OFF direction, in SQL, for every row
122
+ assert.match(pool.calls[0].text, /\$2 = false OR /);
123
+ assert.deepEqual(pool.calls[0].params, [7, false]);
124
+ });
125
+
126
+ // ── the other plane asks the same gate through the seam ──────────────────────
127
+
128
+ test('provisioning maps its own row onto the gate: an instance slug IS its name', () => {
129
+ const told = provRoutes.publishVerdict({ slug: 'mercury', description: 'A weather sim.' });
130
+ assert.equal(told.publishable, true);
131
+ assert.deepEqual(told.missing, []);
132
+
133
+ // The About step skipped: the only identity field this plane can lack.
134
+ const skipped = provRoutes.publishVerdict({ slug: 'mercury', description: null });
135
+ assert.equal(skipped.publishable, false);
136
+ assert.deepEqual(skipped.missing, ['description']);
137
+ assert.equal(skipped.missing_phrase, 'no description',
138
+ 'the phrase travels WITH the verdict so the owner-facing card cannot word the rule differently');
139
+
140
+ // A slug is never empty, so `name` can never be the missing half here — and the
141
+ // gate is never told about a column this table does not have.
142
+ assert.ok(!skipped.missing.includes('name'));
143
+ });
144
+
145
+ test('an instance read carries the verdict, so no read can ship the row without the reason', () => {
146
+ const row = { id: 4, slug: 'mercury', status: 'active', description: null, detail: {} };
147
+ const out = provRoutes.ownerInstance(row);
148
+ assert.equal(out.slug, 'mercury', 'the allow-listed projection is still there');
149
+ assert.equal(out.publish.publishable, false);
150
+ assert.deepEqual(out.publish.missing, ['description']);
151
+ });
152
+
153
+ test('with NO hub on the platform there is no map — and the verdict is silence, not a guess', () => {
154
+ // resolveOptional's absent case. platform-identity is hub-only (`default: false`),
155
+ // and the public map is a hub concept: an instance without it has nothing to be
156
+ // on. A locally-invented rule here is exactly what the seam exists to prevent, so
157
+ // the surfaces hide the section instead.
158
+ const seams = require('../src/module-seams.js');
159
+ const saved = seams.resolve('project.publishGate');
160
+ seams._reset();
161
+ try {
162
+ assert.equal(provRoutes.publishVerdict({ slug: 'mercury', description: null }), null);
163
+ assert.equal(provRoutes.ownerInstance({ id: 1, slug: 'mercury' }).publish, null);
164
+ } finally {
165
+ seams.registerProvider('project.publishGate', saved);
166
+ }
167
+ });