@bongos/core 1.25.19 → 1.25.20
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 +108 -78
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +36 -2
- package/docs/api-reference.md +5 -4
- package/docs/copy-inventory.md +217 -215
- package/docs/copy-registry.json +256 -238
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +348 -347
- package/docs/project-context.template.md +1 -1
- package/modules/front-door-guide/handoff.js +120 -0
- package/modules/front-door-guide/module.json +1 -1
- package/modules/front-door-guide/routes/front-door-guide-plan.js +24 -0
- package/modules/hall-ui/public/ai-studio.html +1 -1
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/city.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/commands.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/genesis-home.js +23 -4
- package/modules/hall-ui/public/goal-map.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/home-city-draw.js +14 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/market-watch-item.html +1 -1
- package/modules/hall-ui/public/market-watch-rules.html +1 -1
- package/modules/hall-ui/public/market-watch.html +1 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/money.html +1 -1
- package/modules/hall-ui/public/my-agents.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/hall-ui/public/shell.js +32 -20
- package/modules/hall-ui/public/street.html +1 -1
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/style.css +9 -4
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor.html +1 -1
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/lifecycle/home-city.js +17 -3
- package/modules/onboarding/founding-mode.js +45 -1
- package/modules/onboarding/genesis-home.js +2 -0
- package/modules/onboarding/guide-handoff.js +109 -0
- package/modules/onboarding/routes/genesis-work.js +9 -5
- package/modules/provisioning/guide-handoff.js +65 -0
- package/modules/provisioning/provisioning.js +2 -2
- package/modules/provisioning/routes/provisioning.js +4 -2
- package/modules/public-landing/public/projects.html +79 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -0
- package/src/bongos/founding-page-gate.js +67 -0
- package/src/bongos/serve-internal.js +3 -0
- package/src/branding.js +4 -0
- package/src/module-api.js +1 -1
- package/tests/founding_mode.mjs +84 -8
- package/tests/founding_page_gate.mjs +105 -0
- package/tests/front_door_guide_existing.mjs +1 -0
- package/tests/genesis_home.mjs +24 -0
- package/tests/guide_handoff.mjs +322 -0
- package/tests/home_city.mjs +52 -0
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/provision_settings_apply.mjs +2 -1
- package/tests/provisioning_settings_env.mjs +2 -2
|
@@ -1579,6 +1579,8 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1579
1579
|
<h2 id="p1-h"><span class="n">01</span>Your project</h2>
|
|
1580
1580
|
<!-- auto-fill: the answers this account gave on its most recent project -->
|
|
1581
1581
|
<p class="fillOffer" id="physFillOffer" hidden><button type="button" class="linklike" id="physFromAccount">Fill these in from my last project</button></p>
|
|
1582
|
+
<!-- the guide's answers (task 1005274): shown only when this page filled itself in from the plan -->
|
|
1583
|
+
<p class="fillOffer hint" id="guideFill" hidden>Filled in from your talk with the guide. Change anything you like. <a id="guideFileLink" href="#" download="PROJECT-HANDOFF.md">Download the plan for your AI</a></p>
|
|
1582
1584
|
<!-- the demo route (task 1004420, BV2.PS09): opened in place at the top of part
|
|
1583
1585
|
01 by the small link under the heading; the rest of the page is unchanged
|
|
1584
1586
|
and marked "demo" (D9). The fit check is a copy of modules/provisioning/demo.js
|
|
@@ -5213,6 +5215,77 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
5213
5215
|
});
|
|
5214
5216
|
paintTeam(state.teamShape || null);
|
|
5215
5217
|
|
|
5218
|
+
/* ── the guide's answers (task 1005274) ──
|
|
5219
|
+
A founder who talked with the start-a-project guide (modules/front-door-guide) is
|
|
5220
|
+
never asked the same thing twice. This browser holds their chat in localStorage
|
|
5221
|
+
(the landing's guide-scene.js writes it); once they are signed in, the chat moves
|
|
5222
|
+
onto their account (POST …/attach) and its plan fills in what it covers, and ONLY
|
|
5223
|
+
what is still unanswered: the kind, the sentence, the extras. The name stays the
|
|
5224
|
+
founder's (the plan has none). Every field stays the ordinary control it always
|
|
5225
|
+
was, so each answer is editable. The create then says from_guide, and the hub
|
|
5226
|
+
carries the plan's scope answers and the hand-off file into the new project
|
|
5227
|
+
(modules/provisioning/guide-handoff.js). No chat in this browser: nothing is read
|
|
5228
|
+
and nothing changes. */
|
|
5229
|
+
var GUIDE_KEY = 'cb-front-door-guide';
|
|
5230
|
+
var guideAsked = false;
|
|
5231
|
+
function guideChat() {
|
|
5232
|
+
try {
|
|
5233
|
+
if (!(window.__MODULES__ && window.__MODULES__.enabled && window.__MODULES__.enabled['front-door-guide'])) return null;
|
|
5234
|
+
var g = JSON.parse(localStorage.getItem(GUIDE_KEY) || 'null');
|
|
5235
|
+
return g && g.id && g.token ? g : null;
|
|
5236
|
+
} catch (e) { return null; }
|
|
5237
|
+
}
|
|
5238
|
+
/* applyGuidePlan(plan) — fills the unanswered fields from a plan; returns what it filled. */
|
|
5239
|
+
function applyGuidePlan(plan) {
|
|
5240
|
+
var filled = [];
|
|
5241
|
+
if (!plan || typeof plan !== 'object') return filled;
|
|
5242
|
+
if (!state.projType && plan.project_type && root.querySelector('#typeCards .tpill[data-type="' + plan.project_type + '"]')) {
|
|
5243
|
+
state.projType = plan.project_type; filled.push('type');
|
|
5244
|
+
}
|
|
5245
|
+
if (!String(state.description || '').trim() && typeof plan.summary === 'string' && plan.summary.trim()) {
|
|
5246
|
+
state.description = plan.summary.trim().slice(0, 600); filled.push('description');
|
|
5247
|
+
}
|
|
5248
|
+
if (state.modules === null && Array.isArray(plan.modules) && plan.modules.length) {
|
|
5249
|
+
state.modules = plan.modules.map(function (m) { return m && m.key; }).filter(function (k) { return typeof k === 'string' && k; });
|
|
5250
|
+
filled.push('modules');
|
|
5251
|
+
}
|
|
5252
|
+
return filled;
|
|
5253
|
+
}
|
|
5254
|
+
function paintGuideFill() {
|
|
5255
|
+
var note = $('guideFill');
|
|
5256
|
+
if (!note) return;
|
|
5257
|
+
note.hidden = !state.fromGuide;
|
|
5258
|
+
$('guideFileLink').href = API + '/front-door-guide/mine/handoff.md?name=' + encodeURIComponent(String(state.name || '').trim());
|
|
5259
|
+
}
|
|
5260
|
+
function guidePrefill() {
|
|
5261
|
+
if (guideAsked || !me) return;
|
|
5262
|
+
var g = guideChat();
|
|
5263
|
+
if (!g) return;
|
|
5264
|
+
guideAsked = true;
|
|
5265
|
+
fetch(API + '/front-door-guide/conversations/' + encodeURIComponent(g.id) + '/attach', {
|
|
5266
|
+
method: 'POST', credentials: 'same-origin',
|
|
5267
|
+
headers: { 'Content-Type': 'application/json' },
|
|
5268
|
+
body: JSON.stringify({ token: g.token })
|
|
5269
|
+
}).catch(function () { return null; }).then(function () {
|
|
5270
|
+
return fetch(API + '/front-door-guide/mine', { credentials: 'same-origin' });
|
|
5271
|
+
}).then(function (res) {
|
|
5272
|
+
return res && res.ok ? res.json() : null;
|
|
5273
|
+
}).then(function (data) {
|
|
5274
|
+
var plan = data && data.conversation && data.conversation.plan;
|
|
5275
|
+
if (!plan) return;
|
|
5276
|
+
applyGuidePlan(plan);
|
|
5277
|
+
state.fromGuide = true;
|
|
5278
|
+
saveDraft();
|
|
5279
|
+
paintType(state.projType || null);
|
|
5280
|
+
$('f-desc').value = state.description || '';
|
|
5281
|
+
renderModules();
|
|
5282
|
+
paintSetup();
|
|
5283
|
+
paintCreate();
|
|
5284
|
+
paintGuideFill();
|
|
5285
|
+
}).catch(function () { /* the guide is a convenience: the page works without it */ });
|
|
5286
|
+
}
|
|
5287
|
+
paintGuideFill();
|
|
5288
|
+
|
|
5216
5289
|
/* ── the module picker (task 1002339, BV1.R20) ──
|
|
5217
5290
|
ONE public read gives the panel both halves it needs: `optional` is the
|
|
5218
5291
|
catalog it renders, `bundles` is what each project type preselects
|
|
@@ -6933,6 +7006,9 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
6933
7006
|
has commits rejects as unrelated history. state.mode already tracked
|
|
6934
7007
|
greenfield|adopt for the UI — it has to reach the server too. */
|
|
6935
7008
|
onboard_mode: state.mode === 'adopt' ? 'adopt' : 'greenfield',
|
|
7009
|
+
/* the page filled itself in from the guide's plan (task 1005274): the hub
|
|
7010
|
+
carries the scope answers and the hand-off file into the new project */
|
|
7011
|
+
from_guide: state.fromGuide ? true : undefined,
|
|
6936
7012
|
/* the ship row (task 1004629): becomes the new project's own ship mode */
|
|
6937
7013
|
ship_mode: shipModeChoice()
|
|
6938
7014
|
})
|
|
@@ -6963,6 +7039,8 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
6963
7039
|
demo_invites: state.demo && state.demo.invites && state.demo.invites.length ? state.demo.invites.slice() : undefined
|
|
6964
7040
|
}));
|
|
6965
7041
|
sessionStorage.removeItem(DRAFT_KEY);
|
|
7042
|
+
/* the guide's chat has done its job: a second project must not fill in from it */
|
|
7043
|
+
if (state.fromGuide) localStorage.removeItem(GUIDE_KEY);
|
|
6966
7044
|
} catch (e) {}
|
|
6967
7045
|
createBusy = false;
|
|
6968
7046
|
renderDone();
|
|
@@ -8568,6 +8646,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
8568
8646
|
$('f-owner').value = state.owner;
|
|
8569
8647
|
saveDraft();
|
|
8570
8648
|
}
|
|
8649
|
+
guidePrefill();
|
|
8571
8650
|
if (currentView === 'new') enter(false);
|
|
8572
8651
|
}
|
|
8573
8652
|
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.25.
|
|
3
|
+
"version": "1.25.20",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.25.
|
|
9
|
+
"version": "1.25.20",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.25.
|
|
3
|
+
"version": "1.25.20",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -11858,5 +11858,15 @@
|
|
|
11858
11858
|
"id": "1005115",
|
|
11859
11859
|
"text": "A solo founder's Genesis now moves to the next stage by itself when a stage's work is finished, and every stage leaves a named document (the scope, the strategy...) they can come back and edit any time."
|
|
11860
11860
|
}
|
|
11861
|
+
],
|
|
11862
|
+
"1.25.20": [
|
|
11863
|
+
{
|
|
11864
|
+
"id": "1005112",
|
|
11865
|
+
"text": "While a new project is being set up, the menu now shows only the rooms that are open, a room that just opened is highlighted, a closed room can't be reached by its address, and the later setup stages say what finishing them ea"
|
|
11866
|
+
},
|
|
11867
|
+
{
|
|
11868
|
+
"id": "1005274",
|
|
11869
|
+
"text": "A founder who planned their project with the guide now finds the project form and the first Genesis question already filled in, plus a plan file for their own AI."
|
|
11870
|
+
}
|
|
11861
11871
|
]
|
|
11862
11872
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/bongos/founding-page-gate.js — A SLEEPING ROOM IS NOT REACHABLE BY URL (task 1005112).
|
|
4
|
+
//
|
|
5
|
+
// While a project is being founded, the hall's sidebar holds only the rooms its genesis
|
|
6
|
+
// stages have opened. Hiding a room and refusing its page are ONE change: a room nobody
|
|
7
|
+
// can see but anyone can type the address of would be a wall the product only claims to
|
|
8
|
+
// have. So this is the page half. It asks the server's own answer, the onboarding port's
|
|
9
|
+
// foundingModeFor() (the same read /me carries as founding_mode.asleep, keyed by the
|
|
10
|
+
// sidebar's room ids), and never a browser's opinion (ADR 0016).
|
|
11
|
+
//
|
|
12
|
+
// A sleeping room's page answers with a redirect home carrying ?asleep=<room>&opens=<stage>,
|
|
13
|
+
// where the shell says what the room is and which stage opens it, instead of a dead end.
|
|
14
|
+
//
|
|
15
|
+
// What it never touches: a project that is not founding (foundingModeFor answers null,
|
|
16
|
+
// and the page is served exactly as before), a room that is awake, the Primer and the
|
|
17
|
+
// Board Room (never asleep), and every page that is not one of the rooms below. An
|
|
18
|
+
// unreadable founding state is null too, so a failure opens the page, never shuts it:
|
|
19
|
+
// founding-mode.js fails closed to OFF for the same reason (a hall wrongly asleep would
|
|
20
|
+
// hijack it). Rooms are keyed by the sidebar id, and each room's page slug IS that id.
|
|
21
|
+
|
|
22
|
+
const path = require('path');
|
|
23
|
+
const seams = require('../module-seams');
|
|
24
|
+
|
|
25
|
+
// The rooms a genesis stage can put to sleep (founding-mode.js WAKES + WAKES_WHEN_ALIVE),
|
|
26
|
+
// by page slug. tests/founding_page_gate.mjs holds this list to that map.
|
|
27
|
+
const ROOM_PAGE_RE = /^\/builders\/(work|goals|goal-map|collab|roster)(?:\.html)?\/?$/;
|
|
28
|
+
|
|
29
|
+
// asleepRoomFor(room, founding) — PURE. The room id when that room sleeps under this
|
|
30
|
+
// founding answer, else null.
|
|
31
|
+
function asleepRoomFor(room, founding) {
|
|
32
|
+
if (!founding || founding.active !== true || !founding.asleep || typeof founding.asleep !== 'object') return null;
|
|
33
|
+
return Object.prototype.hasOwnProperty.call(founding.asleep, room) ? room : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// readFounding() — the server's answer, or null on any failure (fail open: OFF).
|
|
37
|
+
async function readFounding(resolve = seams.resolveOptional) {
|
|
38
|
+
try {
|
|
39
|
+
const onboarding = resolve('onboarding');
|
|
40
|
+
if (!onboarding || typeof onboarding.foundingModeFor !== 'function') return null;
|
|
41
|
+
return await onboarding.foundingModeFor();
|
|
42
|
+
} catch (_) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// foundingPageGate({ skip, isBuildersHost, read }) — the Express middleware. `skip(req)`
|
|
48
|
+
// is the caller's module-surface test; `read` is injectable for tests. The founding read
|
|
49
|
+
// is made only for one of the five room pages, so every other request pays nothing.
|
|
50
|
+
function foundingPageGate({ skip = () => false, isBuildersHost = () => false, read = readFounding } = {}) {
|
|
51
|
+
return async function gate(req, res, next) {
|
|
52
|
+
if (skip(req) || !String(req.path || '').startsWith('/builders')) return next();
|
|
53
|
+
const m = ROOM_PAGE_RE.exec(path.posix.normalize(req.path));
|
|
54
|
+
if (!m) return next();
|
|
55
|
+
let founding = null;
|
|
56
|
+
try { founding = await read(); } catch (_) { founding = null; } // fail open: OFF
|
|
57
|
+
const room = asleepRoomFor(m[1], founding);
|
|
58
|
+
if (!room) return next();
|
|
59
|
+
const opens = String(founding.asleep[room] || '');
|
|
60
|
+
res.set('Cache-Control', 'no-store');
|
|
61
|
+
return res.redirect(302, `${isBuildersHost(req) ? '' : '/builders'}/?asleep=${encodeURIComponent(room)}`
|
|
62
|
+
+ (/^[a-z]{1,20}$/.test(opens) ? `&opens=${opens}` : ''));
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// One export: the pure parts are proven through it (tests/founding_page_gate.mjs).
|
|
67
|
+
module.exports = { foundingPageGate };
|
|
@@ -50,6 +50,7 @@ const { linkPreviewTags } = require('../link-preview');
|
|
|
50
50
|
const { prelaunchGate } = require('./prelaunch-gate');
|
|
51
51
|
const { platformVisibilityGate } = require('./platform-visibility-gate');
|
|
52
52
|
const { schemaPending } = require('./schema-lag');
|
|
53
|
+
const { foundingPageGate } = require('./founding-page-gate');
|
|
53
54
|
const { clientModules, isModuleEnabled } = require('../modules');
|
|
54
55
|
const { hallWidgetScripts, moduleWebSurfaces } = require('../module-loader/loader');
|
|
55
56
|
const { resolveCoreRoot, resolveDocsRoot, resolveInstanceRoot, resolveEnv } = require('../instance-config');
|
|
@@ -1353,6 +1354,8 @@ function mountInternalSurfaces(app) {
|
|
|
1353
1354
|
}
|
|
1354
1355
|
next();
|
|
1355
1356
|
});
|
|
1357
|
+
// task 1005112: a founding project's sleeping room is refused by URL, after sign-in.
|
|
1358
|
+
app.use(foundingPageGate({ skip: isModuleSurfaceHost, isBuildersHost }));
|
|
1356
1359
|
|
|
1357
1360
|
// task 1965: the read-only Task Board (/board, task 751) was consolidated into
|
|
1358
1361
|
// the Work page (/work) — the hall keeps one task-listing surface. 301 old
|
package/src/branding.js
CHANGED
|
@@ -112,6 +112,10 @@ const ENV_OVERRIDES = [
|
|
|
112
112
|
// The ship mode the founder picked in the wizard (task 1004629), empty when none was.
|
|
113
113
|
// resolveShipMode reads it only while project_settings['ship.mode'] is unset.
|
|
114
114
|
['SHIP_MODE', ['founding', 'shipMode']],
|
|
115
|
+
// The start-a-project guide's hand-off (task 1005274, modules/provisioning/guide-handoff.js): base64url
|
|
116
|
+
// JSON { scope, markdown }, empty when the founder never used the guide. modules/onboarding/guide-handoff.js
|
|
117
|
+
// writes it once: the scope answers as carried over, and the hand-off file. SERVER-ONLY, like the birth.
|
|
118
|
+
['GUIDE_HANDOFF', ['founding', 'guideHandoff']],
|
|
115
119
|
];
|
|
116
120
|
|
|
117
121
|
const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v);
|
package/src/module-api.js
CHANGED
|
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
|
|
|
81
81
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
82
82
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
83
83
|
|
|
84
|
-
const CORE_VERSION = '1.25.
|
|
84
|
+
const CORE_VERSION = '1.25.20'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
85
85
|
|
|
86
86
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
87
87
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
package/tests/founding_mode.mjs
CHANGED
|
@@ -143,7 +143,8 @@ function makeHall() {
|
|
|
143
143
|
documentElement: root,
|
|
144
144
|
createElement: (t) => new El(t),
|
|
145
145
|
querySelector: (sel) => (sel === '.app-body' ? appBody : null),
|
|
146
|
-
querySelectorAll: (sel) => (sel === '#app-sidebar .nav-item--asleep
|
|
146
|
+
querySelectorAll: (sel) => (sel === '#app-sidebar .nav-item--asleep, #app-sidebar .nav-item--arrived'
|
|
147
|
+
? nav.filter((a) => a.classList.contains('nav-item--asleep') || a.classList.contains('nav-item--arrived')) : []),
|
|
147
148
|
getElementById: (id) => nav.find((a) => a.id === id) || appBody.children.find((c) => c.id === id) || null,
|
|
148
149
|
};
|
|
149
150
|
return { document, nav, appBody, root };
|
|
@@ -164,12 +165,12 @@ function sliceFn(name) {
|
|
|
164
165
|
assert.ok(start > -1 && end > start, name + ' was located in shell.js');
|
|
165
166
|
return SHELL.slice(start, end + 4);
|
|
166
167
|
}
|
|
167
|
-
function loadApplyFounding(document, access, brand = { identity: { productName: 'Kestrel' } }) {
|
|
168
|
+
function loadApplyFounding(document, access, brand = { identity: { productName: 'Kestrel' } }, storage = null) {
|
|
168
169
|
const wakeStart = SHELL.indexOf(' var WAKE_TEXT = {');
|
|
169
170
|
const wakeEnd = SHELL.indexOf('};', wakeStart) + 2;
|
|
170
171
|
assert.ok(wakeStart > -1, 'WAKE_TEXT was located in shell.js');
|
|
171
172
|
const src = [SHELL.slice(wakeStart, wakeEnd), sliceEsc(), sliceFn('applyFounding')].join('\n');
|
|
172
|
-
return new Function('document', 'brand', 'access', 'window', `${src}\nreturn applyFounding;`)(document, brand, access, { OTB: realOTB() });
|
|
173
|
+
return new Function('document', 'brand', 'access', 'window', `${src}\nreturn applyFounding;`)(document, brand, access, { OTB: realOTB(), localStorage: storage || undefined });
|
|
173
174
|
}
|
|
174
175
|
|
|
175
176
|
const snapshot = (h) => JSON.stringify({
|
|
@@ -213,8 +214,8 @@ test('founding ON: the band above the topbar, and the rooms the server named asl
|
|
|
213
214
|
const byId = (id) => h.nav.find((a) => a.id === 'nav-' + id);
|
|
214
215
|
assert.ok(!byId('goals').classList.contains('nav-item--asleep'), 'scope is behind: goals is awake');
|
|
215
216
|
assert.ok(byId('work').classList.contains('nav-item--asleep'), 'strategize is current: tasks still sleeps');
|
|
216
|
-
assert.equal(byId('work').children
|
|
217
|
-
assert.
|
|
217
|
+
assert.equal(byId('work').children.length, 0, 'a hidden room carries no label nobody can see (task 1005112)');
|
|
218
|
+
assert.ok(byId('collab').classList.contains('nav-item--asleep'), 'community sleeps until alive');
|
|
218
219
|
assert.ok(!byId('home').classList.contains('nav-item--asleep'), 'home is never put to sleep');
|
|
219
220
|
});
|
|
220
221
|
|
|
@@ -227,7 +228,7 @@ test('founding OFF after ON restores the hall exactly — OFF removes, it does n
|
|
|
227
228
|
assert.notEqual(snapshot(h), before);
|
|
228
229
|
access.founding = null;
|
|
229
230
|
apply();
|
|
230
|
-
assert.equal(snapshot(h), before, 'band, asleep classes
|
|
231
|
+
assert.equal(snapshot(h), before, 'band, asleep classes and the root attribute are all gone');
|
|
231
232
|
});
|
|
232
233
|
|
|
233
234
|
test('the band escapes what it prints — a project name cannot inject markup', () => {
|
|
@@ -280,13 +281,13 @@ test('a failing waiting-on source never turns the mode off', async () => {
|
|
|
280
281
|
assert.equal(f.waiting_on, null);
|
|
281
282
|
});
|
|
282
283
|
|
|
283
|
-
test('re-applying ON does not stack a second band or
|
|
284
|
+
test('re-applying ON does not stack a second band or add anything to a room', () => {
|
|
284
285
|
const h = makeHall();
|
|
285
286
|
const access = { founding: fm.shapeFoundingMode({ active: 'true', stage: 'decide' }) };
|
|
286
287
|
const apply = loadApplyFounding(h.document, access);
|
|
287
288
|
apply(); apply();
|
|
288
289
|
assert.equal(h.appBody.children.filter((c) => c.id === 'founding-band').length, 1);
|
|
289
|
-
assert.equal(h.nav.find((a) => a.id === 'nav-work').children.length,
|
|
290
|
+
assert.equal(h.nav.find((a) => a.id === 'nav-work').children.length, 0);
|
|
290
291
|
});
|
|
291
292
|
|
|
292
293
|
// ── the wiring ──────────────────────────────────────────────────────────────
|
|
@@ -317,3 +318,78 @@ test('founding mode adds no page, no route and no nav item — it is a mode', ()
|
|
|
317
318
|
const hall = fs.readdirSync(path.join(ROOT, 'modules', 'hall-ui', 'public'));
|
|
318
319
|
assert.ok(!hall.some((f) => /^founding\./.test(f)), 'no founding page file');
|
|
319
320
|
});
|
|
321
|
+
|
|
322
|
+
// ── task 1005112: hidden, not dimmed; the arrival; the honest refusal ─────────
|
|
323
|
+
|
|
324
|
+
test('a sleeping room is hidden by the sheet, and a group of sleeping rooms hides its head', () => {
|
|
325
|
+
const css = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'style.css'), 'utf8');
|
|
326
|
+
assert.match(css, /\.nav-item--asleep \{ display: none; \}/, 'asleep is not drawn at all, never dimmed');
|
|
327
|
+
assert.doesNotMatch(css, /\.nav-item--asleep \{ opacity/);
|
|
328
|
+
assert.match(css, /html\[data-founding="on"\] \.nav-group:not\(:has\(\.nav-item:not\(\.nav-item--asleep\)/);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test('a room that woke since this browser last looked arrives marked, once (task 1005112)', () => {
|
|
332
|
+
const store = new Map();
|
|
333
|
+
const storage = { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)) };
|
|
334
|
+
const at = (stage) => ({ founding: fm.shapeFoundingMode({ active: 'true', stage }) });
|
|
335
|
+
const h1 = makeHall();
|
|
336
|
+
loadApplyFounding(h1.document, at('scope'), undefined, storage)();
|
|
337
|
+
assert.ok(!h1.nav.some((a) => a.classList.contains('nav-item--arrived')), 'the first look marks nothing');
|
|
338
|
+
const h2 = makeHall();
|
|
339
|
+
loadApplyFounding(h2.document, at('strategize'), undefined, storage)();
|
|
340
|
+
const arrived = h2.nav.filter((a) => a.classList.contains('nav-item--arrived')).map((a) => a.id);
|
|
341
|
+
assert.deepEqual(arrived.sort(), ['nav-goal-map', 'nav-goals'], 'scope finished: Goals and Sky arrive');
|
|
342
|
+
const h3 = makeHall();
|
|
343
|
+
loadApplyFounding(h3.document, at('strategize'), undefined, storage)();
|
|
344
|
+
assert.ok(!h3.nav.some((a) => a.classList.contains('nav-item--arrived')), 'the next page load: no mark again');
|
|
345
|
+
// Switching off removes the mark with everything else.
|
|
346
|
+
const access = at('charter');
|
|
347
|
+
const h4 = makeHall();
|
|
348
|
+
const before = snapshot(h4);
|
|
349
|
+
const apply = loadApplyFounding(h4.document, access, undefined, storage);
|
|
350
|
+
apply();
|
|
351
|
+
access.founding = null;
|
|
352
|
+
apply();
|
|
353
|
+
assert.equal(snapshot(h4), before);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test('the shell says, on Home, what a refused room is and which stage opens it (task 1005112)', () => {
|
|
357
|
+
const navStart = SHELL.indexOf(' var NAV = [');
|
|
358
|
+
const navEnd = SHELL.indexOf(' var NAV_COLLAPSE_KEY');
|
|
359
|
+
const wakeStart = SHELL.indexOf(' var WAKE_TEXT = {');
|
|
360
|
+
const wakeEnd = SHELL.indexOf('};', wakeStart) + 2;
|
|
361
|
+
const src = [SHELL.slice(navStart, navEnd), SHELL.slice(wakeStart, wakeEnd), sliceEsc(),
|
|
362
|
+
sliceFn('navItemById'), sliceFn('renderDeniedNotice')].join('\n');
|
|
363
|
+
let inserted = null;
|
|
364
|
+
let replaced = null;
|
|
365
|
+
const el = { querySelector: () => ({ addEventListener() {} }), setAttribute() {} };
|
|
366
|
+
const document = {
|
|
367
|
+
getElementById: (id) => (id === 'app-topbar' ? { insertAdjacentElement: (_, n) => { inserted = n; } } : null),
|
|
368
|
+
createElement: () => el,
|
|
369
|
+
};
|
|
370
|
+
const run = (search) => {
|
|
371
|
+
inserted = null;
|
|
372
|
+
new Function('document', 'window', 'history', 'brand', 'access', 'icon', 'chevronIcon',
|
|
373
|
+
`${src}\nreturn renderDeniedNotice;`)(document,
|
|
374
|
+
{ location: { search, pathname: '/', hash: '' }, OTB: realOTB() },
|
|
375
|
+
{ replaceState: (a, b, url) => { replaced = url; } }, {}, {}, () => '', () => '')();
|
|
376
|
+
return inserted;
|
|
377
|
+
};
|
|
378
|
+
const n = run('?asleep=goals&opens=scope');
|
|
379
|
+
assert.ok(n, 'a notice is drawn');
|
|
380
|
+
assert.match(n.innerHTML, /Goals isn’t open yet\. It opens after scope\. Finish that stage of founding here on Home\./);
|
|
381
|
+
assert.equal(replaced, '/', 'the query is stripped so a refresh does not re-show it');
|
|
382
|
+
assert.equal(run('?asleep=goals%3Cx&opens=scope'), null, 'a malformed room id draws nothing');
|
|
383
|
+
assert.match(run('?denied=page.view.government').innerHTML, /permission/, 'the old denial still reads the same');
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test('the arrival mark survives the shell applying the mode a second time on one page', () => {
|
|
387
|
+
const store = new Map([['hall-founding-asleep', JSON.stringify({ goals: 'scope', 'goal-map': 'scope', 'board-room': 'charter' })]]);
|
|
388
|
+
const storage = { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)) };
|
|
389
|
+
const h = makeHall();
|
|
390
|
+
const apply = loadApplyFounding(h.document, { founding: fm.shapeFoundingMode({ active: 'true', stage: 'strategize' }) }, undefined, storage);
|
|
391
|
+
apply();
|
|
392
|
+
apply(); // /me settles, then the page reports: applyAccess runs again
|
|
393
|
+
const arrived = h.nav.filter((a) => a.classList.contains('nav-item--arrived')).map((a) => a.id).sort();
|
|
394
|
+
assert.deepEqual(arrived, ['nav-board-room', 'nav-goal-map', 'nav-goals']);
|
|
395
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// tests/founding_page_gate.mjs — A SLEEPING ROOM IS NOT REACHABLE BY URL (task 1005112).
|
|
2
|
+
//
|
|
3
|
+
// The sidebar hides a room a founding project has not opened; this is the other half,
|
|
4
|
+
// the page. Everything here RUNS the real middleware (src/bongos/founding-page-gate.js)
|
|
5
|
+
// over fake requests, fed by the real founding-mode shape (modules/onboarding/
|
|
6
|
+
// founding-mode.js) — and, in the last cases, through the real port lookup — so the
|
|
7
|
+
// server's /me answer and the page refusal are one rule.
|
|
8
|
+
|
|
9
|
+
import { strict as assert } from 'node:assert';
|
|
10
|
+
import { test } from 'node:test';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const { foundingPageGate } = require('../src/bongos/founding-page-gate.js');
|
|
16
|
+
const seams = require('../src/module-seams.js');
|
|
17
|
+
const fm = require('../modules/onboarding/founding-mode.js');
|
|
18
|
+
|
|
19
|
+
const founding = (stage) => fm.shapeFoundingMode({ active: 'true', stage });
|
|
20
|
+
const ROOMS = [...new Set([...Object.values(fm.WAKES).flat(), ...fm.WAKES_WHEN_ALIVE])];
|
|
21
|
+
|
|
22
|
+
// Drive the middleware once; resolve with what it did.
|
|
23
|
+
function run(pathname, { read, buildersHost = false, skip = false } = {}) {
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
const res = {
|
|
26
|
+
headers: {},
|
|
27
|
+
set(k, v) { this.headers[k] = v; return this; },
|
|
28
|
+
redirect(code, url) { resolve({ redirect: url, code, headers: this.headers }); },
|
|
29
|
+
};
|
|
30
|
+
const opts = { isBuildersHost: () => buildersHost, skip: () => skip };
|
|
31
|
+
if (read) opts.read = read;
|
|
32
|
+
foundingPageGate(opts)({ path: pathname }, res, () => resolve({ next: true }));
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test('every room a genesis stage can put to sleep is refused while it sleeps, and nothing else is', async () => {
|
|
37
|
+
// At 'decide' every room sleeps, so this is the whole list the gate must cover.
|
|
38
|
+
const read = async () => founding('decide');
|
|
39
|
+
for (const id of ROOMS) {
|
|
40
|
+
const out = await run(`/builders/${id}`, { read });
|
|
41
|
+
assert.ok(out.redirect, `${id} is refused while asleep`);
|
|
42
|
+
}
|
|
43
|
+
for (const never of ['primer', 'board-room', 'profile', 'task/12', 'settings', 'blockers', 'studio']) {
|
|
44
|
+
assert.deepEqual(await run(`/builders/${never}`, { read }), { next: true }, `${never} is never gated`);
|
|
45
|
+
}
|
|
46
|
+
// And the gate names no room the server never puts to sleep: a fake answer naming
|
|
47
|
+
// an unknown room changes nothing.
|
|
48
|
+
assert.deepEqual(await run('/builders/blockers', { read: async () => ({ active: true, asleep: { blockers: 'scope' } }) }), { next: true });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('a sleeping room\'s page goes home, naming the room and the stage that opens it', async () => {
|
|
52
|
+
const out = await run('/builders/goals', { read: async () => founding('scope') });
|
|
53
|
+
assert.equal(out.code, 302);
|
|
54
|
+
assert.equal(out.redirect, '/builders/?asleep=goals&opens=scope');
|
|
55
|
+
assert.equal(out.headers['Cache-Control'], 'no-store', 'a refusal is never cached past the wake');
|
|
56
|
+
const sub = await run('/builders/work.html', { read: async () => founding('scope'), buildersHost: true });
|
|
57
|
+
assert.equal(sub.redirect, '/?asleep=work&opens=strategize', 'the builders. host form');
|
|
58
|
+
const alive = await run('/builders/roster/', { read: async () => founding('legislation') });
|
|
59
|
+
assert.equal(alive.redirect, '/builders/?asleep=roster&opens=alive');
|
|
60
|
+
const sneaky = await run('/builders//goal-map', { read: async () => founding('decide') });
|
|
61
|
+
assert.equal(sneaky.redirect, '/builders/?asleep=goal-map&opens=scope', 'a doubled slash is normalised, not a way past');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('an awake room opens, and a later stage never takes one away', async () => {
|
|
65
|
+
assert.deepEqual(await run('/builders/goals', { read: async () => founding('strategize') }), { next: true });
|
|
66
|
+
assert.deepEqual(await run('/builders/work', { read: async () => founding('charter') }), { next: true });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('a project that is not founding is untouched, and a failed read opens rather than shuts', async () => {
|
|
70
|
+
assert.deepEqual(await run('/builders/goals', { read: async () => null }), { next: true });
|
|
71
|
+
assert.deepEqual(await run('/builders/collab', { read: async () => ({ active: false }) }), { next: true });
|
|
72
|
+
assert.deepEqual(await run('/builders/goals', { read: async () => { throw new Error('db down'); } }), { next: true },
|
|
73
|
+
'a read that throws opens the page');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('only the five room pages ever cost a founding read', async () => {
|
|
77
|
+
let reads = 0;
|
|
78
|
+
const read = async () => { reads += 1; return founding('decide'); };
|
|
79
|
+
for (const p of ['/builders/', '/builders/primer', '/builders/board-room', '/builders/task/5', '/api/bongos/me', '/goals']) {
|
|
80
|
+
assert.deepEqual(await run(p, { read }), { next: true }, p);
|
|
81
|
+
}
|
|
82
|
+
assert.deepEqual(await run('/builders/goals', { read, skip: true }), { next: true }, 'a module surface host is skipped');
|
|
83
|
+
assert.equal(reads, 0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('the default read is the onboarding port\'s foundingModeFor, the answer /me carries', async () => {
|
|
87
|
+
seams._reset();
|
|
88
|
+
try {
|
|
89
|
+
assert.deepEqual(await run('/builders/goals'), { next: true }, 'onboarding module off: not founding, page opens');
|
|
90
|
+
seams.registerProvider('onboarding', { foundingModeFor: async () => founding('scope') });
|
|
91
|
+
assert.equal((await run('/builders/goals')).redirect, '/builders/?asleep=goals&opens=scope');
|
|
92
|
+
seams._reset();
|
|
93
|
+
seams.registerProvider('onboarding', { foundingModeFor: async () => { throw new Error('settings table gone'); } });
|
|
94
|
+
assert.deepEqual(await run('/builders/goals'), { next: true }, 'a port that throws opens the page');
|
|
95
|
+
} finally {
|
|
96
|
+
seams._reset();
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('the server mounts it after the sign-in gate, so a stranger still meets sign-in first', () => {
|
|
101
|
+
const src = fs.readFileSync(new URL('../src/bongos/serve-internal.js', import.meta.url), 'utf8');
|
|
102
|
+
const signIn = src.indexOf('return gdsAuth.requireBuilderPage(req, res, next);');
|
|
103
|
+
const mount = src.indexOf('app.use(foundingPageGate(');
|
|
104
|
+
assert.ok(signIn > 0 && mount > signIn, 'mounted, and after the sign-in gate');
|
|
105
|
+
});
|
|
@@ -222,6 +222,7 @@ test('the guide has no upload, fetch or link-reading path: these are all its rou
|
|
|
222
222
|
assert.deepEqual([...routes(chatRouter), ...routes(planRouter)].sort(), [
|
|
223
223
|
'GET /front-door-guide/conversations/:id',
|
|
224
224
|
'GET /front-door-guide/mine',
|
|
225
|
+
'GET /front-door-guide/mine/handoff.md', // the plan as the hand-off file, a download (task 1005274): reads nothing of theirs
|
|
225
226
|
'GET /front-door-guide/status',
|
|
226
227
|
'POST /front-door-guide/conversations',
|
|
227
228
|
'POST /front-door-guide/conversations/:id/attach',
|
package/tests/genesis_home.mjs
CHANGED
|
@@ -653,6 +653,8 @@ test('the stage reads as a checklist: done, open and locked, and a locked row of
|
|
|
653
653
|
assert.match(row('302'), /class="genesis__task is-locked"/);
|
|
654
654
|
assert.match(row('302'), /after Gather what already exists/, 'a locked row says what it waits on');
|
|
655
655
|
assert.doesNotMatch(row('302'), /data-genesis-copy/, 'a locked row offers no prompt for work it cannot claim');
|
|
656
|
+
assert.doesNotMatch(row('301'), /sr-only/, 'an open row adds no hidden word its status pill could contradict');
|
|
657
|
+
assert.match(row('302'), /class="sr-only">Locked: /);
|
|
656
658
|
assert.doesNotMatch(html, /is-finished|line-through/, 'nothing is drawn struck through');
|
|
657
659
|
// The "done" row: the earlier stage is not the one on screen, so check the shape directly.
|
|
658
660
|
const done = gh.shapeGenesis({ mode: { stage: 'scope', stage_number: 1, stage_count: 5, day: 1 }, plan: gh.parsePlan({}).plan,
|
|
@@ -774,3 +776,25 @@ test('task 1005121: the close route answers 202 with the whole body for an open
|
|
|
774
776
|
const click = hall.slice(hall.indexOf("t.hasAttribute('data-genesis-close')"), hall.indexOf("t.hasAttribute('data-genesis-confirm')"));
|
|
775
777
|
assert.ok(click.length > 0 && !click.includes('/votes'),'the hall no longer casts a second vote after closing — the server did it');
|
|
776
778
|
});
|
|
779
|
+
|
|
780
|
+
test('a stage that opens no room says what finishing it earns, never nothing (task 1005112)', async () => {
|
|
781
|
+
const genesis = gh.shapeGenesis({ mode: { stage: 'scope', stage_number: 1, stage_count: 5, day: 1 }, plan: gh.parsePlan({}).plan });
|
|
782
|
+
const by = Object.fromEntries(genesis.stages.map((s) => [s.key, s]));
|
|
783
|
+
assert.equal(by.scope.earns, null, 'a stage that wakes rooms names the rooms instead');
|
|
784
|
+
assert.equal(by.strategize.earns, null);
|
|
785
|
+
for (const k of ['charter', 'legalize', 'legislation']) {
|
|
786
|
+
assert.deepEqual(by[k].wakes, [], `${k} wakes no room`);
|
|
787
|
+
assert.ok(by[k].earns && by[k].earns.length > 10, `${k} says what it earns`);
|
|
788
|
+
}
|
|
789
|
+
assert.match(by.legislation.earns, /every room opens/, 'the last stage is the one that opens everything');
|
|
790
|
+
const b = bootCard({ founding: true, data: { genesis, viewer_id: '1' } });
|
|
791
|
+
await mountClickable(b);
|
|
792
|
+
assert.match(b.host.innerHTML, /Wakes Goals, Goal map/);
|
|
793
|
+
assert.match(b.host.innerHTML, /puts your government in force/);
|
|
794
|
+
assert.match(b.host.innerHTML, /settles how contributors are rewarded/);
|
|
795
|
+
// The district rises with the stages (task 1005112): each row also names what it raises.
|
|
796
|
+
assert.match(b.host.innerHTML, /Wakes Goals, Goal map · raises Planning/);
|
|
797
|
+
assert.match(b.host.innerHTML, /Raises Treasury · settles how contributors are rewarded/);
|
|
798
|
+
assert.match(b.host.innerHTML, /Raises Rules, Review, Disputes · brings the project to life/);
|
|
799
|
+
assert.deepEqual(by.legalize.raises, ['treasury']);
|
|
800
|
+
});
|