@bongos/core 1.25.23 → 1.25.25

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.
@@ -30,7 +30,8 @@
30
30
  stage_tasks_unfinished: 'This stage still has unfinished tasks.',
31
31
  not_current_stage: 'That stage is already behind you.',
32
32
  legislation_not_passed: 'Legislation has to pass the Board Room first.',
33
- not_deciding: 'The plan is already settled.',
33
+ stage_closed: 'The Board Room has passed that stage, so its answers are fixed.',
34
+ stage_lock_unknown: 'The Board Room could not be read, so nothing was changed. Try again in a moment.',
34
35
  };
35
36
 
36
37
  // What saving the plan did to the government, when it was not settled in the same
@@ -99,9 +100,17 @@
99
100
 
100
101
  // ── decide ────────────────────────────────────────────────────────────────
101
102
 
103
+ // Task 1005107: the same form REVISES a saved plan — it opens with the founder's own
104
+ // answers, and saving the same plan again changes nothing on the server.
102
105
  function decideHtml(g, gov) {
106
+ var p = g.plan || null;
107
+ if (p && p.government && gov) gov = Object.assign({}, gov, { recommended: { key: p.government } });
108
+ if (p && p.expected_builders && gov) gov = Object.assign({}, gov, { headcount: p.expected_builders });
103
109
  var rows = g.stages.map(function (s) {
104
- var opts = DAY_CHOICES.map(function (d) {
110
+ // A saved length that is not one of the presets is kept as its own choice, so
111
+ // reopening the plan never quietly changes it (task 1005107).
112
+ var choices = DAY_CHOICES.indexOf(s.days) < 0 ? DAY_CHOICES.concat([s.days]).sort(function (x, y) { return x - y; }) : DAY_CHOICES;
113
+ var opts = choices.map(function (d) {
105
114
  return '<option value="' + d + '"' + (d === s.days ? ' selected' : '') + '>' + d + (d === 1 ? ' day' : ' days') + '</option>';
106
115
  }).join('');
107
116
  return '<li class="genesis__plan-row">'
@@ -110,22 +119,25 @@
110
119
  + '<label class="genesis__plan-len"><span class="sr-only">Length of ' + esc(s.label) + '</span>'
111
120
  + '<select data-days="' + esc(s.key) + '">' + opts + '</select></label></li>';
112
121
  }).join('');
113
- return '<p class="scroll__lede">Settle the plan and each stage becomes real tasks. '
114
- + 'Everything is preset to our recommendation, so one click is enough.</p>'
122
+ return (p
123
+ ? '<p class="scroll__lede">Change any of your answers. Due dates follow the new lengths; a new government goes to the board like any charter change.</p>'
124
+ : '<p class="scroll__lede">Settle the plan and each stage becomes real tasks. '
125
+ + 'Everything is preset to our recommendation, so one click is enough.</p>')
115
126
  + '<ol class="genesis__plan">' + rows + '</ol>'
116
127
  + '<div class="genesis__picks">'
117
128
  + governmentHtml(gov)
118
129
  + '<fieldset class="genesis__pick"><legend>How many passes</legend>'
119
- + radio('passes', '1', 'One', 'Each stage is decided once.', true)
120
- + radio('passes', '2', 'Two', 'Once now, and again when your next builders join.', false)
130
+ + radio('passes', '1', 'One', 'Each stage is decided once.', !p || p.passes !== 2)
131
+ + radio('passes', '2', 'Two', 'Once now, and again when your next builders join.', !!p && p.passes === 2)
121
132
  + '</fieldset>'
122
133
  + '<fieldset class="genesis__pick"><legend>Advisors</legend>'
123
- + radio('advisors', 'none', 'None for now', 'You can add advisors at any stage.', true)
124
- + radio('advisors', 'cbaas', 'Cloud Bongos advisors', 'Our consultants review each stage.', false)
134
+ + radio('advisors', 'none', 'None for now', 'You can add advisors at any stage.', !p || p.advisors !== 'cbaas')
135
+ + radio('advisors', 'cbaas', 'Cloud Bongos advisors', 'Our consultants review each stage.', !!p && p.advisors === 'cbaas')
125
136
  + '</fieldset>'
126
- + '<label class="genesis__check"><input type="checkbox" data-existing> It already exists, so gather what it has first</label>'
137
+ + '<label class="genesis__check"><input type="checkbox" data-existing' + (p && p.existing ? ' checked' : '') + '> It already exists, so gather what it has first</label>'
127
138
  + '</div>'
128
- + '<div class="genesis__doors"><button type="button" class="btn-accent" data-genesis-plan>Use this plan</button></div>';
139
+ + '<div class="genesis__doors"><button type="button" class="btn-accent" data-genesis-plan>' + (p ? 'Save the plan' : 'Use this plan') + '</button>'
140
+ + (p ? '<button type="button" class="btn-ghost" data-genesis-plan-cancel>Keep it as it was</button>' : '') + '</div>';
129
141
  }
130
142
 
131
143
  function radio(name, value, label, hint, checked) {
@@ -157,6 +169,47 @@
157
169
  + '</fieldset>';
158
170
  }
159
171
 
172
+ // ── changing a finished stage's answers (task 1005107) ─────────────────────
173
+ // The stage's own record (GET /founding/work/:stage), opened in place with the answers
174
+ // already given. Save sends only what changed; nothing changed sends nothing.
175
+ function stageEditorHtml(w) {
176
+ if (!w) return '';
177
+ var rows = (w.questions || []).map(function (q) {
178
+ var cur = q.answer && q.answer.state === 'answered' ? (q.answer.text || '') : '';
179
+ var field = q.choices
180
+ ? '<select data-answer="' + esc(q.id) + '"><option value=""' + (cur ? '' : ' selected') + '>Not answered</option>'
181
+ + q.choices.map(function (c) { return '<option value="' + esc(c.key) + '"' + (c.key === cur ? ' selected' : '') + '>' + esc(c.label) + '</option>'; }).join('') + '</select>'
182
+ : '<textarea rows="3" data-answer="' + esc(q.id) + '">' + esc(cur) + '</textarea>';
183
+ return '<label class="genesis__edit-q"><b>' + esc(q.ask) + '</b>' + field + '</label>';
184
+ }).join('');
185
+ return '<section class="genesis__edit" aria-label="Change the ' + esc(w.stage) + ' answers">'
186
+ + '<h3 class="genesis__now-h">Change the ' + esc(w.stage) + ' answers</h3>'
187
+ + '<p class="genesis__note">Changing them does not reopen the stage.</p>' + rows
188
+ + '<label class="genesis__edit-q"><b>In your own words</b><textarea rows="5" data-page>' + esc(w.page || '') + '</textarea></label>'
189
+ + '<div class="genesis__doors"><button type="button" class="btn-accent" data-genesis-stage-save>Save changes</button>'
190
+ + '<button type="button" class="btn-ghost" data-genesis-stage-cancel>Leave it as it was</button></div></section>';
191
+ }
192
+
193
+ // stageChanges(w, host) → the writes the edited form needs, only for what changed.
194
+ function stageChanges(w, host) {
195
+ var out = [];
196
+ (w.questions || []).forEach(function (q) {
197
+ var el = host.querySelector('[data-answer="' + q.id + '"]');
198
+ if (!el) return;
199
+ var was = q.answer && q.answer.state === 'answered' ? (q.answer.text || '') : '';
200
+ var now = String(el.value || '').trim();
201
+ if (now === was.trim()) return;
202
+ var path = '/founding/work/' + encodeURIComponent(w.stage) + '/answers/' + encodeURIComponent(q.id);
203
+ if (now) out.push(['PUT', path, { state: 'answered', text: now }]);
204
+ else if (q.answer) out.push(['DELETE', path, null]);
205
+ });
206
+ var page = host.querySelector('[data-page]');
207
+ if (page && String(page.value || '').trim() !== String(w.page || '').trim()) {
208
+ out.push(['PUT', '/founding/work/' + encodeURIComponent(w.stage) + '/page', { page: String(page.value || '') }]);
209
+ }
210
+ return out;
211
+ }
212
+
160
213
  // ── the stages ────────────────────────────────────────────────────────────
161
214
 
162
215
  function lifeHtml(g) {
@@ -207,9 +260,15 @@
207
260
  // buildings it raises, and for a stage that wakes no room, what it earns.
208
261
  + '<span>' + esc(gainsText(s)) + esc(due) + '</span>'
209
262
  + (s.state === 'later' && s.can_close ? '<span><button type="button" class="btn-ghost" data-genesis-close="' + esc(s.key) + '">Put to the board</button></span>' : '')
263
+ // Task 1005107: a finished stage stays editable unless the Board Room passed it.
264
+ + (s.state === 'done' ? (s.locked
265
+ ? '<span class="genesis__stage-fixed">' + esc('Fixed: ' + s.locked.reason) + '</span>'
266
+ : '<span><button type="button" class="btn-ghost" data-genesis-stage-edit="' + esc(s.key) + '">Change its answers</button></span>') : '')
210
267
  + '</span>'
211
268
  + '<span class="genesis__stage-st">' + esc(stateText(s)) + '</span></li>';
212
- }).join('') + '</ol>';
269
+ }).join('') + '</ol>'
270
+ // Task 1005107: the plan is never a trap — reopen it with the answers already given.
271
+ + (g.plan_editable && !g.demo ? '<div class="genesis__doors"><button type="button" class="btn-ghost" data-genesis-plan-edit>Change the plan</button></div>' : '');
213
272
  }
