@bongos/core 1.25.22 → 1.25.24
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 +25 -25
- package/docs/copy-inventory.md +38 -27
- package/docs/copy-registry.json +148 -39
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +33 -33
- package/modules/hall-ui/public/genesis-home.js +98 -16
- package/modules/hall-ui/public/palette.js +11 -4
- package/modules/hall-ui/records/founding-mode.md +6 -0
- package/modules/onboarding/genesis-home.js +64 -4
- package/modules/onboarding/genesis-work.js +65 -10
- package/modules/onboarding/routes/genesis-work.js +2 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/hall-preview/README.md +1 -0
- package/scripts/hall-preview/server.js +32 -1
- package/src/module-api.js +1 -1
- package/tests/genesis_home.mjs +115 -2
- package/tests/genesis_work.mjs +38 -0
- package/tests/genesis_work_routes.mjs +21 -0
- package/tests/hall_palette.mjs +59 -2
|
@@ -209,6 +209,8 @@ function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {}, boardR
|
|
|
209
209
|
// What this stage left behind (task 1005115): null where the store is not read.
|
|
210
210
|
document: documents[key] || null,
|
|
211
211
|
all_finished: allFinished,
|
|
212
|
+
// task 1005107: a stage the Board Room passed is fixed; its answers read-only, with why.
|
|
213
|
+
locked: b && b.state === 'passed' ? { reason: require('./genesis-work').lockReason(key) } : null,
|
|
212
214
|
board: b ? {
|
|
213
215
|
state: b.state, item_id: b.item_id || null, needs: b.needs || null,
|
|
214
216
|
one_vote_decides: !!b.one_vote_decides, waiting_on: b.waiting_on || [],
|
|
@@ -233,6 +235,8 @@ function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {}, boardR
|
|
|
233
235
|
day: mode.day,
|
|
234
236
|
deciding: mode.stage === 'decide',
|
|
235
237
|
plan: plan || null,
|
|
238
|
+
// The plan stays editable for as long as genesis is on (task 1005107): POST /founding/plan revises it.
|
|
239
|
+
plan_editable: !!plan,
|
|
236
240
|
// The demo's time box when this genesis is a demo's (task 1004506), else null.
|
|
237
241
|
demo: plan && plan.demo ? plan.demo : null,
|
|
238
242
|
stages,
|
|
@@ -257,7 +261,7 @@ function seams(deps = {}) {
|
|
|
257
261
|
government: deps.government !== undefined ? deps.government : api.resolveOptional('government'),
|
|
258
262
|
// Each stage's document summary (task 1005115): the founder's store unless injected.
|
|
259
263
|
// Fails soft in readGenesis, so a hall without the tables still renders.
|
|
260
|
-
documents: deps.documents !== undefined ? deps.documents : () => require('./genesis-work').readDocuments(),
|
|
264
|
+
documents: deps.documents !== undefined ? deps.documents : (extra) => require('./genesis-work').readDocuments(extra),
|
|
261
265
|
now: deps.now,
|
|
262
266
|
};
|
|
263
267
|
}
|
|
@@ -317,7 +321,10 @@ async function readGenesis(deps = {}) {
|
|
|
317
321
|
}
|
|
318
322
|
let documents = {};
|
|
319
323
|
if (typeof s.documents === 'function') {
|
|
320
|
-
|
|
324
|
+
// The board was just read: hand its passed stages over, so the documents need no second read.
|
|
325
|
+
const locks = {};
|
|
326
|
+
for (const [k, row] of Object.entries(board)) if (row && row.state === 'passed') locks[k] = { reason: require('./genesis-work').lockReason(k) };
|
|
327
|
+
try { documents = (await s.documents({ locks })) || {}; } catch (_) { documents = {}; } // the home still renders without them
|
|
321
328
|
}
|
|
322
329
|
return shapeGenesis({ mode, plan, tasks, board, stageAt, boardRoom: hasBoardRoom(s.government), documents });
|
|
323
330
|
}
|
|
@@ -371,12 +378,13 @@ async function setEnabled(enabled, builderId, deps = {}) {
|
|
|
371
378
|
}
|
|
372
379
|
|
|
373
380
|
// savePlan — the decide stage's door: record the plan, file the five stages as tasks,
|
|
374
|
-
// and move to scope.
|
|
381
|
+
// and move to scope. After that, the same door REVISES the plan (task 1005107; owner:
|
|
382
|
+
// "the UI is very restricting to be in the flow and not be able to go back and edit").
|
|
375
383
|
async function savePlan(body, builderId, deps = {}) {
|
|
376
384
|
const s = seams(deps);
|
|
377
385
|
const mode = await fm.foundingModeFor(modeDeps(s));
|
|
378
386
|
if (!mode) return { ok: false, reason: 'not_founding' };
|
|
379
|
-
if (mode.stage !== 'decide') return
|
|
387
|
+
if (mode.stage !== 'decide') return revisePlan(body, builderId, s);
|
|
380
388
|
const parsed = parsePlan(body);
|
|
381
389
|
if (!parsed.ok) return parsed;
|
|
382
390
|
if (!s.lifecycle || typeof s.lifecycle.seedGenesis !== 'function') return { ok: false, reason: 'no_board' };
|
|
@@ -387,6 +395,58 @@ async function savePlan(body, builderId, deps = {}) {
|
|
|
387
395
|
return { ok: true, plan: parsed.plan, seeded: seeded && seeded.seeded, charter };
|
|
388
396
|
}
|
|
389
397
|
|
|
398
|
+
// revisePlan — change the settled plan while genesis is on. The decide stage is never
|
|
399
|
+
// put to a sitting, so it is never fixed. Re-sending the same plan changes NOTHING (no
|
|
400
|
+
// write, no side effect). A changed plan is saved and what was derived from it follows:
|
|
401
|
+
// days each stage's due date is computed from the plan at read time, so it moves at once
|
|
402
|
+
// government a different form is filed as a charter amendment, decided like any (applyGovernmentChoice)
|
|
403
|
+
// existing turning it on files the gather-what-exists step (the seeder dedups); turning it
|
|
404
|
+
// off cannot take back a step already filed, and the answer says so
|
|
405
|
+
// `could_not` lists, in words, every change it could not carry through. A demo's settled
|
|
406
|
+
// time box and minutes are kept: the plan route does not carry them.
|
|
407
|
+
async function revisePlan(body, builderId, s) {
|
|
408
|
+
const prevRow = await s.get(PLAN_KEY);
|
|
409
|
+
const prev = readPlanValue(prevRow && prevRow.value);
|
|
410
|
+
const parsed = parsePlan(body);
|
|
411
|
+
if (!parsed.ok) return parsed;
|
|
412
|
+
const plan = parsed.plan;
|
|
413
|
+
if (prev && prev.demo) { plan.demo = prev.demo; if (prev.minutes) plan.minutes = prev.minutes; }
|
|
414
|
+
const same = prev && JSON.stringify(canonPlan(prev)) === JSON.stringify(canonPlan(plan));
|
|
415
|
+
if (same) return { ok: true, revised: false, changed: [], could_not: [], plan: prev };
|
|
416
|
+
const changed = prev ? Object.keys(plan).filter((k) => JSON.stringify(prev[k]) !== JSON.stringify(plan[k])) : Object.keys(plan);
|
|
417
|
+
const couldNot = [];
|
|
418
|
+
await s.set(PLAN_KEY, JSON.stringify(plan), builderId);
|
|
419
|
+
let charter = null;
|
|
420
|
+
if (changed.includes('government')) {
|
|
421
|
+
charter = plan.government ? await applyGovernmentChoice(plan.government, builderId, s) : { state: 'none' };
|
|
422
|
+
if (charter.state === 'not_filed' || charter.state === 'needs_council') {
|
|
423
|
+
couldNot.push(`The government could not be changed to ${plan.government} here: ${GOV_NOT_FILED[charter.reason || charter.state] || GOV_NOT_FILED.charter_failed}. The one in force stays.`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (changed.includes('existing')) {
|
|
427
|
+
if (plan.existing && s.lifecycle && typeof s.lifecycle.seedGenesis === 'function') {
|
|
428
|
+
await s.lifecycle.seedGenesis({ builderId, existing: true });
|
|
429
|
+
} else if (!plan.existing) {
|
|
430
|
+
couldNot.push('The step for gathering what already exists stays on the board: a filed task is not taken back. Drop it there if it no longer applies.');
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return { ok: true, revised: true, changed, could_not: couldNot, plan, charter };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Why a government choice could not be filed, as a person would say it.
|
|
437
|
+
const GOV_NOT_FILED = Object.freeze({
|
|
438
|
+
needs_council: 'it needs a council with a rank before it can seat anyone',
|
|
439
|
+
no_government: 'this project has no Board Room to file it with',
|
|
440
|
+
charter_failed: 'the change to the charter could not be filed',
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// canonPlan — PURE. A plan with its keys in one order, so two equal plans compare equal.
|
|
444
|
+
function canonPlan(p) {
|
|
445
|
+
const out = {};
|
|
446
|
+
for (const k of Object.keys(p).sort()) out[k] = p[k] && typeof p[k] === 'object' && !Array.isArray(p[k]) ? canonPlan(p[k]) : p[k];
|
|
447
|
+
return out;
|
|
448
|
+
}
|
|
449
|
+
|
|
390
450
|
// applyGovernmentChoice — the government picked at decide becomes the one in force
|
|
391
451
|
// (task 1004656). It is filed as an ordinary charter amendment under the constitution
|
|
392
452
|
// in force (the government port's proposeCharter), so the board decides it like any
|
|
@@ -297,9 +297,9 @@ function documentOf(s) {
|
|
|
297
297
|
markdown: filled ? lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n' : '',
|
|
298
298
|
counts: s.counts,
|
|
299
299
|
updated_at: latest([s.updated_at, ...said.map((q) => q.answer.updated_at)]),
|
|
300
|
-
// Editing never reopens the stage.
|
|
301
|
-
|
|
302
|
-
|
|
300
|
+
// Editing never reopens the stage. A stage the Board Room passed is fixed (task 1005107).
|
|
301
|
+
editable: !s.locked,
|
|
302
|
+
locked_reason: s.locked ? s.locked.reason : null,
|
|
303
303
|
edit: {
|
|
304
304
|
answer: `PUT /api/bongos/founding/work/${s.stage}/answers/:question`,
|
|
305
305
|
page: `PUT /api/bongos/founding/work/${s.stage}/page`,
|
|
@@ -320,9 +320,11 @@ async function readDocument(stage, deps = {}) {
|
|
|
320
320
|
// genesis home carries per stage, so the page shows each stage's document in place.
|
|
321
321
|
async function readDocuments(deps = {}) {
|
|
322
322
|
const out = {};
|
|
323
|
+
let locks = {};
|
|
324
|
+
try { locks = await locksFor(deps); } catch (_) { locks = {}; }
|
|
323
325
|
for (const s of await readAll(deps)) {
|
|
324
|
-
const d = documentOf(s);
|
|
325
|
-
out[s.stage] = { title: d.title, filled: d.filled, updated_at: d.updated_at, editable: d.editable };
|
|
326
|
+
const d = documentOf({ ...s, locked: locks[s.stage] || null });
|
|
327
|
+
out[s.stage] = { title: d.title, filled: d.filled, updated_at: d.updated_at, editable: d.editable, locked_reason: d.locked_reason };
|
|
326
328
|
}
|
|
327
329
|
return out;
|
|
328
330
|
}
|
|
@@ -441,10 +443,50 @@ async function readAll(deps = {}) {
|
|
|
441
443
|
return shapeAll(await storeOf(deps).loadAll());
|
|
442
444
|
}
|
|
443
445
|
|
|
446
|
+
// ── which stages are fixed (task 1005107) ─────────────────────────────────────
|
|
447
|
+
// The owner's smallest honest answer: any stage not yet closed is editable; a stage the
|
|
448
|
+
// Board Room PASSED is shown read-only with a plain reason, because its answers are what
|
|
449
|
+
// the team voted on. A stage that moved on without a Board Room (a solo founder's
|
|
450
|
+
// finished tasks, task 1005115) stays editable: nobody voted on it. No government module
|
|
451
|
+
// means no sitting, so nothing is fixed. If the Board Room cannot be read, writes are
|
|
452
|
+
// refused rather than risk changing what was voted on.
|
|
453
|
+
const lockReason = (stage) => `The Board Room passed the ${stage} stage, so its answers are fixed: they are what was voted on. To change them, put the change to the Board Room.`;
|
|
454
|
+
|
|
455
|
+
// locksFor(deps) → { stage: { reason } } for every stage the Board Room passed. A caller
|
|
456
|
+
// that has already read the board passes the answer in as deps.locks (one read per request).
|
|
457
|
+
async function locksFor(deps = {}) {
|
|
458
|
+
if (deps.locks) return deps.locks;
|
|
459
|
+
const api = deps.api || require('../../src/module-api');
|
|
460
|
+
const gov = deps.government !== undefined ? deps.government : api.resolveOptional('government');
|
|
461
|
+
if (!gov || typeof gov.genesisStageStatus !== 'function') return {};
|
|
462
|
+
const st = await gov.genesisStageStatus();
|
|
463
|
+
const out = {};
|
|
464
|
+
for (const row of (st && st.stages) || []) {
|
|
465
|
+
if (row && row.state === 'passed' && isStage(row.key)) out[row.key] = { reason: lockReason(row.key) };
|
|
466
|
+
}
|
|
467
|
+
return out;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// guardWrite(stage, deps) → { refused } when the stage may not be written, else
|
|
471
|
+
// { deps } carrying the lock read, so the read that answers the write reuses it. Every
|
|
472
|
+
// write checks this first, before its own content checks, so a fixed stage always
|
|
473
|
+
// answers stage_closed whatever was sent.
|
|
474
|
+
async function guardWrite(stage, deps) {
|
|
475
|
+
let locks;
|
|
476
|
+
try { locks = await locksFor(deps); } catch (_) {
|
|
477
|
+
return { refused: { ok: false, reason: 'stage_lock_unknown', message: 'The Board Room could not be read, so nothing was changed. Try again in a moment.' } };
|
|
478
|
+
}
|
|
479
|
+
if (locks[stage]) return { refused: { ok: false, reason: 'stage_closed', message: locks[stage].reason } };
|
|
480
|
+
return { deps: { ...deps, locks } };
|
|
481
|
+
}
|
|
482
|
+
|
|
444
483
|
async function readStage(stage, deps = {}) {
|
|
445
484
|
if (!isStage(stage)) return { ok: false, reason: 'unknown_stage' };
|
|
446
485
|
const all = await readAll(deps);
|
|
447
486
|
const work = all.find((s) => s.stage === stage);
|
|
487
|
+
let locks = {};
|
|
488
|
+
try { locks = await locksFor(deps); } catch (_) { locks = {}; }
|
|
489
|
+
work.locked = locks[stage] || null;
|
|
448
490
|
if (stage === 'charter') work.advice = await charterAdvice(work, deps);
|
|
449
491
|
return { ok: true, work };
|
|
450
492
|
}
|
|
@@ -490,16 +532,22 @@ async function setDoor(stage, door, by, deps = {}) {
|
|
|
490
532
|
return readStage(stage, deps);
|
|
491
533
|
}
|
|
492
534
|
|
|
493
|
-
async function setPage(stage, page, by,
|
|
535
|
+
async function setPage(stage, page, by, deps0 = {}) {
|
|
494
536
|
if (!isStage(stage)) return { ok: false, reason: 'unknown_stage' };
|
|
537
|
+
const g = await guardWrite(stage, deps0);
|
|
538
|
+
if (g.refused) return g.refused;
|
|
539
|
+
const deps = g.deps;
|
|
495
540
|
const words = text(page);
|
|
496
541
|
if (words.length > LIMITS.page) return { ok: false, reason: 'page_too_long' };
|
|
497
542
|
await storeOf(deps).upsertWork(stage, { page_md: words || null }, by);
|
|
498
543
|
return readStage(stage, deps);
|
|
499
544
|
}
|
|
500
545
|
|
|
501
|
-
async function setLink(stage, url, by,
|
|
546
|
+
async function setLink(stage, url, by, deps0 = {}) {
|
|
502
547
|
if (!isStage(stage)) return { ok: false, reason: 'unknown_stage' };
|
|
548
|
+
const g = await guardWrite(stage, deps0);
|
|
549
|
+
if (g.refused) return g.refused;
|
|
550
|
+
const deps = g.deps;
|
|
503
551
|
const c = checkLink(url);
|
|
504
552
|
if (!c.ok) return c;
|
|
505
553
|
await storeOf(deps).upsertWork(stage, { link_url: c.value }, by);
|
|
@@ -508,7 +556,11 @@ async function setLink(stage, url, by, deps = {}) {
|
|
|
508
556
|
|
|
509
557
|
// setAnswer — store or revise one answer. opts.ifEmpty (for carrying answers over,
|
|
510
558
|
// task 1005274) writes only where the founder has said nothing yet.
|
|
511
|
-
async function setAnswer(stage, questionId, body, by,
|
|
559
|
+
async function setAnswer(stage, questionId, body, by, deps0 = {}, opts = {}) {
|
|
560
|
+
if (!isStage(stage)) return { ok: false, reason: 'unknown_stage' };
|
|
561
|
+
const g = await guardWrite(stage, deps0);
|
|
562
|
+
if (g.refused) return g.refused;
|
|
563
|
+
const deps = g.deps;
|
|
512
564
|
const c = checkAnswer(stage, questionId, body);
|
|
513
565
|
if (!c.ok) return c;
|
|
514
566
|
const { changed } = await storeOf(deps).writeAnswer(stage, questionId, c.value, by, opts);
|
|
@@ -517,8 +569,11 @@ async function setAnswer(stage, questionId, body, by, deps = {}, opts = {}) {
|
|
|
517
569
|
}
|
|
518
570
|
|
|
519
571
|
// clearAnswer — un-answer: the question goes back to empty (not to "skipped").
|
|
520
|
-
async function clearAnswer(stage, questionId, by,
|
|
572
|
+
async function clearAnswer(stage, questionId, by, deps0 = {}) {
|
|
521
573
|
if (!isStage(stage)) return { ok: false, reason: 'unknown_stage' };
|
|
574
|
+
const g = await guardWrite(stage, deps0);
|
|
575
|
+
if (g.refused) return g.refused;
|
|
576
|
+
const deps = g.deps;
|
|
522
577
|
if (!questionFor(stage, questionId)) return { ok: false, reason: 'unknown_question' };
|
|
523
578
|
const { cleared } = await storeOf(deps).clearAnswer(stage, questionId, by);
|
|
524
579
|
const r = await readStage(stage, deps);
|
|
@@ -547,6 +602,6 @@ async function readContext(deps = {}) {
|
|
|
547
602
|
// and are exercised through the operations (tests/genesis_work.mjs).
|
|
548
603
|
module.exports = {
|
|
549
604
|
STAGES, LIMITS, QUESTIONS, assistPrompt,
|
|
550
|
-
readDocument, readDocuments,
|
|
605
|
+
readDocument, readDocuments, lockReason,
|
|
551
606
|
pgStore, readAll, readStage, setDoor, setPage, setLink, setAnswer, clearAnswer, answerHistory, readContext,
|
|
552
607
|
};
|
|
@@ -17,6 +17,7 @@ const handoff = require('../guide-handoff'); // the start-a-project guide's an
|
|
|
17
17
|
|
|
18
18
|
const REFUSALS = {
|
|
19
19
|
unknown_stage: 404, unknown_question: 404, bad_door: 400, bad_state: 400, bad_origin: 400,
|
|
20
|
+
stage_closed: 409, stage_lock_unknown: 503,
|
|
20
21
|
unknown_fact: 400, bad_fact: 400, region_not_in_country: 400,
|
|
21
22
|
empty_answer: 400, not_a_choice: 400, answer_too_long: 400, page_too_long: 400, link_too_long: 400, bad_link: 400,
|
|
22
23
|
};
|
|
@@ -28,7 +29,7 @@ module.exports = function mountGenesisWorkRoutes(router, deps = {}) {
|
|
|
28
29
|
try {
|
|
29
30
|
const r = await fn(req);
|
|
30
31
|
if (r && r.ok === false) {
|
|
31
|
-
return res.status(REFUSALS[r.reason] || 409).json({ error: { code: r.reason, message: r.reason, ...(r.expected ? { expected: r.expected } : {}) } });
|
|
32
|
+
return res.status(REFUSALS[r.reason] || 409).json({ error: { code: r.reason, message: r.message || r.reason, ...(r.expected ? { expected: r.expected } : {}) } });
|
|
32
33
|
}
|
|
33
34
|
res.set('Cache-Control', 'no-store');
|
|
34
35
|
res.json(r);
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.25.
|
|
3
|
+
"version": "1.25.24",
|
|
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.24",
|
|
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.24",
|
|
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
|
@@ -11900,5 +11900,17 @@
|
|
|
11900
11900
|
"id": "1005129",
|
|
11901
11901
|
"text": "Choosing how contributors are rewarded now shows, for every option, what the platform really requires and what is closed — and the project now remembers where it is and where its builders are, so those notes fit it."
|
|
11902
11902
|
}
|
|
11903
|
+
],
|
|
11904
|
+
"1.25.23": [
|
|
11905
|
+
{
|
|
11906
|
+
"id": "1005285",
|
|
11907
|
+
"text": "In the builders' hall, a project still being founded no longer lists its closed rooms in the quick-jump search; only the rooms that are open can be found."
|
|
11908
|
+
}
|
|
11909
|
+
],
|
|
11910
|
+
"1.25.24": [
|
|
11911
|
+
{
|
|
11912
|
+
"id": "1005107",
|
|
11913
|
+
"text": "A founder can now go back and change their Genesis plan after saving it, and change any earlier stage's answers until the Board Room has voted that stage through — a voted stage shows its answers with a plain reason why they a"
|
|
11914
|
+
}
|
|
11903
11915
|
]
|
|
11904
11916
|
}
|
|
@@ -24,6 +24,7 @@ node scripts/hall-preview/server.js # neutral pack, port 4600
|
|
|
24
24
|
| `--fixture-me` | don't read your own account live — serve the checked-in `me.json` snapshot (see below) |
|
|
25
25
|
| `--signed-out` | answer `/me` with a 401 — preview the signed-out sign-in card |
|
|
26
26
|
| `--latency 3000` | hold every data read that many ms before answering, so a page's loading state (the hall's `.hall-loading` mark) stays on screen long enough to look at |
|
|
27
|
+
| `--founding scope` | preview a project being founded, at that genesis stage (task 1005285): `/me.founding_mode` is the real `shapeFoundingMode`, `GET /founding/genesis` the real `shapeGenesis` over `fixtures/founding__genesis__tasks.json`, and a sleeping room's page is refused by the real `foundingPageGate` |
|
|
27
28
|
|
|
28
29
|
Writes are not wired: any `POST/PATCH/DELETE` answers `501 preview_readonly` — except
|
|
29
30
|
the canned writes in `server.js` and, since task 1004685, the five per-builder pref
|
|
@@ -74,6 +74,12 @@ const SIGNED_OUT = process.argv.includes('--signed-out');
|
|
|
74
74
|
// fixtures answer in a millisecond, which is why no loader had ever been seen.
|
|
75
75
|
const LATENCY = Math.max(0, Number(arg('--latency')) || 0);
|
|
76
76
|
const BRAND_FILE = arg('--brand') || 'config/branding.neutral.json';
|
|
77
|
+
// --founding <stage>: preview a project being founded, at that genesis stage (task 1005285).
|
|
78
|
+
// The answers are the server's own code, never a hand-written copy: /me.founding_mode is
|
|
79
|
+
// shapeFoundingMode (modules/onboarding/founding-mode.js), GET /founding/genesis is
|
|
80
|
+
// shapeGenesis over fixtures/founding__genesis__tasks.json (modules/onboarding/genesis-home.js),
|
|
81
|
+
// and a sleeping room's page is refused by the real foundingPageGate (src/bongos/).
|
|
82
|
+
const FOUNDING_STAGE = arg('--founding');
|
|
77
83
|
process.env.BONGOS_BRANDING_FILE = path.resolve(ROOT, BRAND_FILE);
|
|
78
84
|
|
|
79
85
|
/* eslint-disable import/no-dynamic-require */
|
|
@@ -93,6 +99,18 @@ const { overrideKey, resolveLiveIdentity } = require('./live-identity');
|
|
|
93
99
|
const { createPrefRecorder, readJsonBody } = require('./pref-writes');
|
|
94
100
|
const PREFS = createPrefRecorder();
|
|
95
101
|
|
|
102
|
+
let FOUNDING = null;
|
|
103
|
+
let GENESIS = null;
|
|
104
|
+
if (FOUNDING_STAGE) {
|
|
105
|
+
const fm = require(path.join(ROOT, 'modules', 'onboarding', 'founding-mode.js'));
|
|
106
|
+
const gh = require(path.join(ROOT, 'modules', 'onboarding', 'genesis-home.js'));
|
|
107
|
+
FOUNDING = fm.shapeFoundingMode({ active: 'true', stage: FOUNDING_STAGE, startedAt: new Date(Date.now() - 2 * 86400000).toISOString() });
|
|
108
|
+
if (!FOUNDING) throw new Error(`--founding: not a genesis stage: ${FOUNDING_STAGE} (one of ${fm.STAGES.join(', ')})`);
|
|
109
|
+
const tasks = JSON.parse(fs.readFileSync(path.join(FIXTURES, 'founding__genesis__tasks.json'), 'utf8'));
|
|
110
|
+
delete tasks._comment;
|
|
111
|
+
GENESIS = gh.shapeGenesis({ mode: FOUNDING, plan: gh.parsePlan({}).plan, tasks });
|
|
112
|
+
}
|
|
113
|
+
|
|
96
114
|
// The session is the ONE thing this harness reads from outside its own tree, and
|
|
97
115
|
// it reads it through the FAIL-OPEN helper on purpose: apiCall/cliClient exit the
|
|
98
116
|
// process on a 401, which would kill a preview over a cosmetic feature.
|
|
@@ -423,12 +441,17 @@ app.use((req, res, next) => {
|
|
|
423
441
|
// wasn't resolved live falls through to its fixture exactly as before.
|
|
424
442
|
const q = req.query || {};
|
|
425
443
|
const live = liveOverrides.get(overrideKey(apiPath, q)) ?? liveOverrides.get(apiPath);
|
|
444
|
+
if (FOUNDING && apiPath === 'founding/genesis') {
|
|
445
|
+
const me = liveOverrides.get('me') ?? fixtureFor('me');
|
|
446
|
+
return res.json({ genesis: GENESIS, viewer_id: me && me.builder ? me.builder.id : null });
|
|
447
|
+
}
|
|
426
448
|
const fix = live !== undefined ? live : fixtureFor(apiPath, q);
|
|
427
449
|
if (fix === undefined) {
|
|
428
450
|
console.log(` no fixture: ${apiPath} (add scripts/hall-preview/fixtures/${apiPath.replace(/\//g, '__')}.json)`);
|
|
429
451
|
return res.status(404).json({ error: { code: 'no_fixture', message: apiPath } });
|
|
430
452
|
}
|
|
431
453
|
let payload = EMPTY ? emptied(fix) : fix;
|
|
454
|
+
if (FOUNDING && apiPath === 'me') payload = { ...payload, founding_mode: FOUNDING };
|
|
432
455
|
if (RANK_OVERRIDE && apiPath === 'me') {
|
|
433
456
|
payload = JSON.parse(JSON.stringify(payload));
|
|
434
457
|
if (payload.builder) payload.builder.rank = RANK_OVERRIDE;
|
|
@@ -486,8 +509,15 @@ app.get('/', (req, res, next) => { if (!sendPage(res, 'index.html')) next(); });
|
|
|
486
509
|
// fell through to the real gate and 302'd to sign-in (task 1003305).
|
|
487
510
|
app.get(/^\/task\/\d+$/, (req, res, next) => { if (!sendPage(res, 'task.html')) next(); });
|
|
488
511
|
app.get(/^\/idea\/\d+$/, (req, res, next) => { if (!sendPage(res, 'idea.html')) next(); });
|
|
512
|
+
// --founding: a sleeping room's page answers as prod does, through the real gate (it
|
|
513
|
+
// matches the apex /builders/<room> shape; the harness serves pages at the root).
|
|
514
|
+
const foundingGate = FOUNDING
|
|
515
|
+
? require(path.join(ROOT, 'src', 'bongos', 'founding-page-gate.js')).foundingPageGate({ isBuildersHost: () => true, read: async () => FOUNDING })
|
|
516
|
+
: null;
|
|
489
517
|
app.get(/^\/([a-z-]+)$/, (req, res, next) => {
|
|
490
|
-
if (!sendPage(res, `${req.params[0]}.html`)) next();
|
|
518
|
+
const serve = () => { if (!sendPage(res, `${req.params[0]}.html`)) next(); };
|
|
519
|
+
if (!foundingGate) return serve();
|
|
520
|
+
return foundingGate({ path: `/builders${req.path}` }, res, serve);
|
|
491
521
|
});
|
|
492
522
|
|
|
493
523
|
// What the READING ROOM reads (task 1003306): the primer document and
|
|
@@ -554,6 +584,7 @@ function accountLine() {
|
|
|
554
584
|
app.listen(PORT, '127.0.0.1', () => {
|
|
555
585
|
console.log(`hall-preview: http://builders.localhost:${PORT}/`);
|
|
556
586
|
console.log(accountLine());
|
|
587
|
+
if (FOUNDING) console.log(` founding: stage ${FOUNDING.stage}; asleep: ${Object.keys(FOUNDING.asleep).join(', ') || 'none'}`);
|
|
557
588
|
console.log(` brand: ${BRAND_FILE}${EMPTY ? ' (EMPTY fixtures)' : ''}${MODULES_OFF.length ? ` modules off: ${MODULES_OFF.join(',')}` : ''}`);
|
|
558
589
|
console.log(' pages:', Object.keys(stamped).map((f) => f.replace(/\.html$/, '')).sort().join(' '));
|
|
559
590
|
});
|
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.24'; // 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/genesis_home.mjs
CHANGED
|
@@ -229,7 +229,7 @@ function world({ withGovernment = true } = {}) {
|
|
|
229
229
|
lifecycle, government, now, documents: null,
|
|
230
230
|
});
|
|
231
231
|
return {
|
|
232
|
-
store, tasks, board, opened, deps,
|
|
232
|
+
store, tasks, board, opened, deps, government,
|
|
233
233
|
finish: (s) => tasks[s].forEach((t) => { t.status = 'shipped'; }),
|
|
234
234
|
pass: (s) => { board[s] = { state: 'passed', item_id: '9', waiting_on: [] }; },
|
|
235
235
|
tick: (days) => { now = new Date(now.getTime() + days * 86400000); },
|
|
@@ -251,7 +251,7 @@ test('decide → five stages through the Board Room → alive, and the hall open
|
|
|
251
251
|
g = await gh.readGenesis(w.deps());
|
|
252
252
|
assert.equal(g.stage, 'scope');
|
|
253
253
|
assert.equal(g.can_close, false, 'a stage with unfinished tasks cannot be put to the board');
|
|
254
|
-
assert.equal((await gh.savePlan({}, 1, w.deps())).
|
|
254
|
+
assert.equal((await gh.savePlan({}, 1, w.deps())).revised, false, 'the same plan again changes nothing (task 1005107)');
|
|
255
255
|
|
|
256
256
|
for (const s of STAGES5) {
|
|
257
257
|
assert.equal((await gh.readGenesis(w.deps())).stage, s);
|
|
@@ -446,6 +446,48 @@ test('task 1005115: each stage on the genesis home carries its document', async
|
|
|
446
446
|
assert.equal(g.stages[0].document, null);
|
|
447
447
|
});
|
|
448
448
|
|
|
449
|
+
test('task 1005107: the plan can be changed after decide; the same plan again changes nothing', async () => {
|
|
450
|
+
const w = world();
|
|
451
|
+
w.government.proposeCharter = async ({ form }) => ({ ok: true, item: { id: 44, constitution: { pass_rule: 'first_ratifier' } }, reads_as: { name: form } });
|
|
452
|
+
await gh.startFounding(1, w.deps());
|
|
453
|
+
await gh.savePlan({ days: { scope: 7 } }, 1, w.deps());
|
|
454
|
+
let g = await gh.readGenesis(w.deps());
|
|
455
|
+
assert.equal(g.plan_editable, true);
|
|
456
|
+
const before = new Map(w.store);
|
|
457
|
+
const same = await gh.savePlan({ days: { scope: 7 } }, 1, w.deps());
|
|
458
|
+
assert.equal(same.ok, true);
|
|
459
|
+
assert.equal(same.revised, false);
|
|
460
|
+
assert.deepEqual([...w.store], [...before], 'leaving it unchanged writes nothing');
|
|
461
|
+
// A changed length moves the stage's due date at once.
|
|
462
|
+
const r = await gh.savePlan({ days: { scope: 10 } }, 1, w.deps());
|
|
463
|
+
assert.equal(r.revised, true);
|
|
464
|
+
assert.deepEqual(r.changed, ['days']);
|
|
465
|
+
g = await gh.readGenesis(w.deps());
|
|
466
|
+
assert.equal(g.stages[0].days, 10);
|
|
467
|
+
assert.equal(g.stage, 'scope', 'revising the plan never moves the stage');
|
|
468
|
+
// A different government is filed for the board, like any charter change.
|
|
469
|
+
const gov = await gh.savePlan({ days: { scope: 10 }, government: 'republic' }, 1, w.deps());
|
|
470
|
+
assert.equal(gov.charter.state, 'sitting');
|
|
471
|
+
assert.equal(gov.charter.item_id, '44');
|
|
472
|
+
// Turning "existing" off cannot take back a filed step, and it says so.
|
|
473
|
+
await gh.savePlan({ days: { scope: 10 }, government: 'republic', existing: true }, 1, w.deps());
|
|
474
|
+
const off = await gh.savePlan({ days: { scope: 10 }, government: 'republic', existing: false }, 1, w.deps());
|
|
475
|
+
assert.equal(off.could_not.length, 1);
|
|
476
|
+
assert.match(off.could_not[0], /stays on the board/);
|
|
477
|
+
assert.equal((await gh.savePlan({ days: { scope: 999 } }, 1, w.deps())).reason, 'bad_days', 'a revision is held to the same ranges');
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test('task 1005107: a stage the Board Room passed is shown fixed, with why; one that has not is not', async () => {
|
|
481
|
+
const w = world();
|
|
482
|
+
await gh.startFounding(1, w.deps());
|
|
483
|
+
await gh.savePlan({}, 1, w.deps());
|
|
484
|
+
w.finish('scope');
|
|
485
|
+
w.pass('scope');
|
|
486
|
+
const g = await gh.readGenesis(w.deps());
|
|
487
|
+
assert.match(g.stages[0].locked.reason, /Board Room passed the scope stage/);
|
|
488
|
+
assert.equal(g.stages[1].locked, null);
|
|
489
|
+
});
|
|
490
|
+
|
|
449
491
|
test('the view: states, wakes, the life plates and a due date from the plan', () => {
|
|
450
492
|
const mode = { stage: 'strategize', stage_number: 2, stage_count: 5, day: 9 };
|
|
451
493
|
const g = gh.shapeGenesis({
|
|
@@ -704,6 +746,77 @@ test('task 1005115: with no Board Room the card says the stage moved by itself,
|
|
|
704
746
|
assert.doesNotMatch(host.innerHTML, /The strategy<\/b>/, 'an empty document is not listed');
|
|
705
747
|
});
|
|
706
748
|
|
|
749
|
+
test('task 1005107: the card offers to change the plan, reopens it with the saved answers, and says which stages are fixed', async () => {
|
|
750
|
+
const genesis = gh.shapeGenesis({
|
|
751
|
+
mode: { stage: 'strategize', stage_number: 2, stage_count: 5, day: 3 },
|
|
752
|
+
plan: { days: { scope: 7, strategize: 9, charter: 4, legalize: 7, legislation: 3 }, passes: 2, advisors: 'cbaas', existing: true, government: null, expected_builders: null },
|
|
753
|
+
tasks: { scope: [{ id: '1', title: 'Scope', status: 'shipped' }], strategize: [{ id: '2', title: 'Strategy', status: 'ready' }] },
|
|
754
|
+
board: { scope: { state: 'passed', waiting_on: [] } },
|
|
755
|
+
stageAt: { strategize: '2026-10-08T00:00:00Z' },
|
|
756
|
+
});
|
|
757
|
+
const b = bootCard({ founding: true, data: { genesis, viewer_id: '1' } });
|
|
758
|
+
const click = await mountClickable(b);
|
|
759
|
+
assert.match(b.host.innerHTML, /data-genesis-plan-edit>Change the plan</);
|
|
760
|
+
assert.match(b.host.innerHTML, /Fixed: The Board Room passed the scope stage/);
|
|
761
|
+
assert.doesNotMatch(b.host.innerHTML, /data-genesis-plan>/, 'the plan form is not shown until asked for');
|
|
762
|
+
await click(fakeButton('data-genesis-plan-edit'));
|
|
763
|
+
assert.match(b.host.innerHTML, /data-genesis-plan>Save the plan</);
|
|
764
|
+
assert.match(b.host.innerHTML, /data-genesis-plan-cancel>Keep it as it was</);
|
|
765
|
+
assert.match(b.host.innerHTML, /value="2" checked/, 'two passes, as saved');
|
|
766
|
+
assert.match(b.host.innerHTML, /value="cbaas" checked/, 'advisors, as saved');
|
|
767
|
+
assert.match(b.host.innerHTML, /data-existing checked/, '"it already exists", as saved');
|
|
768
|
+
assert.match(b.host.innerHTML, /<option value="9" selected>9 days/, 'the stage length, as saved');
|
|
769
|
+
await click(fakeButton('data-genesis-plan-cancel'));
|
|
770
|
+
assert.doesNotMatch(b.host.innerHTML, /data-genesis-plan>/, 'keeping it as it was sends nothing and closes the form');
|
|
771
|
+
assert.ok(!b.calls.some((c) => c.startsWith('POST /founding/plan')));
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
test('task 1005107: a finished stage\'s answers are changed in place; unchanged sends nothing, a change sends only itself', async () => {
|
|
775
|
+
const genesis = gh.shapeGenesis({
|
|
776
|
+
mode: { stage: 'strategize', stage_number: 2, stage_count: 5, day: 3 },
|
|
777
|
+
plan: { days: {} },
|
|
778
|
+
tasks: { scope: [{ id: '1', title: 'Scope', status: 'shipped' }], strategize: [{ id: '2', title: 'Strategy', status: 'ready' }] },
|
|
779
|
+
stageAt: { strategize: '2026-10-08T00:00:00Z' },
|
|
780
|
+
});
|
|
781
|
+
const work = {
|
|
782
|
+
stage: 'scope', page: 'Old page',
|
|
783
|
+
questions: [
|
|
784
|
+
{ id: 'what_creating', ask: 'What are you creating?', answer: { state: 'answered', text: 'A ledger' } },
|
|
785
|
+
{ id: 'reach_who', ask: 'Who for?', answer: null },
|
|
786
|
+
],
|
|
787
|
+
};
|
|
788
|
+
const b = bootCard({ founding: true, data: { genesis, viewer_id: '1', work } });
|
|
789
|
+
const click = await mountClickable(b);
|
|
790
|
+
assert.match(b.host.innerHTML, /data-genesis-stage-edit="scope">Change its answers</);
|
|
791
|
+
await click(fakeButton('data-genesis-stage-edit', 'scope'));
|
|
792
|
+
assert.ok(b.calls.includes('GET /api/bongos/founding/work/scope'));
|
|
793
|
+
assert.match(b.host.innerHTML, /<textarea rows="3" data-answer="what_creating">A ledger<\/textarea>/, 'opens with the answer already given');
|
|
794
|
+
assert.match(b.host.innerHTML, /data-page>Old page</);
|
|
795
|
+
// Leaving everything as it was sends nothing.
|
|
796
|
+
const values = { '[data-answer="what_creating"]': 'A ledger', '[data-answer="reach_who"]': '', '[data-page]': 'Old page' };
|
|
797
|
+
b.host.querySelector = (sel) => (sel in values ? { value: values[sel] } : null);
|
|
798
|
+
const before = b.calls.length;
|
|
799
|
+
await click(fakeButton('data-genesis-stage-save'));
|
|
800
|
+
assert.equal(b.calls.length, before, 'nothing changed: no request');
|
|
801
|
+
assert.deepEqual(b.toasts.slice(-1), ['Nothing changed.']);
|
|
802
|
+
// A change sends only that answer.
|
|
803
|
+
await click(fakeButton('data-genesis-stage-edit', 'scope'));
|
|
804
|
+
values['[data-answer="reach_who"]'] = 'Small bakeries';
|
|
805
|
+
const mark = b.calls.length;
|
|
806
|
+
await click(fakeButton('data-genesis-stage-save'));
|
|
807
|
+
assert.deepEqual(b.calls.slice(mark), ['PUT /api/bongos/founding/work/scope/answers/reach_who']);
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
test('task 1005107: a government the plan cannot file is explained in words, never as a code', async () => {
|
|
811
|
+
const w = world();
|
|
812
|
+
w.government.proposeCharter = async () => ({ ok: false, reason: 'council_needs_a_rank' });
|
|
813
|
+
await gh.startFounding(1, w.deps());
|
|
814
|
+
await gh.savePlan({}, 1, w.deps());
|
|
815
|
+
const r = await gh.savePlan({ government: 'foundation' }, 1, w.deps());
|
|
816
|
+
assert.match(r.could_not[0], /needs a council with a rank/);
|
|
817
|
+
assert.doesNotMatch(r.could_not[0], /needs_council|council_needs/);
|
|
818
|
+
});
|
|
819
|
+
|
|
707
820
|
test('the hall carries the five life plates byte-identical to the world\'s', () => {
|
|
708
821
|
const world = path.join(ROOT, 'modules', 'public-landing', 'public', 'assets', 'world');
|
|
709
822
|
const hall = path.join(ROOT, 'modules', 'hall-ui', 'public', 'genesis');
|
package/tests/genesis_work.mjs
CHANGED
|
@@ -399,6 +399,44 @@ if (import.meta.url === url.pathToFileURL(process.argv[1]).href) {
|
|
|
399
399
|
assert.equal(all.charter.title, 'The charter');
|
|
400
400
|
});
|
|
401
401
|
|
|
402
|
+
await test('task 1005107: a stage the Board Room passed is read-only with a plain reason; others stay editable', async () => {
|
|
403
|
+
let passed = ['scope'];
|
|
404
|
+
const government = { genesisStageStatus: async () => ({ stages: passed.map((key) => ({ key, state: 'passed' })) }) };
|
|
405
|
+
const deps = { store: memStore(), government };
|
|
406
|
+
const w = await gw.setAnswer('scope', 'what_creating', { text: 'A ledger' }, 1, deps);
|
|
407
|
+
assert.equal(w.ok, false);
|
|
408
|
+
assert.equal(w.reason, 'stage_closed');
|
|
409
|
+
assert.match(w.message, /Board Room passed the scope stage/);
|
|
410
|
+
assert.equal((await gw.setPage('scope', 'x', 1, deps)).reason, 'stage_closed');
|
|
411
|
+
assert.equal((await gw.setLink('scope', 'https://x.test', 1, deps)).reason, 'stage_closed');
|
|
412
|
+
assert.equal((await gw.clearAnswer('scope', 'what_creating', 1, deps)).reason, 'stage_closed');
|
|
413
|
+
const read = await gw.readStage('scope', deps);
|
|
414
|
+
assert.match(read.work.locked.reason, /fixed/);
|
|
415
|
+
const doc = (await gw.readDocument('scope', deps)).document;
|
|
416
|
+
assert.equal(doc.editable, false);
|
|
417
|
+
assert.match(doc.locked_reason, /what was voted on/);
|
|
418
|
+
assert.equal((await gw.readDocuments(deps)).scope.editable, false);
|
|
419
|
+
// A stage not passed stays editable, and so does one with no Board Room at all.
|
|
420
|
+
assert.equal((await gw.setAnswer('strategize', 'risks', { text: 'Time' }, 1, deps)).ok, true);
|
|
421
|
+
assert.equal((await gw.setAnswer('scope', 'what_creating', { text: 'A ledger' }, 1, { store: deps.store, government: null })).ok, true);
|
|
422
|
+
// When the Board Room cannot be read, nothing is written.
|
|
423
|
+
const broken = { store: deps.store, government: { genesisStageStatus: async () => { throw new Error('down'); } } };
|
|
424
|
+
assert.equal((await gw.setAnswer('strategize', 'risks', { text: 'Money' }, 1, broken)).reason, 'stage_lock_unknown');
|
|
425
|
+
passed = [];
|
|
426
|
+
assert.equal((await gw.setPage('scope', 'Now open again', 1, deps)).ok, true);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
await test('task 1005107: a fixed stage answers stage_closed whatever was sent, and the board is read once per write', async () => {
|
|
430
|
+
let reads = 0;
|
|
431
|
+
const government = { genesisStageStatus: async () => { reads++; return { stages: [{ key: 'charter', state: 'passed' }] }; } };
|
|
432
|
+
const deps = { store: memStore(), government };
|
|
433
|
+
assert.equal((await gw.setPage('charter', 'x'.repeat(gw.LIMITS.page + 1), 1, deps)).reason, 'stage_closed', 'not page_too_long');
|
|
434
|
+
assert.equal((await gw.setAnswer('charter', 'no_such_question', { text: 'x' }, 1, deps)).reason, 'stage_closed', 'not unknown_question');
|
|
435
|
+
reads = 0;
|
|
436
|
+
assert.equal((await gw.setAnswer('strategize', 'risks', { text: 'Time' }, 1, deps)).ok, true);
|
|
437
|
+
assert.equal(reads, 1, 'the write and the read that answers it share one Board Room read');
|
|
438
|
+
});
|
|
439
|
+
|
|
402
440
|
console.log(`\ngenesis_work: ${passed} passed, ${failed} failed`);
|
|
403
441
|
if (failed) process.exit(1);
|
|
404
442
|
}
|
|
@@ -34,12 +34,15 @@ app.use(express.json());
|
|
|
34
34
|
const router = express.Router();
|
|
35
35
|
// The project's facts (task 1005129) live in project_settings: an in-memory one here.
|
|
36
36
|
const settings = new Map();
|
|
37
|
+
let passedStages = [];
|
|
37
38
|
mount(router, {
|
|
38
39
|
store,
|
|
39
40
|
getSetting: async (k) => (settings.has(k) ? { key: k, value: settings.get(k) } : null),
|
|
40
41
|
setSetting: async (k, v) => { settings.set(k, String(v)); },
|
|
41
42
|
factsMarker: null,
|
|
42
43
|
payGates: null,
|
|
44
|
+
// The Board Room (task 1005107): the stages listed in `passedStages` read as passed.
|
|
45
|
+
government: { genesisStageStatus: async () => { if (!passedStages) throw new Error('board down'); return { stages: passedStages.map((key) => ({ key, state: 'passed' })) }; } },
|
|
43
46
|
});
|
|
44
47
|
app.use('/api/bongos', router);
|
|
45
48
|
const server = app.listen(0);
|
|
@@ -100,6 +103,24 @@ try {
|
|
|
100
103
|
assert.match(r.data.markdown, /Where its builders are: global/);
|
|
101
104
|
});
|
|
102
105
|
|
|
106
|
+
await test('task 1005107: a write to a stage the Board Room passed is a 409 that says why', async () => {
|
|
107
|
+
passedStages = ['legislation'];
|
|
108
|
+
const r = await call('PUT', '/founding/work/legislation/page', { body: { page: 'x' } });
|
|
109
|
+
assert.equal(r.status, 409);
|
|
110
|
+
assert.equal(r.data.error.code, 'stage_closed');
|
|
111
|
+
assert.match(r.data.error.message, /Board Room passed the legislation stage/);
|
|
112
|
+
const d = await call('GET', '/founding/work/legislation/document', { as: OTHER });
|
|
113
|
+
assert.equal(d.data.document.editable, false);
|
|
114
|
+
passedStages = [];
|
|
115
|
+
assert.equal((await call('PUT', '/founding/work/legislation/page', { body: { page: 'x' } })).status, 200);
|
|
116
|
+
// When the Board Room cannot be read, the write is refused as a 503, never let through.
|
|
117
|
+
passedStages = null;
|
|
118
|
+
const down = await call('PUT', '/founding/work/legislation/page', { body: { page: 'y' } });
|
|
119
|
+
assert.equal(down.status, 503);
|
|
120
|
+
assert.equal(down.data.error.code, 'stage_lock_unknown');
|
|
121
|
+
passedStages = [];
|
|
122
|
+
});
|
|
123
|
+
|
|
103
124
|
await test('a builder who is not the owner cannot write', async () => {
|
|
104
125
|
for (const [m, p, body] of [
|
|
105
126
|
['PUT', '/founding/work/strategize/answers/risks', { text: 'x' }],
|