@bongos/core 1.19.508 → 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 +35 -25
- 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 +2 -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/projects_hub.mjs +62 -0
- package/tests/publish_gate.mjs +167 -0
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
|
+
});
|