@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.
- package/.bongos-core.json +41 -26
- package/.claude/skills/new-project/SKILL.md +2 -0
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0182-project-visibility-two-axes.md +1 -0
- package/docs/api/openapi.json +5 -3
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +210 -207
- package/docs/copy-registry.json +297 -269
- package/docs/module-api-changelog.md +4 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/platform-identity.js +30 -36
- package/modules/platform-identity/publish-gate.js +103 -0
- package/modules/platform-identity/routes/projects.js +31 -4
- package/modules/platform-identity/tests/platform-identity.mjs +7 -2
- package/modules/provisioning/routes/provisioning.js +51 -5
- package/modules/public-landing/public/projects.html +157 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/hall_page_gate_map.mjs +71 -0
- package/tests/my_projects_surface.mjs +112 -0
- package/tests/projects_hub.mjs +62 -0
- package/tests/publish_gate.mjs +167 -0
|
@@ -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
|
+
});
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -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
|
+
});
|