@bongos/core 1.19.510 → 1.19.512

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 +48 -38
  2. package/config/branding.neutral.json +3 -2
  3. package/docs/adr/0233-a-copy-proposal-is-a-task-carrying-a-patch.md +16 -2
  4. package/docs/adr/0238-the-join-door-is-a-provisioning-settings-policy-key.md +57 -0
  5. package/docs/adr/README.md +1 -0
  6. package/docs/copy-inventory.md +124 -118
  7. package/docs/copy-registry.json +223 -158
  8. package/docs/module-api-changelog.md +4 -0
  9. package/modules/copy-desk/proposals.js +13 -0
  10. package/modules/copy-desk/routes/copy-desk.js +9 -2
  11. package/modules/copy-desk/tests/copy_proposals.mjs +41 -0
  12. package/modules/lifecycle/conflict-resolve.js +59 -3
  13. package/modules/provisioning/provisioning.js +27 -1
  14. package/modules/provisioning/tests/provisioning.mjs +17 -14
  15. package/modules/public-landing/public/assets/cosmos.css +3 -1
  16. package/modules/public-landing/public/projects.html +125 -0
  17. package/modules/ui-design/kit/serve.js +4 -3
  18. package/package-lock.json +2 -2
  19. package/package.json +1 -1
  20. package/scripts/gds/copy-apply.js +54 -4
  21. package/scripts/gds/git-merge-regen.js +18 -0
  22. package/src/branding.js +9 -0
  23. package/src/module-api.js +1 -1
  24. package/tests/conflict_resolve.mjs +118 -4
  25. package/tests/currency_label.mjs +5 -4
  26. package/tests/git_merge_regen.mjs +128 -2
  27. package/tests/project_door_ui.mjs +267 -0
  28. package/tests/projects_hub.mjs +86 -1
  29. package/tests/projects_hub_pre_uat.mjs +4 -1
  30. package/tests/provision_settings_apply.mjs +8 -7
  31. package/tests/provisioning_settings.mjs +65 -6
  32. package/tests/provisioning_settings_apply.mjs +3 -3
  33. package/tests/provisioning_settings_env.mjs +10 -8
