@bongos/core 1.25.19 → 1.25.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/.bongos-core.json +237 -112
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +12 -0
  4. package/clients/bongos-client/index.cjs +12 -0
  5. package/clients/bongos-client/index.d.ts +26 -9
  6. package/clients/bongos-client/index.mjs +12 -0
  7. package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +10 -0
  8. package/docs/adr/0407-a-project-grant-is-made-by-the-owner-not-witnessed-at-sign-in.md +3 -1
  9. package/docs/api/openapi.json +444 -37
  10. package/docs/api-reference.md +11 -5
  11. package/docs/copy-inventory.md +223 -221
  12. package/docs/copy-registry.json +262 -244
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/page-readings.json +348 -347
  15. package/docs/project-context.template.md +1 -1
  16. package/modules/front-door-guide/handoff.js +120 -0
  17. package/modules/front-door-guide/module.json +1 -1
  18. package/modules/front-door-guide/routes/front-door-guide-plan.js +24 -0
  19. package/modules/government/board.js +8 -7
  20. package/modules/government/charter-advice.js +10 -3
  21. package/modules/government/charter.js +88 -4
  22. package/modules/government/migrations/government_039_vote_cast_via_filed.sql +22 -0
  23. package/modules/government/routes/government.js +12 -12
  24. package/modules/government/sole-member.js +75 -0
  25. package/modules/hall-ui/public/ai-studio.html +1 -1
  26. package/modules/hall-ui/public/atlas.html +1 -1
  27. package/modules/hall-ui/public/blockers.html +1 -1
  28. package/modules/hall-ui/public/board-room.html +1 -1
  29. package/modules/hall-ui/public/city.html +1 -1
  30. package/modules/hall-ui/public/collab.html +1 -1
  31. package/modules/hall-ui/public/commands.html +1 -1
  32. package/modules/hall-ui/public/copy-desk.html +1 -1
  33. package/modules/hall-ui/public/deploy.html +1 -1
  34. package/modules/hall-ui/public/genesis-home.js +35 -9
  35. package/modules/hall-ui/public/goal-map.html +1 -1
  36. package/modules/hall-ui/public/goals.html +1 -1
  37. package/modules/hall-ui/public/government.html +1 -1
  38. package/modules/hall-ui/public/home-city-draw.js +14 -1
  39. package/modules/hall-ui/public/idea.html +1 -1
  40. package/modules/hall-ui/public/ideas.html +1 -1
  41. package/modules/hall-ui/public/index.html +1 -1
  42. package/modules/hall-ui/public/market-watch-item.html +1 -1
  43. package/modules/hall-ui/public/market-watch-rules.html +1 -1
  44. package/modules/hall-ui/public/market-watch.html +1 -1
  45. package/modules/hall-ui/public/modules.html +1 -1
  46. package/modules/hall-ui/public/money.html +1 -1
  47. package/modules/hall-ui/public/my-agents.html +1 -1
  48. package/modules/hall-ui/public/primer.html +1 -1
  49. package/modules/hall-ui/public/profile.html +1 -1
  50. package/modules/hall-ui/public/project-settings.html +1 -1
  51. package/modules/hall-ui/public/roster.html +1 -1
  52. package/modules/hall-ui/public/settings.html +1 -1
  53. package/modules/hall-ui/public/shell.js +32 -20
  54. package/modules/hall-ui/public/street.html +1 -1
  55. package/modules/hall-ui/public/studio.html +1 -1
  56. package/modules/hall-ui/public/style.css +9 -4
  57. package/modules/hall-ui/public/task.html +1 -1
  58. package/modules/hall-ui/public/thinking.html +1 -1
  59. package/modules/hall-ui/public/tweak-editor.html +1 -1
  60. package/modules/hall-ui/public/watch.html +1 -1
  61. package/modules/hall-ui/public/work.html +1 -1
  62. package/modules/lifecycle/home-city.js +17 -3
  63. package/modules/onboarding/founding-mode.js +45 -1
  64. package/modules/onboarding/genesis-home.js +16 -2
  65. package/modules/onboarding/guide-handoff.js +109 -0
  66. package/modules/onboarding/routes/genesis-work.js +9 -5
  67. package/modules/platform-identity/module.json +1 -1
  68. package/modules/platform-identity/project-standing.js +49 -0
  69. package/modules/platform-identity/routes/my-projects.js +8 -0
  70. package/modules/provisioning/grants.js +126 -0
  71. package/modules/provisioning/guide-handoff.js +65 -0
  72. package/modules/provisioning/migrations/provisioning_038_render_promote.sql +43 -0
  73. package/modules/provisioning/migrations/provisioning_039_project_grants.sql +40 -0
  74. package/modules/provisioning/module.json +2 -1
  75. package/modules/provisioning/project-refusal.js +107 -0
  76. package/modules/provisioning/provisioning.js +4 -4
  77. package/modules/provisioning/render-lookup.js +36 -1
  78. package/modules/provisioning/render-promote.js +80 -0
  79. package/modules/provisioning/route-helpers.js +70 -16
  80. package/modules/provisioning/routes/env-manifest.js +21 -5
  81. package/modules/provisioning/routes/grants.js +141 -0
  82. package/modules/provisioning/routes/provisioning.js +12 -9
  83. package/modules/provisioning/routes/render-standup.js +82 -3
  84. package/modules/public-landing/public/projects.html +178 -1
  85. package/package-lock.json +2 -2
  86. package/package.json +1 -1
  87. package/release-notes.json +28 -0
  88. package/scripts/bongos/genesis.js +11 -1
  89. package/scripts/bongos/government-walk-sole.js +41 -0
  90. package/scripts/bongos/government-walk.js +15 -16
  91. package/scripts/bongos/provision-render-common.js +48 -0
  92. package/scripts/bongos/provision-render-promote.js +93 -0
  93. package/scripts/bongos/provision-render-sandbox.js +14 -2
  94. package/scripts/bongos/provision-render.js +4 -36
  95. package/scripts/bongos/provision.js +1 -0
  96. package/scripts/bongos/render-api.js +17 -2
  97. package/scripts/gds/government-walk-sole.js +6 -0
  98. package/scripts/gds/provision-render-common.js +6 -0
  99. package/scripts/gds/provision-render-promote.js +6 -0
  100. package/src/bongos/founding-page-gate.js +67 -0
  101. package/src/bongos/serve-internal.js +3 -0
  102. package/src/branding.js +4 -0
  103. package/src/module-api.js +1 -1
  104. package/tests/fleet_cost_view.mjs +2 -0
  105. package/tests/founding_mode.mjs +84 -8
  106. package/tests/founding_page_gate.mjs +105 -0
  107. package/tests/front_door_guide_existing.mjs +1 -0
  108. package/tests/genesis_government.mjs +5 -1
  109. package/tests/genesis_home.mjs +41 -0
  110. package/tests/government_charter_advice.mjs +49 -0
  111. package/tests/government_charter_forms.mjs +14 -3
  112. package/tests/government_seed.mjs +1 -0
  113. package/tests/government_sole_member.mjs +153 -0
  114. package/tests/guide_handoff.mjs +322 -0
  115. package/tests/home_city.mjs +52 -0
  116. package/tests/nav_permission_atoms.mjs +1 -1
  117. package/tests/project_catalog_routes.mjs +2 -0
  118. package/tests/provision_settings_apply.mjs +2 -1
  119. package/tests/provisioning_env_manifest.mjs +4 -0
  120. package/tests/provisioning_env_manifest_edit_delete.mjs +2 -0
  121. package/tests/provisioning_project_grants.mjs +458 -0
  122. package/tests/provisioning_project_owner_gate.mjs +3 -1
  123. package/tests/provisioning_project_refusal.mjs +316 -0
  124. package/tests/provisioning_render_route.mjs +1 -1
  125. package/tests/provisioning_restart.mjs +2 -0
  126. package/tests/provisioning_settings.mjs +2 -0
  127. package/tests/provisioning_settings_apply.mjs +1 -1
  128. package/tests/provisioning_settings_env.mjs +2 -2
  129. package/tests/provisioning_teardown_intent.mjs +2 -2
  130. package/tests/render_promote.mjs +309 -0
  131. package/tests/render_sandbox.mjs +9 -4