214
273
 
215
274
  // task 1005109: Copy prompt on each task still to do, so the founder pastes it into
@@ -382,9 +441,10 @@
382
441
  function render(host, state) {
383
442
  var g = state.genesis;
384
443
  if (!g) { host.hidden = true; return; }
385
- host.innerHTML = headHtml(g) + (g.deciding
386
- ? decideHtml(g, state.gov)
387
- : demoHtml(g) + lifeHtml(g) + votesHtml(g, state.viewer) + '<div class="genesis__grid">' + stagesHtml(g) + nowHtml(g, state.viewer) + '</div>');
444
+ host.innerHTML = headHtml(g) + (g.deciding || state.editingPlan
445
+ ? decideHtml(g.deciding ? Object.assign({}, g, { plan: null }) : g, state.gov)
446
+ : demoHtml(g) + lifeHtml(g) + votesHtml(g, state.viewer) + '<div class="genesis__grid">' + stagesHtml(g) + nowHtml(g, state.viewer) + '</div>'
447
+ + stageEditorHtml(state.stageWork));
388
448
  host.hidden = false;
389
449
  }
390
450
 
@@ -449,22 +509,44 @@
449
509
  if (!t || !host.contains(t)) return;
450
510
  if (t.hasAttribute('data-genesis-copy')) {
451
511
  copyPrompt(t, state);
512
+ } else if (t.hasAttribute('data-genesis-stage-edit')) {
513
+ act(send('GET', '/founding/work/' + encodeURIComponent(t.getAttribute('data-genesis-stage-edit'))).then(function (d) {
514
+ state.stageWork = d && d.work; render(host, state);
515
+ }));
516
+ } else if (t.hasAttribute('data-genesis-stage-cancel')) {
517
+ state.stageWork = null;
518
+ render(host, state);
519
+ } else if (t.hasAttribute('data-genesis-stage-save')) {
520
+ var writes = stageChanges(state.stageWork, host);
521
+ if (!writes.length) { state.stageWork = null; render(host, state); toast('Nothing changed.'); return; }
522
+ act(writes.reduce(function (p, w) { return p.then(function () { return send(w[0], w[1], w[2]); }); }, Promise.resolve())
523
+ .then(function () { state.stageWork = null; render(host, state); toast('Saved.'); }));
524
+ } else if (t.hasAttribute('data-genesis-plan-edit')) {
525
+ var exp = state.genesis && state.genesis.plan && state.genesis.plan.expected_builders;
526
+ act(loadGov(exp || null).then(function (gov) { state.gov = gov; state.editingPlan = true; render(host, state); }));
527
+ } else if (t.hasAttribute('data-genesis-plan-cancel')) {
528
+ state.editingPlan = false;
529
+ render(host, state);
452
530
  } else if (t.hasAttribute('data-genesis-plan')) {
453
531
  // The government picked here is filed as a charter amendment with the plan
454
532
  // (task 1004656). When the founder's one yes decides it, saving the plan is
455
533
  // that yes too, the same one-click fast-track the stage closes take.
456
534
  act(send('POST', '/founding/plan', planBody(host)).then(function (out) {
535
+ // A revision (task 1005107): say if nothing changed, and every change it could not carry.
536
+ if (out && out.revised === false) { state.editingPlan = false; render(host, state); toast('Nothing changed.'); return 'stay'; }
537
+ var held = !!(out && out.could_not && out.could_not.length);
538
+ if (held) toast(out.could_not.join(' '));
457
539
  var c = out && out.charter;
458
540
  if (c && c.state === 'sitting' && c.one_vote_decides && c.item_id) {
459
541
  return send('POST', '/government/board/items/' + encodeURIComponent(c.item_id) + '/votes', { direction: 'yes' })
460
542
  .catch(function () { return null; });
461
543
  }
462
544
  var said = charterNote(c);
463
- if (!said) return null;
545
+ if (!said) return held ? window.OTB.sleep(3500) : null;
464
546
  toast(said);
465
547
  // Give the sentence time to be read before the reload takes it away.
466
548
  return window.OTB.sleep(3500);
467
- }).then(function () { window.location.reload(); }));
549
+ }).then(function (r) { if (r !== 'stay') window.location.reload(); }));
468
550
  } else if (t.hasAttribute('data-genesis-close')) {
469
551
  // One call (task 1005121): where the founder's one yes decides, the server
470
552
  // closes the stage with it; otherwise it answers 202 with what the sitting
@@ -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
- try { documents = (await s.documents()) || {}; } catch (_) { documents = {}; } // the home still renders without them
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. Only while deciding.
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 { ok: false, reason: 'not_deciding' };
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. Task 1005107 narrows this for a stage the Board
301
- // Room passed; until then every document is open to its author.
302
- editable: true,
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, deps = {}) {
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, deps = {}) {
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, deps = {}, opts = {}) {
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, deps = {}) {
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);
@@ -1558,6 +1558,12 @@ summary{min-height:24px;padding:3px 0;}
1558
1558
  <svg class="gh" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