@@ -0,0 +1,267 @@
1
+ // tests/project_door_ui.mjs — the owner's join-door control, EXECUTED (BV1.R05,
2
+ // task 1002324, ADR 0182 D1).
3
+ //
4
+ // tests/projects_hub.mjs pins this section's source text; this file pins its
5
+ // BEHAVIOUR. It lifts the real region out of modules/public-landing/public/
6
+ // projects.html — the option table, renderDoor, loadDoor, doDoorSave and the
7
+ // delegated click — runs it in a vm under a minimal DOM stub, PARSES the markup
8
+ // it writes into innerHTML, and drives a real click on the Save control.
9
+ //
10
+ // Why a vm harness rather than jsdom: there is no jsdom in this repo. The
11
+ // precedent is tests/task_detail_ui.mjs (after tests/goal_criterion_confirm_ui.mjs);
12
+ // this one differs in where the source comes from — the hub's script is INLINE in
13
+ // the page, so the region is sliced out rather than read from a .js file, and the
14
+ // slice boundaries are asserted so a rename can never quietly test nothing.
15
+ //
16
+ // What it proves, and why each matters:
17
+ // • the three choices render with the goal's PLAIN interview labels
18
+ // • the project's CURRENT door is the preselected one — an owner opening the
19
+ // page reads their own setting, never a default that misreports it
20
+ // • only values the SERVER offered are rendered (the options list is the source)
21
+ // • Save PATCHes the own-scoped settings channel with exactly { visibility }
22
+ // • the platform's receipt is shown verbatim, and a refusal is SAID — the radio
23
+ // never moves to a value the server did not accept
24
+ //
25
+ // Run: node --test --test-reporter=tap tests/project_door_ui.mjs
26
+
27
+ import assert from 'node:assert/strict';
28
+ import { test } from 'node:test';
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+ import vm from 'node:vm';
32
+ import { fileURLToPath } from 'node:url';
33
+
34
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
35
+ const HUB = fs.readFileSync(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'), 'utf8');
36
+ const SCRIPT = (HUB.match(/<script>([\s\S]*?)<\/script>\s*<\/body>/) || [, ''])[1];
37
+
38
+ // The region under test, sliced from the page verbatim. Both boundaries are
39
+ // asserted: a rename that moved either one would otherwise leave this file
40
+ // executing an empty string and passing.
41
+ const START = SCRIPT.indexOf('var DOOR_OPTIONS = {');
42
+ const END = SCRIPT.indexOf('/* ── delete the project');
43
+ assert.ok(START > 0, 'the join-door region must start at DOOR_OPTIONS');
44
+ assert.ok(END > START, 'the join-door region must end before the delete section');
45
+ const REGION = SCRIPT.slice(START, END);
46
+ for (const fn of ['function renderDoor(', 'function loadDoor(', 'function doDoorSave(']) {
47
+ assert.ok(REGION.includes(fn), `the region must carry ${fn}`);
48
+ }
49
+
50
+ // ── the DOM stub ─────────────────────────────────────────────────────────────
51
+ // Narrow on purpose (the task_detail_ui.mjs rule): it supports exactly the
52
+ // selectors the region uses, so it stays honest about what it can prove.
53
+
54
+ const RADIO = /<input type="radio" name="mDoorPick" value="([^"]*)"( checked)?>/g;
55
+ const OPTION = /<label class="visOpt"><input type="radio" name="mDoorPick" value="([^"]*)"( checked)?><span><b>([^<]*)<\/b>([^<]*)<\/span><\/label>/g;
56
+
57
+ function parseOptions(html) {
58
+ const out = [];
59
+ for (const m of String(html).matchAll(OPTION)) {
60
+ out.push({ value: m[1], checked: !!m[2], label: m[3], blurb: m[4] });
61
+ }
62
+ return out;
63
+ }
64
+
65
+ // Boot the region against a scripted settings channel and hand back the handles
66
+ // a test drives it with.
67
+ function boot({ settings, options = ['public', 'private', 'stealth'], patchAnswer } = {}) {
68
+ const requests = [];
69
+ const els = new Map();
70
+ const listeners = new Map();
71
+
72
+ const makeEl = (id) => ({
73
+ _id: id, _html: '', textContent: '', className: '', disabled: false,
74
+ set innerHTML(v) { this._html = String(v); },
75
+ get innerHTML() { return this._html; },
76
+ addEventListener(type, fn) {
77
+ if (type !== 'click') return;
78
+ if (!listeners.has(id)) listeners.set(id, []);
79
+ listeners.get(id).push(fn);
80
+ },
81
+ });
82
+ const $ = (id) => {
83
+ if (!els.has(id)) els.set(id, makeEl(id));
84
+ return els.get(id);
85
+ };
86
+
87
+ // what the owner has clicked; starts as whatever the render marked checked
88
+ let picked = null;
89
+ const slotHtml = () => ($('mDoor')._html || '');
90
+ const saveBtn = { hasAttribute: (a) => a === 'data-door-save', disabled: false };
91
+
92
+ const document = {
93
+ querySelector(sel) {
94
+ if (sel === '#mDoor input[name="mDoorPick"]:checked') {
95
+ if (picked !== null) return { value: picked };
96
+ const found = [...slotHtml().matchAll(RADIO)].find((m) => m[2]);
97
+ return found ? { value: found[1] } : null;
98
+ }
99
+ if (sel === '#mDoor [data-door-save]') return slotHtml().includes('data-door-save') ? saveBtn : null;
100
+ throw new Error(`the stub does not support the selector ${sel}`);
101
+ },
102
+ };
103
+
104
+ const answer = (body, { ok = true, status = 200 } = {}) => Promise.resolve({ ok, status, _json: body });
105
+ const fetch = (url, init = {}) => {
106
+ requests.push({ url, method: init.method || 'GET', body: init.body, credentials: init.credentials, headers: init.headers });
107
+ if ((init.method || 'GET') === 'GET') {
108
+ return answer({ ok: true, settings, options: { visibility: options } });
109
+ }
110
+ 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
+ };
112
+
113
+ const sandbox = {
114
+ $, document, fetch, console, Promise, JSON, String, Object, Array, Error, RegExp,
115
+ encodeURIComponent, setTimeout, clearTimeout,
116
+ API: '/api/bongos',
117
+ manageId: '18',
118
+ manageInst: { id: 18, status: 'active' },
119
+ esc: (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'),
120
+ jsonOrEmpty: (res) => Promise.resolve(res._json),
121
+ apiError: (data, status) => ({ code: (data && data.error && data.error.code) || 'error', message: (data && data.error && data.error.message) || `HTTP ${status}` }),
122
+ signInAgain: () => '<a href="/signin">Sign in again</a>',
123
+ loadManage: () => { requests.push({ url: 'loadManage' }); },
124
+ };
125
+ sandbox.globalThis = sandbox;
126
+ vm.createContext(sandbox);
127
+ vm.runInContext(REGION, sandbox, { filename: 'projects.html#join-door' });
128
+
129
+ const settle = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
130
+ return {
131
+ requests, sandbox, settle,
132
+ options: () => parseOptions(slotHtml()),
133
+ slot: () => slotHtml(),
134
+ msg: () => $('mDoorMsg'),
135
+ pick: (v) => { picked = v; },
136
+ async load() { sandbox.loadDoor(); await settle(); },
137
+ async clickSave() {
138
+ const handlers = listeners.get('mDoor') || [];
139
+ assert.equal(handlers.length, 1, 'the section wires exactly one delegated click handler');
140
+ handlers[0]({ target: { closest: (sel) => (sel === 'button' ? saveBtn : null) } });
141
+ await settle();
142
+ },
143
+ };
144
+ }
145
+
146
+ // ── the tests ────────────────────────────────────────────────────────────────
147
+
148
+ test('the three choices render with the goal’s plain interview labels', async () => {
149
+ // Arrange
150
+ const ui = boot({ settings: { visibility: 'public' } });
151
+
152
+ // Act
153
+ await ui.load();
154
+
155
+ // Assert
156
+ assert.deepEqual(ui.options().map((o) => o.label), ['Public', 'Private', 'Stealth']);
157
+ assert.deepEqual(ui.options().map((o) => o.value), ['public', 'private', 'stealth']);
158
+ assert.match(ui.slot(), /role="radiogroup"/, 'the pick is a real radio group, not a list of buttons');
159
+ for (const o of ui.options()) assert.ok(o.blurb.length > 20, `${o.label} says what it changes`);
160
+ });
161
+
162
+ test('the project’s current door is the one preselected — never a default that misreports it', async () => {
163
+ // Arrange
164
+ const ui = boot({ settings: { visibility: 'stealth' } });
165
+
166
+ // Act
167
+ await ui.load();
168
+
169
+ // Assert
170
+ const checked = ui.options().filter((o) => o.checked);
171
+ assert.equal(checked.length, 1, 'exactly one option is preselected');
172
+ assert.equal(checked[0].value, 'stealth');
173
+ });
174
+
175
+ test('only the values the SERVER offered are rendered', async () => {
176
+ // Arrange — a server that offers two (an older platform, or a narrowed list)
177
+ const ui = boot({ settings: { visibility: 'public' }, options: ['public', 'private'] });
178
+
179
+ // Act
180
+ await ui.load();
181
+
182
+ // Assert
183
+ assert.deepEqual(ui.options().map((o) => o.value), ['public', 'private']);
184
+ });
185
+
186
+ test('saving PATCHes the own-scoped settings channel with exactly { visibility }', async () => {
187
+ // Arrange
188
+ const ui = boot({ settings: { visibility: 'public' } });
189
+ await ui.load();
190
+ ui.pick('private');
191
+
192
+ // Act
193
+ await ui.clickSave();
194
+
195
+ // Assert
196
+ const patch = ui.requests.find((r) => r.method === 'PATCH');
197
+ assert.ok(patch, 'a PATCH was sent');
198
+ assert.equal(patch.url, '/api/bongos/provisioning/instances/18/settings');
199
+ assert.deepEqual(JSON.parse(patch.body), { visibility: 'private' }, 'exactly the one key this section owns');
200
+ assert.equal(patch.credentials, 'same-origin', 'the cookie session is the auth — this page carries no token');
201
+ });
202
+
203
+ test('the platform’s receipt is shown verbatim — the page never writes its own promise', async () => {
204
+ // Arrange
205
+ const ui = boot({ settings: { visibility: 'public' } });
206
+ await ui.load();
207
+ ui.pick('stealth');
208
+
209
+ // Act
210
+ await ui.clickSave();
211
+
212
+ // Assert
213
+ assert.equal(ui.msg().textContent,
214
+ 'Saved. Your project will restart to pick this up — until then it keeps its current setting.');
215
+ assert.equal(ui.msg().className, 'projMsg ok');
216
+ });
217
+
218
+ test('a refusal is SAID, and the radio never moves to a value the server did not accept', async () => {
219
+ // Arrange
220
+ const ui = boot({
221
+ settings: { visibility: 'public' },
222
+ patchAnswer: Promise.resolve({ ok: false, status: 400, _json: { error: { code: 'validation_failed', message: 'visibility is not one of: public, private, stealth' } } }),
223
+ });
224
+ await ui.load();
225
+ ui.pick('unlisted');
226
+
227
+ // Act
228
+ await ui.clickSave();
229
+
230
+ // Assert
231
+ assert.equal(ui.msg().className, 'projMsg err');
232
+ assert.match(ui.msg().textContent, /not one of: public, private, stealth/, 'the server’s own sentence');
233
+ assert.equal(ui.sandbox.mDoorSettings.value, 'public', 'the stored choice is untouched by a refused save');
234
+ });
235
+
236
+ test('a 409 intent_conflict is saved-but-waiting, not a failure', async () => {
237
+ // Arrange — the settings row IS written; another run holds the restart slot
238
+ const ui = boot({
239
+ settings: { visibility: 'public' },
240
+ patchAnswer: Promise.resolve({ ok: false, status: 409, _json: { error: { code: 'intent_conflict', message: 'The change was saved, but a \'provision\' run is currently running for this project.' } } }),
241
+ });
242
+ await ui.load();
243
+ ui.pick('private');
244
+
245
+ // Act
246
+ await ui.clickSave();
247
+
248
+ // Assert
249
+ assert.equal(ui.msg().className, 'projMsg', 'not painted as an error — the change is recorded');
250
+ assert.match(ui.msg().textContent, /The change was saved/);
251
+ });
252
+
253
+ test('a signed-out save offers a way back in rather than a dead end', async () => {
254
+ // Arrange
255
+ const ui = boot({
256
+ settings: { visibility: 'public' },
257
+ patchAnswer: Promise.resolve({ ok: false, status: 401, _json: {} }),
258
+ });
259
+ await ui.load();
260
+ ui.pick('private');
261
+
262
+ // Act
263
+ await ui.clickSave();
264
+
265
+ // Assert
266
+ assert.match(ui.msg().innerHTML, /Sign in again/);
267
+ });
@@ -1135,7 +1135,7 @@ test('the manage view ships as real markup with the four settings homes (C7)', (
1135
1135
  assert.match(HUB, /id="view-manage"/);
1136
1136
  for (const id of ['mGate', 'mSignin', 'mMissing', 'mAlert', 'mLoading', 'mMain',
1137
1137
  'mName', 'mChip', 'mMeta', 'mAddr', 'mCreated',
1138
- 'mVisibility', 'mJoinability', 'mRepoVisibility', 'mDelete']) {
1138
+ 'mVisibility', 'mJoinability', 'mDoorState', 'mRepoVisibility', 'mDelete']) {
1139
1139
  assert.match(HUB, new RegExp(`id="${id}"`), `${id} must be real markup`);
1140
1140
  }
1141
1141
  // every settings home is live now (tasks 1003042 / 1003043 / 1003044) — no not-yet copy remains
@@ -1475,6 +1475,78 @@ test('the joinability section is live markup with its body slot and a sibling fo
1475
1475
  assert.match(fold, /restarts the project/, 'the restart is said, not hidden (ADR 0190)');
1476
1476
  });
1477
1477
 
1478
+ /* the join door (task 1002324, ADR 0182 D1): the third settings axis — how the
1479
+ project stands on the platform's MAP, beside its own sign-in door and its own
1480
+ membership door. Its labels are the goal's interview decision: PLAIN words,
1481
+ because the space metaphor belongs to the drawn orb and never to the copy. */
1482
+
1483
+ test('the join-door section is live markup with its body slot and a sibling fold (C7)', () => {
1484
+ assert.match(HUB, /id="mDoor"/, 'the volatile slot is real markup, not JS-built');
1485
+ assert.match(HUB, /id="mDoorWhat"/, 'the what-changes fold ships with the page');
1486
+ const card = (HUB.match(/<div class="card mSec" id="mDoorState">[\s\S]*?\n <\/div>/) || [''])[0];
1487
+ assert.ok(card, 'the join-door card must be extractable');
1488
+ const slotAt = card.indexOf('id="mDoor"');
1489
+ const foldAt = card.indexOf('id="mDoorWhat"');
1490
+ assert.ok(slotAt > 0 && foldAt > slotAt, 'the fold sits outside the slot it must survive');
1491
+ const fold = (HUB.match(/<details class="finePrint" id="mDoorWhat">[\s\S]*?<\/details>/) || [''])[0];
1492
+ for (const label of ['Public', 'Private', 'Stealth']) {
1493
+ assert.match(fold, new RegExp(`<b>${label}</b>`), `${label} is explained in the fold`);
1494
+ }
1495
+ // ADR 0182 D4: the bar is identity, and the gate must NAME what is missing
1496
+ assert.match(fold, /only once it has a name and a description/, 'the publish bar is stated, so a Public pick that shows nowhere is never a mystery');
1497
+ assert.match(fold, /separate from “Who can see it”/, 'the two doors are named as separate settings');
1498
+ assert.match(fold, /restarts the project/, 'the restart is said, not hidden (ADR 0190)');
1499
+ });
1500
+
1501
+ test('the join-door labels are PLAIN — the space metaphor is the orb’s, never the copy’s (the goal’s interview decision)', () => {
1502
+ assert.match(SCRIPT, /var DOOR_OPTIONS = \{\s*public: \{ label: 'Public'/);
1503
+ assert.match(SCRIPT, /private: \{ label: 'Private'/);
1504
+ assert.match(SCRIPT, /stealth: \{ label: 'Stealth'/);
1505
+ // the card is the whole owner-facing surface of this setting: its markup plus
1506
+ // the option table the render reads its words from
1507
+ const card = (HUB.match(/<div class="card mSec" id="mDoorState">[\s\S]*?\n <\/div>/) || [''])[0];
1508
+ const options = (SCRIPT.match(/var DOOR_OPTIONS = \{[\s\S]*?\n \};/) || [''])[0];
1509
+ assert.ok(card && options, 'both the card and the option table must be extractable');
1510
+ // whole words only — "restarts" legitimately contains "star"
1511
+ for (const word of ['planet', 'star', 'black hole', 'dark matter', 'dark-matter']) {
1512
+ const re = new RegExp(`\\b${word}\\b`);
1513
+ assert.doesNotMatch(card.toLowerCase(), re, `"${word}" must not reach the owner’s copy`);
1514
+ assert.doesNotMatch(options.toLowerCase(), re, `"${word}" must not reach a choice’s label or blurb`);
1515
+ }
1516
+ // and the derived fourth state is never offered as a pick (ADR 0182 D2)
1517
+ assert.doesNotMatch(options, /unlisted/i, 'dark matter is DERIVED — there is deliberately no way to choose it');
1518
+ });
1519
+
1520
+ test('the join-door pick is a real radio group built from the server’s own options list', () => {
1521
+ const render = fnHub('renderDoor');
1522
+ assert.match(render, /\(s\.options \|\| \[\]\)\.filter\(function \(v\) \{ return !!DOOR_OPTIONS\[v\]; \}\)/,
1523
+ 'only values the page can describe are offered — the server’s options list is the source');
1524
+ assert.match(render, /role="radiogroup"/);
1525
+ assert.match(render, /type="radio" name="mDoorPick"/);
1526
+ assert.match(render, /v === s\.value \? ' checked' : ''/, 'the project’s current door is the preselected one');
1527
+ assert.match(render, /data-door-save/, 'one Save control');
1528
+ });
1529
+
1530
+ test('the join-door save sends the one key and shows the platform’s receipt verbatim', () => {
1531
+ const save = fnHub('doDoorSave');
1532
+ assert.match(save, /method: 'PATCH'/);
1533
+ assert.match(save, /\/settings'/, 'PATCH /provisioning/instances/:id/settings — the own-scoped channel its two siblings use');
1534
+ assert.match(save, /JSON\.stringify\(\{ visibility: value \}\)/, 'the body is the one key this section owns');
1535
+ assert.match(save, /\(data\.push && data\.push\.message\)/, 'push.message is rendered as the server wrote it');
1536
+ assert.match(save, /e\.code === 'intent_conflict' \? 'projMsg' : 'projMsg err'/,
1537
+ 'a 409 intent_conflict is saved-but-waiting — shown as the server’s sentence, not as a failure');
1538
+ assert.match(save, /loadManage\(true, true\)/, 'the strip + in-flight line re-read open_intent after a save');
1539
+ });
1540
+
1541
+ test('the join door is read on entry + tab return, like every other settings section', () => {
1542
+ assert.match(SCRIPT, /if \(!keepRepo\) loadDoor\(\);/, 'never from the in-flight tick');
1543
+ // deliberately NOT a manifest read-back: an already-deployed project reports no
1544
+ // door yet, and a line that could only ever say "your project doesn’t report
1545
+ // this" is worse than no line. The read-back lands with the enforcement.
1546
+ const load = fnHub('loadDoor');
1547
+ assert.doesNotMatch(load, /api\/gds\/instance/, 'this section asks the project’s manifest nothing');
1548
+ });
1549
+
1478
1550
  test('the joinability choices are the platform’s vocabulary, each in plain words, and the pick is a real radio group', () => {
1479
1551
  assert.match(SCRIPT, /var JOIN_OPTIONS = \{\s*open: \{ label: 'Anyone can join'/);
1480
1552
  assert.match(SCRIPT, /apply: \{ label: 'Apply to join'/);
@@ -1995,6 +2067,19 @@ test('the wizard takes the world by scope: no card ground, the six-step strip as
1995
2067
  assert.doesNotMatch(block, /#[0-9a-f]{3,8}\b/i, 'every colour is a token');
1996
2068
  });
1997
2069
 
2070
+ /* ── one fold for the whole wizard (task 1003538, a task 1003346 follow-up). The rail folded
2071
+ at the sheet's 900 while the wizard's fields, choice bands and review ledger folded at the
2072
+ page's 840 (cosmos's createSplit collapse), so between the two the rail was already three-up
2073
+ beside full-width bands. Both sheets now name the same width. ── */
2074
+ test('the wizard folds at one width: the rail\'s media rule sits at 840px with the bands, and no 900px rule is left in the #view-new block (task 1003538)', () => {
2075
+ const at = COSMOS.indexOf('THE CREATE WIZARD, IN THE WORLD');
2076
+ assert.ok(at > -1, 'the #view-new block exists in cosmos.css');
2077
+ const block = COSMOS.slice(at);
2078
+ assert.match(block, /@media \(max-width:840px\)\{\s*#view-new \.steps\{grid-template-columns:repeat\(3,1fr\);/, 'the rail folds to three columns at 840, the width the bands fold at');
2079
+ assert.doesNotMatch(block, /@media \(max-width:900px\)/, 'no 900px rule is left in the wizard block');
2080
+ assert.match(STYLE, /@media \(max-width:840px\)\{[\s\S]*?\.review\{grid-template-columns:1fr;/, 'the page\'s own wizard block still folds its bands and ledger at that same 840');
2081
+ });
2082
+
1998
2083
  test('the wizard\'s own structures wear the world: hairline fields, cells of one band, the review as a ledger, the human steps as a numbered ledger, the standup strip as the rail (task 1003346)', () => {
1999
2084
  const at = STYLE.indexOf('the create wizard (task 1002763');
2000
2085
  assert.ok(at > -1, 'the wizard block is still the page\'s own');
@@ -267,8 +267,11 @@ test('the manage sections keep their order, and no placeholder copy remains (tas
267
267
  const repo = HUB.indexOf('id="mRepoVisibility"');
268
268
  const vis = HUB.indexOf('id="mVisibility"');
269
269
  const join = HUB.indexOf('id="mJoinability"');
270
+ // the join door (task 1002324) sits with the other two doors, before delete
271
+ const door = HUB.indexOf('id="mDoorState"');
270
272
  const del = HUB.indexOf('id="mDelete"');
271
- assert.ok(repo > 0 && repo < vis && vis < join && join < del, 'repo visibility → who can see it → how people join → delete');
273
+ assert.ok(repo > 0 && repo < vis && vis < join && join < door && door < del,
274
+ 'repo visibility → who can see it → how people join → on the map → delete');
272
275
  // "who can see it" is live since task 1003043 — its not-yet line is gone
273
276
  const visCard = HUB.slice(vis, join);
274
277
  assert.doesNotMatch(visCard, /isn’t wired up|For now, every project’s pages are open to everyone/);
@@ -59,6 +59,7 @@ PGDATABASE=spike
59
59
  # 1003139). Written at standup from the platform's provisioning_instances.settings;
60
60
  ${NEUTRAL_PREFIX}_PLATFORM_VISIBILITY=public
61
61
  ${NEUTRAL_PREFIX}_JOINABILITY=apply
62
+ ${NEUTRAL_PREFIX}_VISIBILITY=public
62
63
  `;
63
64
 
64
65
  t('replaces an existing KEY= line where it stands; every other line survives byte-for-byte', () => {
@@ -111,7 +112,7 @@ t('is PURE: same input, same output; empty body yields just the lines', () => {
111
112
  t('settingsEnvVarsFor: the runner pushes the SAME pair webEnvBody writes at standup, under the pack prefix', () => {
112
113
  const inst = { slug: 'spike', port: 3010, db_name: 'spike', hosting_shape: 'standalone', target_ref: 'jaxri/spike', settings: { platform_visibility: 'gated' } };
113
114
  const vars = P.settingsEnvVarsFor(inst);
114
- assert.deepEqual(vars, { [`${NEUTRAL_PREFIX}_PLATFORM_VISIBILITY`]: 'gated', [`${NEUTRAL_PREFIX}_JOINABILITY`]: 'apply' });
115
+ assert.deepEqual(vars, { [`${NEUTRAL_PREFIX}_PLATFORM_VISIBILITY`]: 'gated', [`${NEUTRAL_PREFIX}_JOINABILITY`]: 'apply', [`${NEUTRAL_PREFIX}_VISIBILITY`]: 'public' });
115
116
  const body = P.webEnvBody(inst, {});
116
117
  for (const [k, v] of Object.entries(vars)) assert.match(body, new RegExp(`^${k}=${v}$`, 'm'), `${k} at standup`);
117
118
  });
@@ -122,10 +123,10 @@ const GATED = { settings: { platform_visibility: 'gated', joinability: 'invite_o
122
123
  const manifestWith = (project) => JSON.stringify({ schema: 'instance-manifest/v1', brand: project === undefined ? {} : { project } });
123
124
 
124
125
  t('true when every pushed key matches brand.project (camelCase, the ENV_OVERRIDES targets)', () => {
125
- assert.equal(P.settingsConsumed(manifestWith({ platformVisibility: 'gated', joinability: 'invite_only' }), GATED), true);
126
+ assert.equal(P.settingsConsumed(manifestWith({ platformVisibility: 'gated', joinability: 'invite_only', visibility: 'public' }), GATED), true);
126
127
  });
127
128
  t('false when the instance still reports a different value', () => {
128
- assert.equal(P.settingsConsumed(manifestWith({ platformVisibility: 'public', joinability: 'invite_only' }), GATED), false);
129
+ assert.equal(P.settingsConsumed(manifestWith({ platformVisibility: 'public', joinability: 'invite_only', visibility: 'public' }), GATED), false);
129
130
  assert.equal(P.settingsConsumed(manifestWith({ joinability: 'invite_only' }), GATED), false, 'a missing key is a mismatch, not a pass');
130
131
  });
131
132
  t('null (unknown) when the manifest carries no project block — a core that predates task 1003139 — or does not parse', () => {
@@ -143,8 +144,8 @@ t('instanceManifestCmd asks the instance on loopback, like healthzCmd', () => {
143
144
  function recorder({ apply = false, envBody = LIVE_ENV, readFails = false, healthzFails = false, privileged = false,
144
145
  manifest = 'match', restartThrows = false, writeThrows = false } = {}) {
145
146
  const cmds = [], writes = [], events = [], probes = [], cleared = [], logs = [];
146
- const manifestOut = manifest === 'match' ? manifestWith({ platformVisibility: 'gated', joinability: 'invite_only' })
147
- : manifest === 'mismatch' ? manifestWith({ platformVisibility: 'public', joinability: 'apply' })
147
+ const manifestOut = manifest === 'match' ? manifestWith({ platformVisibility: 'gated', joinability: 'invite_only', visibility: 'public' })
148
+ : manifest === 'mismatch' ? manifestWith({ platformVisibility: 'public', joinability: 'apply', visibility: 'public' })
148
149
  : manifest === 'none' ? manifestWith(undefined) : 'nope';
149
150
  const exec = (cmd, opts = {}) => {
150
151
  cmds.push(cmd);
@@ -212,7 +213,7 @@ await ta('dry-run PLANS the patch: reads nothing real, writes NOTHING (no synthe
212
213
  assert.equal(r.ok, true);
213
214
  assert.equal(r.healthy, null, 'nothing ran, nothing observed');
214
215
  assert.equal(writes.length, 0, 'a dry-run must not plan a two-line overwrite of web.env');
215
- assert.ok(logs.some((l) => /would patch .*_PLATFORM_VISIBILITY, .*_JOINABILITY in \/etc\/spike\/web\.env in place/.test(l)), logs.join('\n'));
216
+ assert.ok(logs.some((l) => /would patch .*_PLATFORM_VISIBILITY, .*_JOINABILITY, .*_VISIBILITY in \/etc\/spike\/web\.env in place/.test(l)), logs.join('\n'));
216
217
  assert.ok(cmds.some((c) => /restart spike/.test(c)), 'the restart is still in the plan');
217
218
  assert.ok(logs.some((l) => /dry-run — nothing ran/.test(l)), 'the closing line does not claim a restart happened');
218
219
  });
@@ -397,7 +398,7 @@ await ta("cmdRunIntents (--apply) routes a 'settings-apply' intent to the leg, w
397
398
  claimNextOAuthManifest: async () => null,
398
399
  resolveOAuthManifest: async () => {},
399
400
  },
400
- exec: (cmd) => { cmds.push(cmd); return /\bcat /.test(cmd) ? { ok: true, stdout: LIVE_ENV } : /instance/.test(cmd) ? { ok: true, stdout: manifestWith({ platformVisibility: 'gated', joinability: 'open' }) } : { ok: true, stdout: '{"ok":true}' }; },
401
+ exec: (cmd) => { cmds.push(cmd); return /\bcat /.test(cmd) ? { ok: true, stdout: LIVE_ENV } : /instance/.test(cmd) ? { ok: true, stdout: manifestWith({ platformVisibility: 'gated', joinability: 'open', visibility: 'public' }) } : { ok: true, stdout: '{"ok":true}' }; },
401
402
  writeFile: (p, body) => { writes.push(body); return { ok: true }; }, doApi: null,
402
403
  };
403
404
  const r = await P.cmdRunIntents(deps);
@@ -164,11 +164,14 @@ await ta('owner reads DEFAULTS (public / apply) on a row with empty settings —
164
164
  reset();
165
165
  const r = await get('/provisioning/instances/18/settings');
166
166
  assert.equal(r.status, 200, r.raw);
167
- assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply' });
167
+ assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public' });
168
168
  // the planet rides beside the settings (task 1003347): no default — null = the platform's own pick
169
169
  assert.deepEqual(r.body.planet, { planet_template: null, planet_accent: null, card_backdrop: null });
170
170
  assert.deepEqual(r.body.options, {
171
171
  platform_visibility: ['public', 'gated'], joinability: ['open', 'apply', 'invite_only'],
172
+ // the join door (task 1002324, ADR 0182 D1) — the third policy axis, offered
173
+ // to the form the same way as its two neighbours
174
+ visibility: ['public', 'private', 'stealth'],
172
175
  planet_template: [...provisioning.PLANET_TEMPLATES], planet_accent: ['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent'],
173
176
  // the card's backdrop (task 1003352) is the same kind of key: platform-drawn,
174
177
  // closed list, no default, no push — so it rides the same vocab
@@ -181,14 +184,14 @@ await ta('a stored planet reads back; a name that left the vocabulary reads null
181
184
  const r = await get('/provisioning/instances/18/settings');
182
185
  assert.equal(r.status, 200, r.raw);
183
186
  assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: null, card_backdrop: null });
184
- assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply' }, 'the planet is not a setting: the policy read is untouched');
187
+ assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public' }, 'the planet is not a setting: the policy read is untouched');
185
188
  });
186
189
 
187
190
  await ta('stored keys overlay the defaults', async () => {
188
191
  reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', settings: { joinability: 'invite_only' } } });
189
192
  const r = await get('/provisioning/instances/18/settings');
190
193
  assert.equal(r.status, 200, r.raw);
191
- assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'invite_only' });
194
+ assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'invite_only', visibility: 'public' });
192
195
  });
193
196
 
194
197
  await ta('ANOTHER builder\'s GET → 404 (no existence leak)', async () => {
@@ -216,7 +219,7 @@ await ta('owner changes one key — merged, returned effective, audited api:self
216
219
  reset();
217
220
  const r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
218
221
  assert.equal(r.status, 200, r.raw);
219
- assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open' }, 'the SAVED value — the response never claims the instance runs it (task 1003140)');
222
+ assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public' }, 'the SAVED value — the response never claims the instance runs it (task 1003140)');
220
223
  assert.equal(r.body.push.queued, true, 'an active co-tenant gets the live push (task 1003140)');
221
224
  assert.deepEqual(updateCalls, [{ id: 18, patch: { joinability: 'open' } }], 'exactly the sent key, nothing else');
222
225
  assert.equal(events.length, 1);
@@ -229,7 +232,7 @@ await ta('owner changes both keys in one call', async () => {
229
232
  reset();
230
233
  const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated', joinability: 'invite_only' });
231
234
  assert.equal(r.status, 200, r.raw);
232
- assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'invite_only' });
235
+ assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'invite_only', visibility: 'public' });
233
236
  });
234
237
 
235
238
  await ta('the OWN-SCOPE WALL: another builder\'s PATCH → 404 and the write path is never reached', async () => {
@@ -257,6 +260,62 @@ await ta('an off-vocabulary value → 400 validation_failed (nothing written)',
257
260
  assert.equal(updateCalls.length, 0);
258
261
  });
259
262
 
263
+ // ── the join door (BV1.R05, task 1002324) ────────────────────────────────────
264
+ // ADR 0182 D1's STORED axis — Public / Private / Stealth — reaching the owner on
265
+ // the same own-scoped channel as its two neighbours. What these pin: the server,
266
+ // not the form, decides a value is legal; a fourth state cannot be picked because
267
+ // dark matter is DERIVED (D2); and the door is a POLICY, so it rides the push.
268
+ console.log('\nPATCH /provisioning/instances/:id/settings — the join door (task 1002324):');
269
+
270
+ await ta('the owner sets the door to stealth — stored, echoed effective, audited, and pushed like the policy it is', async () => {
271
+ reset();
272
+ const r = await patch('/provisioning/instances/18/settings', { visibility: 'stealth' });
273
+ assert.equal(r.status, 200, r.raw);
274
+ assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'stealth' });
275
+ assert.deepEqual(updateCalls, [{ id: 18, patch: { visibility: 'stealth' } }], 'exactly the sent key, nothing else');
276
+ assert.equal(events[0].actor, 'api:self');
277
+ assert.match(events[0].detail, /visibility=stealth/);
278
+ // ADR 0182 D6 makes the door the CEILING joinability may only narrow, and D1/D5
279
+ // put that authority on the instance — so it must ride web.env + the restart,
280
+ // not the planet's draw-only channel.
281
+ assert.equal(r.body.push.queued, true, 'the door is a policy the instance obeys — it pushes');
282
+ assert.equal(r.body.push.restart, true, 'and a restart is what applies it');
283
+ });
284
+
285
+ await ta('each of the three states is accepted', async () => {
286
+ for (const value of ['public', 'private', 'stealth']) {
287
+ reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'co-tenant', settings: { visibility: value === 'public' ? 'stealth' : 'public' } } });
288
+ const r = await patch('/provisioning/instances/18/settings', { visibility: value });
289
+ assert.equal(r.status, 200, `${value}: ${r.raw}`);
290
+ assert.equal(r.body.saved.visibility, value);
291
+ }
292
+ });
293
+
294
+ await ta('a fourth state is refused server-side — dark matter is DERIVED, never picked (ADR 0182 D2)', async () => {
295
+ for (const bogus of ['unlisted', 'dark-matter', 'gated', 'hidden', '']) {
296
+ reset();
297
+ const r = await patch('/provisioning/instances/18/settings', { visibility: bogus });
298
+ assert.equal(r.status, 400, `${JSON.stringify(bogus)} must be refused: ${r.raw}`);
299
+ assert.equal(r.body.error.code, 'validation_failed');
300
+ assert.equal(updateCalls.length, 0, 'nothing written on a refused value');
301
+ }
302
+ });
303
+
304
+ await ta('the OWN-SCOPE WALL holds for the door: another builder\'s PATCH → 404, nothing written', async () => {
305
+ reset({ as: { id: 8, github_login: 'stranger', rank: 'thetes' } });
306
+ const r = await patch('/provisioning/instances/18/settings', { visibility: 'private' });
307
+ assert.equal(r.status, 404, r.raw);
308
+ assert.equal(r.body.error.code, 'instance_not_found', 'no existence leak');
309
+ assert.equal(updateCalls.length, 0);
310
+ assert.equal(events.length, 0);
311
+ });
312
+
313
+ await ta('an unknown project → 404 for the door too, archon included', async () => {
314
+ reset({ inst: null, as: { id: 999, github_login: 'archon', rank: 'archon' } });
315
+ const r = await patch('/provisioning/instances/999/settings', { visibility: 'private' });
316
+ assert.equal(r.status, 404, r.raw);
317
+ });
318
+
260
319
  await ta('an unknown key → 400 (strict: a typo fails loudly, never silently dropped)', async () => {
261
320
  reset();
262
321
  const r = await patch('/provisioning/instances/18/settings', { joinabilty: 'open' });
@@ -272,7 +331,7 @@ await ta('the owner picks a planet: saved, echoed as `planet`, audited — and N
272
331
  const r = await patch('/provisioning/instances/18/settings', { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' });
273
332
  assert.equal(r.status, 200, r.raw);
274
333
  assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: 'sea', card_backdrop: null });
275
- assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply' }, 'the policy half is untouched');
334
+ assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'public' }, 'the policy half is untouched');
276
335
  assert.deepEqual(updateCalls, [{ id: 18, patch: { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' } }]);
277
336
  assert.equal(r.body.push.queued, false);
278
337
  assert.equal(r.body.push.restart, false, 'an active co-tenant would get a push for a POLICY change — never for the planet');
@@ -212,7 +212,7 @@ await ta('ACTIVE standalone: the row is written FIRST, then a settings-apply int
212
212
  reset();
213
213
  const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated' });
214
214
  assert.equal(r.status, 200, r.raw);
215
- assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'apply' }, 'the SAVED (requested) value — never `settings`, which a form would render as live');
215
+ assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'apply', visibility: 'public' }, 'the SAVED (requested) value — never `settings`, which a form would render as live');
216
216
  assert.equal(r.body.settings, undefined);
217
217
  assert.deepEqual(updateCalls.map((c) => c.patch), [{ platform_visibility: 'gated' }]);
218
218
  assert.equal(updateCalls[0].enqueuedSoFar, 0, 'the runner reads the row — the write must precede the intent');
@@ -261,7 +261,7 @@ await ta('an enqueue that THROWS still answers 200 "saved" — never a 500 that
261
261
  enqueueThrows = new Error('db down');
262
262
  const r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
263
263
  assert.equal(r.status, 200, r.raw);
264
- assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open' });
264
+ assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public' });
265
265
  assert.equal(updateCalls.length, 1, 'the write landed');
266
266
  receiptShape(r.body.push, { queued: false, restart: true, reachable: true });
267
267
  assert.match(r.body.push.message, /Saved, but the restart could not be queued/);
@@ -274,7 +274,7 @@ await ta('same values as already stored → no write, no event, no intent, "No c
274
274
  reset({ inst: { ...ACTIVE, settings: { platform_visibility: 'gated', joinability: 'open' } } });
275
275
  const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated', joinability: 'open' });
276
276
  assert.equal(r.status, 200, r.raw);
277
- assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'open' });
277
+ assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'open', visibility: 'public' });
278
278
  assert.equal(updateCalls.length, 0, 'nothing written (no updated_at reshuffle)');
279
279
  assert.equal(events.length, 0, 'no "settings changed" event for a change that is not one');
280
280
  assert.equal(enqueueCalls.length, 0, 'a whole-form save must not bounce a live project');