@@ -370,14 +370,28 @@ function shapeCity(raw, viewer) {
370
370
  };
371
371
  }
372
372
 
373
+ // readFoundingDistrict() — while the project is founding, which government-district
374
+ // buildings are not raised yet (task 1005112): the onboarding port's own answer, the one
375
+ // /me carries. null when not founding, the module is off, or the read fails, which
376
+ // draws the district whole, exactly as before.
377
+ async function readFoundingDistrict(resolve = api.resolveOptional) {
378
+ try {
379
+ const onboarding = resolve('onboarding');
380
+ const f = onboarding && typeof onboarding.foundingModeFor === 'function' ? await onboarding.foundingModeFor() : null;
381
+ return f && f.active === true && f.unbuilt && typeof f.unbuilt === 'object' ? { stage: f.stage, unbuilt: f.unbuilt } : null;
382
+ } catch (_) {
383
+ return null;
384
+ }
385
+ }
386
+
373
387
  // The GET /home/city handler, injectable so a test drives it with no express.
374
- function homeCityHandler({ log, read = readCity } = {}) {
388
+ function homeCityHandler({ log, read = readCity, founding = readFoundingDistrict } = {}) {
375
389
  return async function homeCityRoute(req, res) {
376
390
  try {
377
391
  const viewer = req.builder ? { id: req.builder.id, rank: req.builder.rank } : null;
378
- const raw = await read({ weeks: req.query && req.query.weeks, viewer });
392
+ const [raw, district] = await Promise.all([read({ weeks: req.query && req.query.weeks, viewer }), founding()]);
379
393
  res.set('Cache-Control', 'private, max-age=60');
380
- res.json(shapeCity(raw, viewer));
394
+ res.json({ ...shapeCity(raw, viewer), founding: district });
381
395
  } catch (err) {
382
396
  if (log) log.error('GET /home/city', err);
383
397
  res.fail('home_city_failed', { status: 500, message: 'internal error' });
@@ -50,6 +50,48 @@ const WAKES = Object.freeze({
50
50
  });
51
51
  const WAKES_WHEN_ALIVE = Object.freeze(['collab', 'roster']);
52
52
 
53
+ // THE GOVERNMENT DISTRICT RISES WITH GENESIS (task 1005112; V3 spec, task-cleanup
54
+ // answer 1: "Genesis is the founding of the government district"). Home's city draws
55
+ // the district as nine civic buildings (city-draw.js CIVIC_SLOTS, keyed as below). While
56
+ // a project is founding, a building stands only once the stage that raises it is behind;
57
+ // until then its plot is a marked-out foundation. Same rule and same shape as WAKES, so
58
+ // the server decides and every presentation (the city, the genesis card) reads it.
59
+ // Every stage raises something, so no stage ends the sense of progress; automation, the
60
+ // project running itself, rises when the project comes alive.
61
+ const BUILDS = Object.freeze({
62
+ scope: Object.freeze(['planning']),
63
+ strategize: Object.freeze(['release']),
64
+ charter: Object.freeze(['charter', 'people']),
65
+ legalize: Object.freeze(['treasury']),
66
+ legislation: Object.freeze(['rules', 'review', 'disputes']),
67
+ });
68
+ const BUILDS_WHEN_ALIVE = Object.freeze(['automation']);
69
+
70
+ // unbuiltBuildings(stage) — PURE. { buildingKey: raisingStage } for every district
71
+ // building not yet raised at this stage (the asleepRooms rule), or null for no stage.
72
+ function unbuiltBuildings(stage) {
73
+ const at = STAGES.indexOf(stage);
74
+ if (at < 0) return null;
75
+ const out = {};
76
+ for (const [raising, keys] of Object.entries(BUILDS)) {
77
+ if (STAGES.indexOf(raising) >= at) for (const k of keys) out[k] = raising;
78
+ }
79
+ for (const k of BUILDS_WHEN_ALIVE) out[k] = 'alive';
80
+ return out;
81
+ }
82
+
83
+ // What finishing a stage that opens no room gives the founder, in a sentence (task
84
+ // 1005112). Three stages wake no sidebar room since tasks 1005046 and 1005199, and a
85
+ // founder must never be told nothing happens: this is said on the stage's row instead.
86
+ // Each line is true today: the charter puts the government in force, legalize settles
87
+ // how contributors are rewarded, and legislation's pass is what brings the project to
88
+ // life, which opens every room. A stage that wakes rooms says those instead.
89
+ const EARNS = Object.freeze({
90
+ charter: 'Puts your government in force',
91
+ legalize: 'Settles how contributors are rewarded',
92
+ legislation: 'Brings the project to life: every room opens',
93
+ });
94
+
53
95
  // The project_settings keys. Namespaced 'founding.' like 'mingle.' and 'attention.'.
54
96
  const KEYS = Object.freeze({
55
97
  ACTIVE: 'founding.active',
@@ -118,6 +160,8 @@ function shapeFoundingMode(rows, now = new Date()) {
118
160
  stage_count: STAGES.length - 1,
119
161
  day: dayOf(rows.startedAt, now),
120
162
  asleep,
163
+ // The government district's buildings not yet raised (task 1005112), keyed like CIVIC_SLOTS.
164
+ unbuilt: unbuiltBuildings(stage),
121
165
  // Who closing the current stage is waiting on (spec D7) — display names, at most
122
166
  // five, from the government module's genesis-stage sitting (task 1004429). null
123
167
  // when the stage is not sitting, nobody is left to vote, or the read is missing.
@@ -226,5 +270,5 @@ async function setFoundingMode({ active, stage } = {}, builderId = null, deps =
226
270
  module.exports = {
227
271
  STAGES, WAKES, WAKES_WHEN_ALIVE, KEYS, stageAtKey,
228
272
  dayOf, shapeFoundingMode, waitingOnFromGovernment,
229
- foundingModeFor, setFoundingMode,
273
+ foundingModeFor, setFoundingMode, EARNS, BUILDS, BUILDS_WHEN_ALIVE,
230
274
  };
@@ -194,6 +194,8 @@ function shapeGenesis({ mode, plan, tasks = {}, board = {}, stageAt = {}, boardR
194
194
  plate: info.plate,
195
195
  done_when: info.done_when,
196
196
  wakes: fm.WAKES[key] || [],
197
+ earns: (fm.WAKES[key] || []).length ? null : (fm.EARNS[key] || null), // task 1005112
198
+ raises: fm.BUILDS[key] || [], // task 1005112: the district buildings this stage raises
197
199
  days,
198
200
  minutes,
199
201
  due,
@@ -393,7 +395,10 @@ async function savePlan(body, builderId, deps = {}) {
393
395
  // Never refuses the plan — the plan is saved first, and a choice that cannot be filed
394
396
  // says why in `state`:
395
397
  // none no government was picked
396
- // in_force the constitution already reads as that form (Monarchy at birth)
398
+ // in_force the constitution already reads as that form (Monarchy at birth):
399
+ // nothing is filed, `message` says so and `complete` is true
400
+ // adopted filed AND decided in the same call: the founder is the board's only
401
+ // member under first_ratifier (task 1005130), so it is in force now
397
402
  // sitting filed; `item_id`, and `one_vote_decides` when one yes carries it
398
403
  // needs_council Foundation, which needs a council rank before it can seat anyone
399
404
  // not_filed anything else (`reason`), or no government module to file it with
@@ -409,16 +414,25 @@ async function applyGovernmentChoice(form, builderId, s) {
409
414
  }
410
415
  if (out && out.ok) {
411
416
  const snap = (out.item && out.item.constitution) || {};
417
+ // task 1005130: the founder is the board's only member, so choosing it WAS their
418
+ // one yes — it is in force now, and saying "in the Board Room" would be untrue.
419
+ if (out.decided && out.decided.closed && out.decided.outcome === 'passed') {
420
+ const name = out.reads_as ? out.reads_as.name : form;
421
+ return { state: 'adopted', form, item_id: out.item ? String(out.item.id) : null, complete: true,
422
+ message: `${name} is in force now. As the board's only member, choosing it was your one yes.` };
423
+ }
412
424
  return {
413
425
  state: 'sitting',
414
426
  form,
415
427
  item_id: out.item ? String(out.item.id) : null,
416
428
  one_vote_decides: snap.pass_rule === 'first_ratifier',
417
429
  reads_as: out.reads_as ? out.reads_as.name : null,
430
+ message: out.what_happens_next || null, // task 1005130: what happens next, in words
418
431
  };
419
432
  }
420
433
  const reason = (out && out.reason) || 'charter_failed';
421
- if (reason === 'no_change') return { state: 'in_force', form };
434
+ // task 1005130: already in force is said plainly and counts as done, never silent.
435
+ if (reason === 'no_change') return { state: 'in_force', form, complete: true, message: out.message || null };
422
436
  if (reason === 'council_needs_a_rank') return { state: 'needs_council', form };
423
437
  return { state: 'not_filed', form, reason };
424
438
  }
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+
3
+ // modules/onboarding/guide-handoff.js — THE GUIDE'S ANSWERS, ARRIVED (task 1005274, goal
4
+ // 1000106). The founder talked with the start-a-project guide before signing up; the hub
5
+ // stamped what came out of it on the new project (modules/provisioning/guide-handoff.js),
6
+ // and it reached this instance as one web.env line, mapped by src/branding.js onto the
7
+ // server-only founding.guideHandoff (base64url JSON { scope, markdown }).
8
+ //
9
+ // Here it lands, once, in the two places a later reader already looks:
10
+ // * The Genesis scope interview: each carried answer is written as ORIGIN carried_over
11
+ // through genesis-work's own if-empty write, so it never replaces anything the founder
12
+ // already typed, and the interview shows it as carried over and editable (any edit by
13
+ // the founder makes it theirs). Only the questions the hub's explicit map answered are
14
+ // written; the rest stay empty.
15
+ // * The hand-off file: kept in project_settings (HANDOFF_KEY) and served at the top of
16
+ // GET /founding/context, the read `npx bongos genesis context` prints and the project
17
+ // context scaffold (docs/project-context.template.md) tells every session to run first.
18
+ // So the founder's own AI meets it in its first session without being told where.
19
+ //
20
+ // WHEN: lazily, on the first genesis read (routes/genesis-work.js), not at boot — the
21
+ // founding-birth.js reasoning: by then the database is up and the founder is seated. NOT
22
+ // tied to genesis being switched on: a paused project still keeps its founder's words.
23
+ // ONCE: the hand-off file's presence is the done-mark, written LAST, so a run that fails
24
+ // half-way is retried and the if-empty writes make the retry harmless. No marker (a project
25
+ // whose founder never used the guide, or one made before this shipped): resolves without
26
+ // touching the database.
27
+
28
+ const HANDOFF_KEY = 'founding.guide_handoff';
29
+
30
+ // handoffMarker(founding) — PURE. founding.guideHandoff → { scope, markdown } or null.
31
+ function handoffMarker(founding) {
32
+ const raw = founding && typeof founding === 'object' ? founding.guideHandoff : null;
33
+ if (typeof raw !== 'string' || !raw.trim()) return null;
34
+ try {
35
+ const h = JSON.parse(Buffer.from(raw.trim(), 'base64url').toString('utf8'));
36
+ if (!h || typeof h.markdown !== 'string' || !h.markdown.trim()) return null;
37
+ const scope = h.scope && typeof h.scope === 'object' && !Array.isArray(h.scope) ? h.scope : {};
38
+ return { scope, markdown: h.markdown };
39
+ } catch (_) {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ function markerFrom(deps) {
45
+ if (Object.prototype.hasOwnProperty.call(deps, 'marker')) return deps.marker;
46
+ try { return handoffMarker(require('../../src/module-api').branding().founding); } catch (_) { return null; }
47
+ }
48
+
49
+ const settingsOf = (deps) => ({
50
+ get: deps.getSetting || require('../../src/module-api').projectSettings.get,
51
+ set: deps.setSetting || require('../../src/module-api').projectSettings.set,
52
+ });
53
+
54
+ // carryOver(deps) → what it did: { ran:false, reason } or { ran:true, carried:[ids], kept:[ids] }.
55
+ // carried questions now holding the guide's words
56
+ // kept questions the founder had already answered, left alone
57
+ async function carryOver(deps = {}) {
58
+ const marker = markerFrom(deps);
59
+ if (!marker) return { ran: false, reason: 'no_handoff' };
60
+ const { get, set } = settingsOf(deps);
61
+ const done = await get(HANDOFF_KEY);
62
+ if (done && done.value) return { ran: false, reason: 'already' };
63
+ const work = require('./genesis-work');
64
+ const carried = [];
65
+ const kept = [];
66
+ for (const [question, text] of Object.entries(marker.scope)) {
67
+ if (typeof text !== 'string' || !text.trim()) continue;
68
+ const r = await work.setAnswer('scope', question, { state: 'answered', text, origin: 'carried_over' }, null, deps, { ifEmpty: true });
69
+ if (r.ok === false) continue; // a question this core no longer asks: skipped, never invented
70
+ (r.changed ? carried : kept).push(question);
71
+ }
72
+ await set(HANDOFF_KEY, marker.markdown, null);
73
+ return { ran: true, carried, kept };
74
+ }
75
+
76
+ // ensureHandoff(deps) — the once-per-process door the reads call. Never throws: a hand-off
77
+ // hiccup must never fail the read that triggered it; a failed run is retried next read.
78
+ let once = null;
79
+ function ensureHandoff(deps = {}) {
80
+ if (deps.fresh) return carryOver(deps).catch(() => ({ ran: false, reason: 'error' }));
81
+ if (once) return once;
82
+ const p = carryOver(deps).then((r) => r, (err) => {
83
+ once = null;
84
+ try { require('../../src/module-api').logger('onboarding').error('[onboarding] guide hand-off failed', err); } catch (_) { /* no logger */ }
85
+ return { ran: false, reason: 'error' };
86
+ });
87
+ once = p;
88
+ return p;
89
+ }
90
+
91
+ // readHandoff(deps) → the hand-off file's markdown, or null. No marker, no read: a project
92
+ // whose founder never used the guide never touches the database for it.
93
+ async function readHandoff(deps = {}) {
94
+ if (!markerFrom(deps)) return null;
95
+ const row = await settingsOf(deps).get(HANDOFF_KEY);
96
+ return row && typeof row.value === 'string' && row.value.trim() ? row.value : null;
97
+ }
98
+
99
+ // withHandoff(context, markdown) — PURE. The planning read with the hand-off file first:
100
+ // it is where the project started, and the founder's own answers below it are their
101
+ // latest word.
102
+ function withHandoff(context, markdown) {
103
+ if (!markdown) return { ...context, handoff: null };
104
+ const said = context.markdown ? `\n---\n\n${context.markdown}` : '';
105
+ return { ...context, handoff: markdown, markdown: `${markdown.trimEnd()}\n${said}` };
106
+ }
107
+
108
+
109
+ module.exports = { HANDOFF_KEY, handoffMarker, carryOver, ensureHandoff, readHandoff, withHandoff };
@@ -12,6 +12,7 @@
12
12
 
13
13
  const api = require('../../../src/module-api');
14
14
  const work = require('../genesis-work');
15
+ const handoff = require('../guide-handoff'); // the start-a-project guide's answers, carried in once (task 1005274)
15
16
 
16
17
  const REFUSALS = {
17
18
  unknown_stage: 404, unknown_question: 404, bad_door: 400, bad_state: 400, bad_origin: 400,
@@ -37,13 +38,16 @@ module.exports = function mountGenesisWorkRoutes(router, deps = {}) {
37
38
  const stage = (req) => String(req.params.stage);
38
39
  const q = (req) => String(req.params.question);
39
40
 
40
- // The read path for every door and for AI sessions planning a goal or task.
41
- router.get('/founding/work', api.requireBuilder, handle(async () => ({ stages: await work.readAll(deps) })));
42
- router.get('/founding/context', api.requireBuilder, handle(() => work.readContext(deps)));
43
- router.get('/founding/work/:stage', api.requireBuilder, handle((req) => work.readStage(stage(req), deps)));
41
+ // The read path for every door and for AI sessions planning a goal or task. Each first
42
+ // lands the guide's hand-off if one is waiting (../guide-handoff.js), so the scope
43
+ // interview opens with the carried-over answers and the context leads with the file.
44
+ const arrived = (fn) => async (req) => { await handoff.ensureHandoff(deps); return fn(req); };
45
+ router.get('/founding/work', api.requireBuilder, handle(arrived(async () => ({ stages: await work.readAll(deps) }))));
46
+ router.get('/founding/context', api.requireBuilder, handle(arrived(async () => handoff.withHandoff(await work.readContext(deps), await handoff.readHandoff(deps)))));
47
+ router.get('/founding/work/:stage', api.requireBuilder, handle(arrived((req) => work.readStage(stage(req), deps))));
44
48
  // The stage's document (task 1005115): its record read as one named page, editable
45
49
  // through the writes below at any time, without reopening the stage.
46
- router.get('/founding/work/:stage/document', api.requireBuilder, handle((req) => work.readDocument(stage(req), deps)));
50
+ router.get('/founding/work/:stage/document', api.requireBuilder, handle(arrived((req) => work.readDocument(stage(req), deps))));
47
51
  router.get('/founding/work/:stage/assist', api.requireBuilder, handle(async (req) => {
48
52
  const prompt = work.assistPrompt(stage(req));
49
53
  return prompt ? { prompt } : { ok: false, reason: 'unknown_stage' };
@@ -16,6 +16,6 @@
16
16
  "migrations": true,
17
17
  "pollers": ["code-sweep", "visibility-pull", "guild-totals"]
18
18
  },
19
- "provides": ["platform-identity.hub", "project.publishGate", "project.catalog"],
19
+ "provides": ["platform-identity.hub", "project.publishGate", "project.catalog", "platform-identity.projectMembershipKind"],
20
20
  "consumes": ["ideas.capture"]
21
21
  }
@@ -0,0 +1,49 @@
1
+ // modules/platform-identity/project-standing.js — "what is this person's standing on this
2
+ // project?", for the WORDING of a refusal only (task 1004162, ADR 0407 §6).
3
+ //
4
+ // Published as the port `platform-identity.projectMembershipKind` so provisioning can tell a
5
+ // stranger (who keeps the 404 that confirms nothing) from someone already connected to the
6
+ // project (who is told what to do next), without importing this module (ADR 0083).
7
+ //
8
+ // IT NEVER ADMITS ANYONE, and must never be read to. A `member` row is written at every hub
9
+ // sign-in, including the device flow any GitHub user can start (ADR 0328 amendment), so no
10
+ // answer here is a grant. Admission is provisioning's own grant row (ADR 0407 §1-3).
11
+ //
12
+ // Answers, most specific first:
13
+ // 'owner' | 'member' | 'visitor' — a membership the hub itself witnessed (signin_verified_at)
14
+ // 'pending' — a live invitation from the project (inside its 30 days)
15
+ // 'applied' — no row, but this person asked to join inside the
16
+ // application window and the project took the request
17
+ // null — none of these: a stranger
18
+ 'use strict';
19
+
20
+ const api = require('../../src/module-api');
21
+ const { isNumericGithubId } = require('./platform-identity');
22
+ const { APPLICATION_ECHO_WINDOW_DAYS } = require('./application-echo');
23
+
24
+ const KINDS = Object.freeze(['owner', 'member', 'visitor', 'pending', 'applied']);
25
+
26
+ async function projectMembershipKind(clientId, githubId, { pool = api.pool } = {}) {
27
+ const client = String(clientId || '');
28
+ if (!client || !isNumericGithubId(githubId)) return null;
29
+ const { rows } = await pool.query(
30
+ `SELECT m.membership_kind
31
+ FROM platform_identity_project_memberships m
32
+ WHERE m.client_id = $1 AND m.github_id = $2::bigint
33
+ AND ( (m.membership_kind <> 'pending' AND m.signin_verified_at IS NOT NULL)
34
+ OR (m.membership_kind = 'pending' AND m.joined_at > now() - make_interval(days => $3::int)))
35
+ LIMIT 1`,
36
+ [client, String(githubId), api.inviteExpiry.INVITE_EXPIRY_DAYS],
37
+ );
38
+ if (rows[0] && KINDS.includes(rows[0].membership_kind)) return rows[0].membership_kind;
39
+ const { rows: asked } = await pool.query(
40
+ `SELECT 1 FROM platform_identity_application_echoes
41
+ WHERE github_id = $1::bigint AND client_id = $2
42
+ AND applied_at > now() - make_interval(days => $3::int)
43
+ LIMIT 1`,
44
+ [String(githubId), client, APPLICATION_ECHO_WINDOW_DAYS],
45
+ );
46
+ return asked.length ? 'applied' : null;
47
+ }
48
+
49
+ module.exports = { projectMembershipKind };
@@ -27,6 +27,7 @@ const api = require('../../../src/module-api');
27
27
  const pi = require('../platform-identity');
28
28
  const projectStats = require('../project-stats');
29
29
  const applicationEcho = require('../application-echo');
30
+ const projectStanding = require('../project-standing');
30
31
  const guilds = require('../guilds');
31
32
  const { isMintedPersonKey } = require('../account-logins');
32
33
  const guildEngagements = require('../guild-engagements');
@@ -159,6 +160,13 @@ async function applicationVouchHeaders(client, builder) {
159
160
  }
160
161
 
161
162
  module.exports = function myProjectsRoutes() {
163
+ // PROVIDES port `platform-identity.projectMembershipKind` (task 1004162, ADR 0407 §6): a
164
+ // person's standing on one project, so provisioning can word a REFUSAL — never admit.
165
+ if (!api.hasProvider('platform-identity.projectMembershipKind')) {
166
+ api.registerProvider('platform-identity.projectMembershipKind', {
167
+ projectMembershipKind: (clientId, githubId) => projectStanding.projectMembershipKind(clientId, githubId, { pool: api.pool }),
168
+ });
169
+ }
162
170
  const router = express.Router();
163
171
 
164
172
  // rank: any-builder — the caller's OWN cross-project memberships (own data).
@@ -0,0 +1,126 @@
1
+ // modules/provisioning/grants.js — the owner-made grant to help run a hosted project, and its
2
+ // ONLY writer (task 1004161, ADR 0407).
3
+ //
4
+ // Why it exists: a sign-in on the hub writes a `member` membership row for any GitHub user,
5
+ // the device flow included, so membership can never admit anyone to a project's hosting
6
+ // (ADR 0328 amendment). A grant is the one thing that can: a row in
7
+ // `provisioning_project_grants` (migration provisioning_039) that only the project's OWNER
8
+ // creates, from their own hub session. Nothing a visitor sends, nothing a project reports at
9
+ // check-in, and no sign-in reaches this file.
10
+ //
11
+ // Every INSERT and UPDATE of that table lives here; tests/provisioning_project_grants.mjs
12
+ // fails if another file writes it. Who may call which function is decided by the routes
13
+ // (routes/grants.js); this file holds the data rules that must hold whoever calls.
14
+ 'use strict';
15
+
16
+ const GRANT_LEVELS = Object.freeze(['operate']);
17
+ const MAX_LIVE_GRANTS = 25;
18
+
19
+ const GRANT_COLUMNS = `g.id, g.instance_id, g.grantee_builder_id, g.level, g.granted_by_builder_id,
20
+ g.granted_at, g.revoked_at, g.revoked_by_builder_id`;
21
+
22
+ // The live grant one person holds on one project, or null. Read on every request a
23
+ // grant-admitting route serves (ADR 0016: authority is read fresh, never cached), so a revoke
24
+ // binds on the grantee's very next request.
25
+ async function liveGrant(db, instanceId, builderId) {
26
+ const { rows } = await db.query(
27
+ `SELECT ${GRANT_COLUMNS} FROM provisioning_project_grants g
28
+ WHERE g.instance_id = $1 AND g.grantee_builder_id = $2 AND g.revoked_at IS NULL
29
+ LIMIT 1`,
30
+ [instanceId, builderId],
31
+ );
32
+ return rows[0] || null;
33
+ }
34
+
35
+ // The project's live grants, with who each one names — the owner's "People with access" list.
36
+ async function listLiveGrants(db, instanceId) {
37
+ const { rows } = await db.query(
38
+ `SELECT ${GRANT_COLUMNS}, b.github_login, b.display_name, b.avatar_url
39
+ FROM provisioning_project_grants g
40
+ JOIN builders b ON b.id = g.grantee_builder_id
41
+ WHERE g.instance_id = $1 AND g.revoked_at IS NULL
42
+ ORDER BY g.granted_at, g.id`,
43
+ [instanceId],
44
+ );
45
+ return rows;
46
+ }
47
+
48
+ // The ACTIVE hub builder a GitHub login names, or null. The owner names a person by login;
49
+ // the grant stores the builder id, so a later login rename cannot move the grant to someone else.
50
+ async function findGranteeByLogin(db, login) {
51
+ const { rows } = await db.query(
52
+ `SELECT id, github_login, display_name, avatar_url FROM builders
53
+ WHERE lower(github_login) = lower($1) AND status IS DISTINCT FROM 'inactive'
54
+ LIMIT 1`,
55
+ [login],
56
+ );
57
+ return rows[0] || null;
58
+ }
59
+
60
+ // Make a grant. Returns { grant } on success or { refused: code } — never throws for a rule.
61
+ // Rules held here: a known level only; never the project's own owner; at most MAX_LIVE_GRANTS
62
+ // live grants per project; one live grant per person (an existing one is returned as is).
63
+ // The count and the insert run under the project row's lock, so two requests cannot both
64
+ // take the 25th place.
65
+ async function grantProjectAccess(db, { instanceId, granteeBuilderId, level = 'operate', grantedByBuilderId }) {
66
+ if (!GRANT_LEVELS.includes(level)) return { refused: 'bad_level' };
67
+ const client = typeof db.connect === 'function' ? await db.connect() : db;
68
+ const owned = client !== db;
69
+ try {
70
+ await client.query('BEGIN');
71
+ const { rows: inst } = await client.query(
72
+ 'SELECT id, owner_builder_id FROM provisioning_instances WHERE id = $1 FOR UPDATE', [instanceId]);
73
+ if (!inst[0]) { await client.query('ROLLBACK'); return { refused: 'instance_not_found' }; }
74
+ if (String(inst[0].owner_builder_id) === String(granteeBuilderId)) {
75
+ await client.query('ROLLBACK');
76
+ return { refused: 'grantee_is_owner' };
77
+ }
78
+ const existing = await liveGrant(client, instanceId, granteeBuilderId);
79
+ if (existing) { await client.query('ROLLBACK'); return { grant: existing, created: false }; }
80
+ const { rows: n } = await client.query(
81
+ `SELECT count(*)::int AS n FROM provisioning_project_grants
82
+ WHERE instance_id = $1 AND revoked_at IS NULL`, [instanceId]);
83
+ if (n[0].n >= MAX_LIVE_GRANTS) { await client.query('ROLLBACK'); return { refused: 'grant_limit' }; }
84
+ const { rows } = await client.query(
85
+ `INSERT INTO provisioning_project_grants (instance_id, grantee_builder_id, level, granted_by_builder_id)
86
+ VALUES ($1, $2, $3, $4)
87
+ RETURNING id, instance_id, grantee_builder_id, level, granted_by_builder_id, granted_at, revoked_at, revoked_by_builder_id`,
88
+ [instanceId, granteeBuilderId, level, grantedByBuilderId],
89
+ );
90
+ await client.query('COMMIT');
91
+ return { grant: rows[0], created: true };
92
+ } catch (err) {
93
+ await client.query('ROLLBACK').catch(() => {});
94
+ throw err;
95
+ } finally {
96
+ if (owned) client.release();
97
+ }
98
+ }
99
+
100
+ // Revoke one live grant of this project. Returns the revoked row, or null when there is no
101
+ // such live grant (already revoked, another project's id, or none). A stamp, never a delete.
102
+ async function revokeProjectAccess(db, { instanceId, grantId, revokedByBuilderId }) {
103
+ const { rows } = await db.query(
104
+ `UPDATE provisioning_project_grants
105
+ SET revoked_at = now(), revoked_by_builder_id = $3
106
+ WHERE id = $1 AND instance_id = $2 AND revoked_at IS NULL
107
+ RETURNING id, instance_id, grantee_builder_id, level, granted_by_builder_id, granted_at, revoked_at, revoked_by_builder_id`,
108
+ [grantId, instanceId, revokedByBuilderId],
109
+ );
110
+ return rows[0] || null;
111
+ }
112
+
113
+ // One grant of this project by id, live or not — the revoke route reads it to decide who may
114
+ // revoke (the grantee may revoke their own).
115
+ async function getGrant(db, instanceId, grantId) {
116
+ const { rows } = await db.query(
117
+ `SELECT ${GRANT_COLUMNS} FROM provisioning_project_grants g WHERE g.id = $1 AND g.instance_id = $2`,
118
+ [grantId, instanceId],
119
+ );
120
+ return rows[0] || null;
121
+ }
122
+
123
+ module.exports = {
124
+ GRANT_LEVELS, MAX_LIVE_GRANTS,
125
+ liveGrant, listLiveGrants, findGranteeByLogin, grantProjectAccess, revokeProjectAccess, getGrant,
126
+ };
@@ -0,0 +1,65 @@
1
+ 'use strict';
2
+
3
+ // modules/provisioning/guide-handoff.js — THE GUIDE'S ANSWERS RIDE INTO THE NEW PROJECT
4
+ // (task 1005274, goal 1000106). A founder who talked with the start-a-project guide
5
+ // (modules/front-door-guide) should never be asked the same thing twice. The creation
6
+ // page pre-fills itself from the plan; this file carries the rest across to the new
7
+ // project, whose database this process cannot reach — the birth marker's path exactly
8
+ // (birth.js, modules/onboarding/founding-birth.js):
9
+ //
10
+ // 1. The create request says `from_guide: true` (the page sends it only when it
11
+ // pre-filled from a plan). The route asks the guide, through the
12
+ // `front-door-guide.handoff` port, for the CALLER's own newest attached plan — never a
13
+ // plan named by the request, so nobody can carry someone else's words in.
14
+ // 2. stampGuideHandoff writes the reserved write-once settings key `guide_handoff`
15
+ // ({ scope, markdown }) on the row this request JUST made.
16
+ // 3. The companion GUIDE_HANDOFF carries it into the instance's web.env as one
17
+ // base64url line; src/branding.js maps it onto the server-only founding.guideHandoff,
18
+ // and modules/onboarding/guide-handoff.js writes it once: the scope answers as
19
+ // carried over (never over the founder's own) and the hand-off file.
20
+ //
21
+ // No guide, no flag, no port (an instance without the guide) or no plan: nothing is
22
+ // stamped and the companion goes out empty, so a visitor who never used the guide sees
23
+ // exactly what they saw before.
24
+
25
+ const KEY = 'guide_handoff';
26
+ const MAX_BYTES = 24000; // the stored JSON; handoff.js already bounds the markdown
27
+
28
+ const storedOf = (row) => (row && row.settings && typeof row.settings === 'object' && !Array.isArray(row.settings) ? row.settings : {});
29
+
30
+ // cleanHandoff(h) — PURE. The port's answer reduced to exactly { scope, markdown }, or null.
31
+ function cleanHandoff(h) {
32
+ if (!h || typeof h !== 'object' || typeof h.markdown !== 'string' || !h.markdown.trim()) return null;
33
+ const scope = {};
34
+ if (h.scope && typeof h.scope === 'object') {
35
+ for (const [k, v] of Object.entries(h.scope)) if (/^[a-z_]{1,40}$/.test(k) && typeof v === 'string' && v.trim()) scope[k] = v;
36
+ }
37
+ const out = { scope, markdown: h.markdown };
38
+ return Buffer.byteLength(JSON.stringify(out)) <= MAX_BYTES ? out : null;
39
+ }
40
+
41
+ // guideHandoffEnv(row) — PURE. The companion line's value: base64url JSON, or '' for none.
42
+ function guideHandoffEnv(row) {
43
+ const h = cleanHandoff(storedOf(row)[KEY]);
44
+ return h ? Buffer.from(JSON.stringify(h), 'utf8').toString('base64url') : '';
45
+ }
46
+
47
+ // stampGuideHandoff(db, instanceId, builderId, { name, resolve }) — the create route's
48
+ // call, on the row it just made. Returns true when a hand-off was stamped. Write-once.
49
+ async function stampGuideHandoff(db, instanceId, builderId, { name = '', resolve } = {}) {
50
+ const port = (resolve || require('../../src/module-api').resolveOptional)('front-door-guide.handoff');
51
+ if (typeof port !== 'function') return false;
52
+ const h = cleanHandoff(await port(builderId, { name }));
53
+ if (!h) return false;
54
+ await db.query(
55
+ `UPDATE provisioning_instances
56
+ SET settings = COALESCE(settings, '{}'::jsonb) || jsonb_build_object($2::text, $3::jsonb)
57
+ WHERE id = $1 AND NOT (COALESCE(settings, '{}'::jsonb) ? $2::text)`,
58
+ [instanceId, KEY, JSON.stringify(h)],
59
+ );
60
+ return true;
61
+ }
62
+
63
+ const GUIDE_COMPANION_ENV = Object.freeze({ GUIDE_HANDOFF: guideHandoffEnv });
64
+
65
+ module.exports = { KEY, GUIDE_COMPANION_ENV, stampGuideHandoff };
@@ -0,0 +1,43 @@
1
+ -- provisioning_038_render_promote.sql — a person promotes what ran on the sandbox to the
2
+ -- live app, and every promotion is recorded (task 1005290, ADR 0406 §2).
3
+ --
4
+ -- WHY. Once a project has a sandbox, finished work lands on the sandbox only: the live
5
+ -- Render service stops deploying on every merge, and moves only when a person presses
6
+ -- promote. That press queues a `render-promote` intent carrying the owner's borrowed key
7
+ -- (ADR 0327 §2), and the runner records the promotion BEFORE it asks Render to deploy, so
8
+ -- nothing reaches the live app through Bongos without a row here saying who, which change,
9
+ -- and what came of it.
10
+ --
11
+ -- Additive and namespaced (ADR 0083): one new action on the intent CHECK, one new table.
12
+ -- Idempotent — safe to re-run.
13
+
14
+ BEGIN;
15
+
16
+ ALTER TABLE provisioning_intents DROP CONSTRAINT IF EXISTS provisioning_intents_action_check;
17
+ ALTER TABLE provisioning_intents ADD CONSTRAINT provisioning_intents_action_check
18
+ CHECK (action IN ('provision', 'teardown', 'oauth-manifest', 'domain-attach',
19
+ 'settings-apply', 'restart', 'disconnect', 'core-upgrade',
20
+ 'core-upgrade-preflight', 'repo-private', 'render-standup',
21
+ 'render-promote'));
22
+
23
+ CREATE TABLE IF NOT EXISTS provisioning_promotions (
24
+ id bigserial PRIMARY KEY,
25
+ instance_id bigint NOT NULL REFERENCES provisioning_instances(id) ON DELETE CASCADE,
26
+ commit_id text NOT NULL,
27
+ commit_message text,
28
+ from_commit_id text,
29
+ sandbox_deploy_id text,
30
+ render_deploy_id text,
31
+ requested_by text,
32
+ state text NOT NULL DEFAULT 'started' CHECK (state IN ('started', 'sent', 'failed')),
33
+ error text,
34
+ created_at timestamptz NOT NULL DEFAULT now(),
35
+ updated_at timestamptz NOT NULL DEFAULT now()
36
+ );
37
+
38
+ CREATE INDEX IF NOT EXISTS ix_provisioning_promotions_instance ON provisioning_promotions (instance_id, created_at DESC);
39
+
40
+ COMMENT ON TABLE provisioning_promotions IS
41
+ 'Every promotion of a sandbox change to a project''s live Render app (task 1005290, ADR 0406): who asked, which commit, from which, and what Render answered. Written by the runner before it deploys.';
42
+
43
+ COMMIT;
@@ -0,0 +1,40 @@
1
+ -- provisioning_039_project_grants.sql — an owner-made grant to help run a hosted project
2
+ -- (task 1004161, ADR 0407). Landed as provisioning_038 beside render_promote's 038; renumbered
3
+ -- by task 1004162 so the series has one migration per number. Every statement is IF NOT EXISTS,
4
+ -- so a database that already ran it under the old name runs it again harmlessly.
5
+ --
6
+ -- WHY. A hosted project's provisioning routes admitted only its owner (or an archon).
7
+ -- ADR 0328 Phase 2 would have admitted a `member` row of platform_identity_project_memberships,
8
+ -- but the hub writes that row at every sign-in — the device flow any GitHub user can start
9
+ -- included — so it is not a grant (ADR 0328 amendment, ADR 0407). This table is the grant:
10
+ -- made on purpose by the project's owner from their own hub session, stored apart from every
11
+ -- sign-in and check-in path, with one writer (modules/provisioning/grants.js).
12
+ --
13
+ -- A revoke is a stamp, never a delete, so the record survives. One LIVE grant per person per
14
+ -- project (the partial unique index); granting again after a revoke makes a new row. Deleting
15
+ -- the project row (disconnect) takes its grants with it.
16
+ --
17
+ -- `level` is a closed list. 'operate' is the only level (ADR 0407 §3); a second one is an ADR
18
+ -- amendment and a new migration, never a free string.
19
+ --
20
+ -- Additive and namespaced (ADR 0083): no down-migration, idempotent — safe to re-run.
21
+
22
+ BEGIN;
23
+
24
+ CREATE TABLE IF NOT EXISTS provisioning_project_grants (
25
+ id bigserial PRIMARY KEY,
26
+ instance_id bigint NOT NULL REFERENCES provisioning_instances(id) ON DELETE CASCADE,
27
+ grantee_builder_id bigint NOT NULL REFERENCES builders(id) ON DELETE CASCADE,
28
+ level text NOT NULL CHECK (level IN ('operate')),
29
+ granted_by_builder_id bigint NOT NULL REFERENCES builders(id),
30
+ granted_at timestamptz NOT NULL DEFAULT now(),
31
+ revoked_at timestamptz,
32
+ revoked_by_builder_id bigint REFERENCES builders(id),
33
+ CHECK ((revoked_at IS NULL) = (revoked_by_builder_id IS NULL))
34
+ );
35
+
36
+ CREATE UNIQUE INDEX IF NOT EXISTS ux_provisioning_project_grants_live
37
+ ON provisioning_project_grants (instance_id, grantee_builder_id)
38
+ WHERE revoked_at IS NULL;
39
+
40
+ COMMIT;