1559
1559
  Continue with GitHub
1560
1560
  </a>
1561
+ <!-- No GitHub account yet (task 1005283): the same sign-in, opened on GitHub's
1562
+ sign-up page (signup=1), which offers "Continue with Google" and hands the
1563
+ sign-in straight back here once the account exists. Bongos cannot make a
1564
+ GitHub account for anyone; GitHub can, in one step, with Google. -->
1565
+ <p class="finePrint" id="wizWhyGh">Your project’s files live in a GitHub repository, so it needs a GitHub account.</p>
1566
+ <p class="finePrint" id="wizNoGh">No GitHub account? <a id="wizSignup" href="/api/bongos/auth/web/start?via=github&amp;signup=1&amp;return=/projects%3Fview%3Dnew&amp;repos=1">Make one free with Google</a>. On GitHub’s page choose <strong>Continue with Google</strong>, and you come straight back here.</p>
1561
1567
  <!-- Informed consent BEFORE GitHub's own wording (ADR 0155): the scope the
1562
1568
  button asks for reads as "all your repositories" on GitHub's screen, so
1563
1569
  the reason is stated here, on the path every founder actually takes. -->
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.25.23",
3
+ "version": "1.25.25",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.25.23",
9
+ "version": "1.25.25",
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.23",
3
+ "version": "1.25.25",
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",
@@ -11906,5 +11906,17 @@
11906
11906
  "id": "1005285",
11907
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
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
+ }
11915
+ ],
11916
+ "1.25.25": [
11917
+ {
11918
+ "id": "1005283",
11919
+ "text": "Someone with no GitHub account can now start a project. The creation page explains why GitHub is needed and offers a 'Make one free with Google' link. GitHub's own sign-up page opens, they continue with their Google account, a"
11920
+ }
11909
11921
  ]
11910
11922
  }
@@ -291,6 +291,22 @@ function buildAuthorizeUrl(state, redirectUri, { repos = false, switchAccount =
291
291
  return u.toString();
292
292
  }
293
293
 
294
+ // GitHub's own sign-up page, for a person with no GitHub account (task 1005283). It is the page GitHub's sign-in screen links as "Create an account"
295
+ // during an OAuth sign-in: `return_to` carries the authorize step back, so once the
296
+ // account exists (GitHub's sign-up offers "Continue with Google") GitHub resumes the
297
+ // SAME sign-in — same state, same scope — and our callback lands the person where they
298
+ // started. Only github.com's own authorize path is ever handed on; anything else is null
299
+ // and the caller falls back to the plain authorize redirect.
300
+ function githubSignupUrl(authorizeUrl) {
301
+ let u;
302
+ try { u = new URL(String(authorizeUrl)); } catch { return null; }
303
+ if (u.protocol !== 'https:' || u.host !== 'github.com' || u.pathname !== '/login/oauth/authorize') return null;
304
+ const signup = new URL('https://github.com/signup');
305
+ signup.searchParams.set('return_to', u.pathname + u.search);
306
+ signup.searchParams.set('source', 'oauth');
307
+ return signup.toString();
308
+ }
309
+
294
310
  async function completeWebFlow({ code, redirectUri, userAgent, captureRepoToken = false }) {
295
311
  const { accessToken, scope: grantedScope } = await exchangeCodeForToken(code, redirectUri);
296
312
  const ghUser = await fetchGithubUser(accessToken);
@@ -386,6 +402,7 @@ async function completeWebFlow({ code, redirectUri, userAgent, captureRepoToken
386
402
  // slashes on origin are trimmed so `https://hub/` and `https://hub` behave alike.
387
403
 
388
404
  module.exports = {
405
+ githubSignupUrl,
389
406
  buildAuthorizeUrl,
390
407
  completeWebFlow,
391
408
  exchangeCodeForToken,
@@ -58,7 +58,7 @@ const { branding, userAgent } = require('../branding');
58
58
 
59
59
  const { SCOPE, authConfigured, grantedScopeCoversRepos, idpConfigured, idpEnabled, loadIdpConfig, scopeForWebFlow } = require('./auth-config.js');
60
60
  const { firstAdminLogin, firstTimeAdmissionBlocked, instanceNeedsFirstAdmin, joinabilityMode, maybeSeatFirstAdmin, membershipKind, notifyHubOfInvite, openEnrollmentEnabled, readAppStatusFromHub, readApplicantProfileFromHub, readApplicantViewFromHub, webAdmissionStatus } = require('./auth-admission.js');
61
- const { buildAuthorizeUrl, completeWebFlow, pollDeviceFlow, startDeviceFlow } = require('./auth-github.js');
61
+ const { buildAuthorizeUrl, githubSignupUrl, completeWebFlow, pollDeviceFlow, startDeviceFlow } = require('./auth-github.js');
62
62
  const { buildIdpAuthorizeUrl, completeDeviceExchange, completeIdpFlow, idpAuthorizeUrl, loadHubPublicKey, logoutTokenJtiSeenAndRecord, readApplicationVouch, verifyHubAssertion, verifyHubLogoutToken } = require('./auth-idp.js');
63
63
  const { addDiscordGuildMember, buildDiscordAuthorizeUrl, discordAuthorizeUrl, discordBotConfigured, discordConfigured, exchangeDiscordCode, fetchDiscordUser, isValidDiscordUserId, loadDiscordBotToken, loadDiscordCreds, removeDiscordGuildMember } = require('./auth-discord.js');
64
64
 
@@ -854,6 +854,7 @@ module.exports = {
854
854
  startDeviceFlow,
855
855
  pollDeviceFlow,
856
856
  buildAuthorizeUrl,
857
+ githubSignupUrl,
857
858
  scopeForWebFlow,
858
859
  grantedScopeCoversRepos,
859
860
  buildIdpAuthorizeUrl,
@@ -710,6 +710,7 @@ module.exports = function buildAuthRouter() {
710
710
  // ?switch=1 — sign in as a DIFFERENT GitHub account (task 1004789): show
711
711
  // GitHub's account picker instead of re-using the one logged in there.
712
712
  const wantsSwitch = req.query.switch === '1' || req.query.switch === 'true';
713
+ const wantsSignup = req.query.signup === '1' || req.query.signup === 'true'; // no GitHub account yet: its sign-up page (task 1005283)
713
714
  // A sign-in begun on a satellite hub host (builders./status.) restarts on
714
715
  // the canonical host (task 1004357). The handshake cookies are host-only now,
715
716
  // and the callback always lands on the canonical host, so a state cookie set
@@ -720,8 +721,7 @@ module.exports = function buildAuthRouter() {
720
721
  const raw = safeReturnTo(req.query.return) || safeReturnTo(req.headers.referer) || '/';
721
722
  const back = raw.startsWith('/') ? `https://${host}${raw}` : raw;
722
723
  const params = new URLSearchParams({ return: back });
723
- if (wantsRepos) params.set('repos', '1');
724
- if (wantsSwitch) params.set('switch', '1');
724
+ for (const [k, on] of [['repos', wantsRepos], ['switch', wantsSwitch], ['signup', wantsSignup]]) if (on) params.set(k, '1');
725
725
  if (req.query.via === 'github') params.set('via', 'github');
726
726
  return res.redirect(`${WEB_REDIRECT_ORIGIN.replace(/\/+$/, '')}${apiBase(req)}/auth/web/start?${params}`);
727
727
  }
@@ -729,7 +729,7 @@ module.exports = function buildAuthRouter() {
729
729
  // host (a builder who signed in on the apex, then opened builders.): no
730
730
  // GitHub round-trip, just hand that host its copy. A repo-scope sign-in
731
731
  // still goes to GitHub — it exists to ask for the broader grant.
732
- if (!wantsRepos && !wantsSwitch) {
732
+ if (!wantsRepos && !wantsSwitch && !wantsSignup) {
733
733
  const target = safeReturnTo(req.query.return);
734
734
  if (target && !target.startsWith('/')) {
735
735
  try {
@@ -746,7 +746,7 @@ module.exports = function buildAuthRouter() {
746
746
  // lands on one chooser — Continue with GitHub or Continue with Google — so no
747
747
  // page needs its own pair of buttons. `via=github` is the GitHub button (and
748
748
  // any door that needs GitHub itself); "New account" names a GitHub account.
749
- if (req.query.via !== 'github' && !wantsSwitch && googleConfigured()) {
749
+ if (req.query.via !== 'github' && !wantsSwitch && !wantsSignup && googleConfigured()) {
750
750
  const ret = safeReturnTo(req.query.return) || safeReturnTo(req.headers.referer);
751
751
  const gh = new URLSearchParams({ via: 'github' });
752
752
  if (ret) gh.set('return', ret);
@@ -803,9 +803,9 @@ module.exports = function buildAuthRouter() {
803
803
  // Federate through the hub when configured (ADR 0141 §1); otherwise the direct
804
804
  // GitHub authorize, carrying the repo-scope opt-in. Same CSRF state cookie either
805
805
  // way — the callback validates it identically, so the hub leg is CSRF-protected too.
806
- res.redirect(auth.idpConfigured()
807
- ? auth.buildIdpAuthorizeUrl(state, redirectUri)
808
- : auth.buildAuthorizeUrl(state, redirectUri, { repos: wantsRepos, switchAccount: wantsSwitch }));
806
+ if (auth.idpConfigured()) return res.redirect(auth.buildIdpAuthorizeUrl(state, redirectUri));
807
+ const authorizeUrl = auth.buildAuthorizeUrl(state, redirectUri, { repos: wantsRepos, switchAccount: wantsSwitch });
808
+ res.redirect((wantsSignup && auth.githubSignupUrl(authorizeUrl)) || authorizeUrl);
809
809
  });
810
810
 
811
811
  // GET /auth/web/handoff?code=&return= — the far side of the one-time session
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.23'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.25.25'; // 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');