@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
@@ -24,7 +24,8 @@
24
24
  "render-standup",
25
25
  "look",
26
26
  "demo",
27
- "github-setup"
27
+ "github-setup",
28
+ "grants"
28
29
  ],
29
30
  "migrations": true,
30
31
  "pollers": [
@@ -0,0 +1,107 @@
1
+ // modules/provisioning/project-refusal.js — what a person the project gate REFUSED is told
2
+ // (task 1004162, ADR 0407 §6, replacing ADR 0328 decision 3).
3
+ //
4
+ // Called only after requireProjectOwner has already said no. It can never admit: it never
5
+ // calls next() and never sets req.project. It only picks the wording of the refusal, so that
6
+ // someone already connected to the project gets a next step instead of a dead end, while a
7
+ // stranger still learns nothing.
8
+ //
9
+ // no connection at all → 404 instance_not_found (as before)
10
+ // holds a grant, route not open to grantees → 403 owner_only
11
+ // 'visitor' (the project knows them) → 403 project_membership_required + how to apply
12
+ // 'pending' (the project invited them) → 403 project_membership_required + accept it
13
+ // 'applied' (asked to join inside 30 days) → 403 project_membership_required + it's with the owner
14
+ // 'member' / 'owner' row, PUBLIC project → 403 project_access_required (ask the owner)
15
+ // 'member' / 'owner' row, private or stealth → 404 — a `member` row can be had by anyone who
16
+ // runs the device flow (ADR 0328 amendment), so
17
+ // it is no evidence they may learn a closed
18
+ // project's id belongs to it.
19
+ //
20
+ // Any doubt — the standing port missing, throwing, or answering something unknown, or the
21
+ // grant read failing — is the 404: on doubt, say less.
22
+ 'use strict';
23
+
24
+ const api = require('../../src/module-api');
25
+ const provisioning = require('./provisioning');
26
+ const grants = require('./grants');
27
+
28
+ const log = api.logger('provisioning');
29
+
30
+ const STANDING_PORT = 'platform-identity.projectMembershipKind';
31
+ const HOSTING_IS_SEPARATE = 'Managing its hosting is something the project’s owner gives separately.';
32
+
33
+ function notFound(res) {
34
+ return res.fail('instance_not_found', 404);
35
+ }
36
+
37
+ function projectOrigin(inst) {
38
+ return inst.domain ? `https://${inst.domain}` : null;
39
+ }
40
+
41
+ // The project's join door, as its owner set it: only a PUBLIC project may confirm itself to a
42
+ // person whose only link is a sign-in.
43
+ function isPublicProject(inst) {
44
+ try {
45
+ return provisioning.effectiveSettings(inst).visibility === 'public';
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ async function refuseProject(req, res, inst) {
52
+ let grant = null;
53
+ try {
54
+ grant = await grants.liveGrant(api.pool, inst.id, req.builder.id);
55
+ } catch (err) {
56
+ log.error(`[provisioning] ${req.method} ${req.path} — refusal grant read`, err);
57
+ return notFound(res);
58
+ }
59
+ if (grant) {
60
+ return res.fail('owner_only', { status: 403, message: 'Only the project’s owner can do this.' });
61
+ }
62
+
63
+ const port = api.resolveOptional(STANDING_PORT);
64
+ if (!port || typeof port.projectMembershipKind !== 'function') return notFound(res);
65
+ let kind;
66
+ try {
67
+ kind = await port.projectMembershipKind(inst.slug, req.builder.github_id);
68
+ } catch (err) {
69
+ log.error(`[provisioning] ${req.method} ${req.path} — standing read`, err);
70
+ return notFound(res);
71
+ }
72
+
73
+ const origin = projectOrigin(inst);
74
+ switch (kind) {
75
+ case 'visitor':
76
+ return res.fail('project_membership_required', {
77
+ status: 403,
78
+ message: `You’re not a member of this project yet. Ask to join it, and its owner decides. ${HOSTING_IS_SEPARATE}`,
79
+ details: { apply: origin
80
+ ? { state: 'can_apply', method: 'POST', path: '/api/bongos/my-projects/join', body: { origin } }
81
+ : { state: 'can_apply' } },
82
+ });
83
+ case 'pending':
84
+ return res.fail('project_membership_required', {
85
+ status: 403,
86
+ message: `This project has invited you. Sign in to it to accept. ${HOSTING_IS_SEPARATE}`,
87
+ details: { apply: { state: 'invited', sign_in: origin } },
88
+ });
89
+ case 'applied':
90
+ return res.fail('project_membership_required', {
91
+ status: 403,
92
+ message: `You’ve asked to join this project, and its owner decides. ${HOSTING_IS_SEPARATE}`,
93
+ details: { apply: { state: 'applied' } },
94
+ });
95
+ case 'member':
96
+ case 'owner':
97
+ if (!isPublicProject(inst)) return notFound(res);
98
+ return res.fail('project_access_required', {
99
+ status: 403,
100
+ message: 'Only the project’s owner, and people the owner has given access, can manage its hosting. Ask the owner to give you access.',
101
+ });
102
+ default:
103
+ return notFound(res);
104
+ }
105
+ }
106
+
107
+ module.exports = { refuseProject };
@@ -25,7 +25,7 @@ const { normalizeModuleSelection, effectiveModules } = require('./starter-bundle
25
25
  const { PLANET_PHYSICS_QUESTIONS, canonicalAnswer } = require('./planet-physics');
26
26
  const { softLimitsFor } = require('./soft-limits'); const { demoView } = require('./demo'); const { isTestProject, TEST_COMPANION_ENV } = require('./test-project'); // the demo marker's owner view (task 1004420); the test mark (task 1005036)
27
27
  const { dnsResolves, recordDnsResolved, lookupAnswers } = require('./dns-resolves'); // has the platform's lookup seen the address answer (task 1004505)
28
- const { LOOK_SETTINGS_VOCAB, LOOK_DEFAULTS, LOOK_COMPANION_ENV } = require('./look'); const { BIRTH_COMPANION_ENV } = require('./birth'); // born into genesis (task 1004506)
28
+ const { LOOK_SETTINGS_VOCAB, LOOK_DEFAULTS, LOOK_COMPANION_ENV } = require('./look'); const { BIRTH_COMPANION_ENV } = require('./birth'); const { GUIDE_COMPANION_ENV } = require('./guide-handoff'); // born into genesis (task 1004506); the guide's hand-off (task 1005274)
29
29
 
30
30
  // ---------------------------------------------------------------------------
31
31
  // Constants (mirror the CHECK constraints in provisioning_001_tables.sql)
@@ -66,7 +66,7 @@ const PROJECT_TYPES = ['game', 'research', 'business', 'non-profit', 'not-sure']
66
66
  // 'disconnect' (task 1003249, migration provisioning_016): the owner's true delete — ./disconnect.js.
67
67
  // 'core-upgrade' (task 1003159, ADR 0293): move this instance's core pin — the owner's
68
68
  // deploy door. The web tier only QUEUES it; the control-plane runner performs it.
69
- const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply', 'restart', 'disconnect', 'core-upgrade', 'core-upgrade-preflight', 'repo-private', 'render-standup'];
69
+ const INTENT_ACTIONS = ['provision', 'teardown', 'oauth-manifest', 'domain-attach', 'settings-apply', 'restart', 'disconnect', 'core-upgrade', 'core-upgrade-preflight', 'repo-private', 'render-standup', 'render-promote'];
70
70
  // Every state a teardown can be asked for — i.e. everything except the one
71
71
  // state where there is provably nothing left to release. DERIVED from
72
72
  // INSTANCE_STATES on purpose (task 1003042, walk finding N5): the old hand-kept
@@ -513,7 +513,7 @@ function settingsEnvVars(row, prefix) {
513
513
  // cutoff and every unresolved review counts, which is the right answer for a
514
514
  // project that has been strict since it stood up.
515
515
  const SETTINGS_COMPANION_ENV = Object.freeze({
516
- ARTIST_GATE_SINCE: (row) => artistGateStrictSince(row) || '', ...LOOK_COMPANION_ENV, ...BIRTH_COMPANION_ENV, ...TEST_COMPANION_ENV, // + accent, logo (look.js); birth, demo box (birth.js); test mark (test-project.js)
516
+ ARTIST_GATE_SINCE: (row) => artistGateStrictSince(row) || '', ...LOOK_COMPANION_ENV, ...BIRTH_COMPANION_ENV, ...TEST_COMPANION_ENV, ...GUIDE_COMPANION_ENV, // + accent, logo (look.js); birth, demo box (birth.js); test mark (test-project.js); the guide's hand-off (guide-handoff.js)
517
517
  });
518
518
 
519
519
  // The web.env block the runner writes at STANDUP (task 1003139): a comment that
@@ -1192,7 +1192,7 @@ async function enqueueTeardown(db, instanceId, requestedBy) {
1192
1192
  `UPDATE provisioning_intents
1193
1193
  SET state = 'error', last_error = 'superseded: teardown requested', borrowed_key_enc = NULL,
1194
1194
  updated_at = now(), resolved_at = now()
1195
- WHERE instance_id = $1 AND action IN ('provision', 'settings-apply', 'restart', 'repo-private', 'render-standup') AND state = 'pending'
1195
+ WHERE instance_id = $1 AND action IN ('provision', 'settings-apply', 'restart', 'repo-private', 'render-standup', 'render-promote') AND state = 'pending'
1196
1196
  AND NOT EXISTS (SELECT 1 FROM provisioning_instances WHERE id = $1 AND hosting_shape = 'control-plane') RETURNING id`,
1197
1197
  [instanceId]
1198
1198
  );
@@ -65,4 +65,39 @@ async function lookupRender(key, { ownerId = null, fetchImpl = globalThis.fetch
65
65
  return { workspaces, services };
66
66
  }
67
67
 
68
- module.exports = { lookupRender, RenderLookupError, RENDER_API };
68
+ // WHAT IS WAITING TO GO LIVE (task 1005290, ADR 0406 §2; the ADR 0406 §11 amendment widens
69
+ // ADR 0348 by exactly this): the owner's two recorded services — their live app and its
70
+ // sandbox — and the deploys of each. GET only, the same key rules as above, every service
71
+ // asserted to carry the workspace. Nothing here can promote; promoting is the runner's
72
+ // (scripts/bongos/provision-render-promote.js), on a key lent with the press.
73
+ const COMMIT_RE = /^[0-9a-f]{7,40}$/;
74
+ const SERVED = ['live', 'deactivated'];
75
+ function shapeDeploy(d) {
76
+ const c = (d && d.commit) || {};
77
+ return {
78
+ deploy_id: d.id,
79
+ commit_id: COMMIT_RE.test(String(c.id || '')) ? c.id : null,
80
+ short: String(c.id || '').slice(0, 7),
81
+ message: String(c.message || '').split('\n')[0].slice(0, 200),
82
+ at: (c.createdAt || d.createdAt) || null,
83
+ status: d.status || null,
84
+ };
85
+ }
86
+ async function lookupPromotion(key, { ownerId, liveServiceId, sandboxServiceId, fetchImpl = globalThis.fetch }) {
87
+ for (const id of [liveServiceId, sandboxServiceId]) {
88
+ const s = await get(`/services/${encodeURIComponent(id)}`, key, fetchImpl);
89
+ if (!s || s.ownerId !== ownerId) throw new RenderLookupError('render_owner_mismatch', 'Render answered from another workspace, so nothing was shown.');
90
+ }
91
+ const deploys = async (id, n) => unwrap(await get(`/services/${encodeURIComponent(id)}/deploys?limit=${n}`, key, fetchImpl), 'deploy').map(shapeDeploy);
92
+ const live = await deploys(liveServiceId, 10);
93
+ const sandbox = await deploys(sandboxServiceId, 30);
94
+ const liveNow = live.find((d) => d.status === 'live') || null;
95
+ // Waiting = what served on the sandbox since the change live runs, newest first. If live's
96
+ // change is not in the sandbox's list (older than it reaches), everything listed waits.
97
+ const served = sandbox.filter((d) => SERVED.includes(d.status) && d.commit_id);
98
+ const at = liveNow ? served.findIndex((d) => d.commit_id === liveNow.commit_id) : -1;
99
+ const waiting = (at === -1 ? served : served.slice(0, at)).filter((d, i, all) => all.findIndex((x) => x.commit_id === d.commit_id) === i);
100
+ return { live: liveNow, waiting };
101
+ }
102
+
103
+ module.exports = { lookupRender, lookupPromotion, RenderLookupError, RENDER_API };
@@ -0,0 +1,80 @@
1
+ // modules/provisioning/render-promote.js — the web tier's half of PROMOTE: a person moves
2
+ // what ran on the project's sandbox to its live app (task 1005290; ADR 0406 §2, ADR 0327).
3
+ //
4
+ // WHY. Finished work lands on the sandbox (it follows `main`); the live app moves only
5
+ // when a person presses promote — the project law "Land only. Ask before going live". This
6
+ // file queues that press (a `render-promote` intent holding the owner's encrypted key, and
7
+ // the chosen change on the row, in ONE statement), reads back the record of promotions,
8
+ // and shapes what the owner is told. It never calls Render: the read-only look at what is
9
+ // waiting is render-lookup.js (ADR 0348 as widened by ADR 0406 §11), and the promotion
10
+ // itself is the runner's (scripts/bongos/provision-render-promote.js).
11
+ 'use strict';
12
+
13
+ const COMMIT_RE = /^[0-9a-f]{7,40}$/;
14
+
15
+ // Why this project cannot promote right now, in the owner's words, or null.
16
+ function noPromoteReason(inst) {
17
+ const h = (inst && inst.app_host) || {};
18
+ if (!inst || inst.status === 'torn_down' || inst.status === 'tearing_down') return 'This project has been removed.';
19
+ if (h.platform !== 'render' || !h.serviceId) return 'Your app is not set up on Render, so there is nothing to promote to.';
20
+ if (!h.sandbox || !h.sandbox.serviceId || ['removed', 'left', 'failed'].includes(h.sandbox.state)) return 'This project has no sandbox, so there is nothing to promote from. Add one first.';
21
+ return null;
22
+ }
23
+
24
+ // Queue a promotion. Returns { intent } or { conflict: 'open_intent', intent }.
25
+ async function enqueuePromote(db, instanceId, { keyEnc, commitId, expectLiveCommit, requestedBy }) {
26
+ await require('./control-plane-guard').assertIntentAllowed(db, instanceId, 'render-promote');
27
+ const ask = { state: 'requested', commitId, expectLiveCommit: expectLiveCommit || null, requestedBy: requestedBy == null ? null : String(requestedBy), requestedAt: new Date().toISOString(), error: null };
28
+ try {
29
+ const { rows } = await db.query(
30
+ `WITH i AS (
31
+ INSERT INTO provisioning_intents (instance_id, action, requested_by, state, borrowed_key_enc)
32
+ VALUES ($1, 'render-promote', $2, 'pending', $3) RETURNING *
33
+ ), u AS (
34
+ UPDATE provisioning_instances SET app_host = app_host || jsonb_build_object('promote', $4::jsonb)
35
+ WHERE id = $1 RETURNING id
36
+ )
37
+ SELECT i.id, i.instance_id, i.action, i.state, i.created_at FROM i`,
38
+ [instanceId, 'api:self', keyEnc, JSON.stringify(ask)]);
39
+ return { intent: rows[0] };
40
+ } catch (e) {
41
+ if (e && e.code === '23505') {
42
+ const { rows } = await db.query(
43
+ `SELECT id, action, state FROM provisioning_intents
44
+ WHERE instance_id = $1 AND state IN ('pending', 'running') ORDER BY created_at DESC LIMIT 1`, [instanceId]);
45
+ return { conflict: 'open_intent', intent: rows[0] || { action: 'unknown', state: 'open' } };
46
+ }
47
+ throw e;
48
+ }
49
+ }
50
+
51
+ // The project's recent promotions, newest first: what went live, when, by whose press.
52
+ async function listPromotions(db, instanceId, limit = 5) {
53
+ const { rows } = await db.query(
54
+ `SELECT commit_id, commit_message, from_commit_id, state, error, created_at
55
+ FROM provisioning_promotions WHERE instance_id = $1 ORDER BY created_at DESC LIMIT $2`, [instanceId, limit]);
56
+ return (rows || []).filter((r) => r && r.commit_id).map((r) => ({
57
+ commit: String(r.commit_id).slice(0, 7),
58
+ message: r.commit_message || null,
59
+ from: r.from_commit_id ? String(r.from_commit_id).slice(0, 7) : null,
60
+ state: r.state,
61
+ error: r.state === 'failed' ? (r.error || 'It did not go through.') : null,
62
+ at: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
63
+ }));
64
+ }
65
+
66
+ // What the owner is told about promoting: whether they can, whether the live app still
67
+ // deploys every merge, and where the last press stands.
68
+ function promoteView(inst) {
69
+ const h = (inst && inst.app_host) || {};
70
+ const why = noPromoteReason(inst);
71
+ const p = h.promote || null;
72
+ return {
73
+ can: !why,
74
+ reason: why,
75
+ live_follows_main: h.liveAutoDeploy !== 'off',
76
+ last: p ? { state: p.state, commit: p.commitId ? String(p.commitId).slice(0, 7) : null, error: p.state === 'failed' ? (p.error || null) : null } : null,
77
+ };
78
+ }
79
+
80
+ module.exports = { noPromoteReason, enqueuePromote, listPromotions, promoteView, COMMIT_RE };
@@ -1,34 +1,72 @@
1
- // modules/provisioning/route-helpers.js — the one owner-or-archon gate every own-scoped
1
+ // modules/provisioning/route-helpers.js — the one gate every own-scoped
2
2
  // /provisioning/instances/:id route mounts (task 1004159, ADR 0328 decision 2). It replaced
3
3
  // three hand-written copies of the same rule, and the three routes that had only the owner
4
- // half of it (teardown, domain, github-app).
4
+ // half of it (teardown, domain, github-app). Since task 1004161 (ADR 0407) a route may also
5
+ // admit a person the project's OWNER granted access to, by opting in — never by default.
5
6
  'use strict';
6
7
 
7
8
  const api = require('../../src/module-api');
8
9
  const { failFrom } = require('./public-refusal');
10
+ const grants = require('./grants');
11
+ const { refuseProject } = require('./project-refusal');
9
12
 
10
13
  const log = api.logger('provisioning');
11
14
 
15
+ // The grant levels a route may opt into. One today (ADR 0407 §3).
16
+ const GRANTEE_LEVELS = Object.freeze(['operate']);
17
+
12
18
  function isProjectOwner(inst, builder) {
13
19
  return String(inst.owner_builder_id) === String(builder.id);
14
20
  }
15
21
 
16
- // The audit actor for a request the gate admitted: the owner acts as themselves, and an
17
- // archon on someone else's project is named, so the override never reads as the owner's act.
22
+ // Which door admitted this request: 'owner' · 'grantee' · 'archon'. Set by the gate; for a
23
+ // request the gate did not see, decided from the row the old way (owner, else archon).
24
+ function projectDoor(req) {
25
+ if (req.projectDoor) return req.projectDoor;
26
+ return isProjectOwner(req.project, req.builder) ? 'owner' : 'archon';
27
+ }
28
+
29
+ // The audit actor for a request the gate admitted: the owner acts as themselves, and a grantee
30
+ // or an archon is named, so neither ever reads as the owner's act (ADR 0407 §4).
18
31
  function projectActor(req) {
19
- return isProjectOwner(req.project, req.builder) ? 'api:self' : `api:archon:${req.builder.id}`;
32
+ const door = projectDoor(req);
33
+ if (door === 'owner') return 'api:self';
34
+ return `api:${door}:${req.builder.id}`;
35
+ }
36
+
37
+ // The same, in words, for an event's detail line: "owner", "grantee <login>", "archon <login>".
38
+ function projectActorLabel(req) {
39
+ const door = projectDoor(req);
40
+ return door === 'owner' ? 'owner' : `${door} ${req.builder.github_login || req.builder.id}`;
41
+ }
42
+
43
+ // What a grantee is shown of the project read (ADR 0407 §3): everything the owner sees except
44
+ // the owner's own hosting bill and the GitHub App steps, which act on the owner's GitHub
45
+ // account. The owner and an archon get the body unchanged.
46
+ function grantedView(req, body) {
47
+ if (projectDoor(req) !== 'grantee') return body;
48
+ const { owner_monthly_usd, github_install, ...instance } = body.instance || {};
49
+ return { ...body, instance, github_app: null, github_setup: null };
20
50
  }
21
51
 
22
- // requireProjectOwner(loadFn, { archonOverride }) — mounted after requireBuilder, and spelled
23
- // in the route's own middleware list so route-rank-check.js can see it (ADR 0328 §4). Loads
24
- // the row with the caller's loader (`loadFn(id)`) and admits its owner or, by default, an
25
- // archon. That is a plain RANK test on purpose: `provisioning.fleet.manage` floors at metic
26
- // (ADR 0157), so gating on it would widen every route it guards.
52
+ // requireProjectOwner(loadFn, { archonOverride, grantee }) — mounted after requireBuilder, and
53
+ // spelled in the route's own middleware list so route-rank-check.js can see it (ADR 0328 §4).
54
+ // Loads the row with the caller's loader (`loadFn(id)`) and admits, in this order:
55
+ // 1. its owner;
56
+ // 2. when the route opts in with `grantee: 'operate'`, a person holding a LIVE grant of that
57
+ // level on this project — read from provisioning_project_grants on every request, never a
58
+ // membership row (ADR 0407 §§1-3);
59
+ // 3. by default, an archon — a plain RANK test on purpose: `provisioning.fleet.manage`
60
+ // floors at metic (ADR 0157), so gating on it would widen every route it guards.
27
61
  //
28
62
  // A missing row and someone else's row are the same 404, and the gate runs before the
29
63
  // handler's body validator, so a stranger cannot tell a real id from a malformed request.
30
- // The loaded row rides on `req.project`.
31
- function requireProjectOwner(loadFn, { archonOverride = true } = {}) {
64
+ // A grant read that fails DENIES (the opaque 500), never falls through to admit.
65
+ // The loaded row rides on `req.project`; the door that admitted it on `req.projectDoor`.
66
+ function requireProjectOwner(loadFn, { archonOverride = true, grantee = null } = {}) {
67
+ if (grantee !== null && !GRANTEE_LEVELS.includes(grantee)) {
68
+ throw new Error(`requireProjectOwner: unknown grantee level ${grantee}`);
69
+ }
32
70
  return async function projectOwnerGate(req, res, next) {
33
71
  const id = api.parseId(req, res); // sends 400 bad_id + returns null on failure
34
72
  if (!id) return;
@@ -39,12 +77,28 @@ function requireProjectOwner(loadFn, { archonOverride = true } = {}) {
39
77
  log.error(`[provisioning] ${req.method} ${req.path} — project load`, err);
40
78
  return failFrom(res, err, 'instance_read_failed');
41
79
  }
42
- const admitted = !!inst
43
- && (isProjectOwner(inst, req.builder) || (archonOverride && req.builder.rank === 'archon'));
44
- if (!admitted) return res.fail('instance_not_found', 404);
80
+ if (!inst) return res.fail('instance_not_found', 404);
81
+ let door = null;
82
+ if (isProjectOwner(inst, req.builder)) door = 'owner';
83
+ if (!door && grantee) {
84
+ let grant;
85
+ try {
86
+ grant = await grants.liveGrant(api.pool, inst.id, req.builder.id);
87
+ } catch (err) {
88
+ log.error(`[provisioning] ${req.method} ${req.path} — grant read`, err);
89
+ return failFrom(res, err, 'instance_read_failed');
90
+ }
91
+ if (grant && grant.level === grantee) door = 'grantee';
92
+ }
93
+ if (!door && archonOverride && req.builder.rank === 'archon') door = 'archon';
94
+ // Refused. The WORDING depends on who is asking (task 1004162, ADR 0407 §6): a stranger
95
+ // still gets the 404, someone already connected to the project gets a next step. That
96
+ // file can only refuse — it never calls next() and never sets req.project.
97
+ if (!door) return refuseProject(req, res, inst);
45
98
  req.project = inst;
99
+ req.projectDoor = door;
46
100
  return next();
47
101
  };
48
102
  }
49
103
 
50
- module.exports = { requireProjectOwner, projectActor, isProjectOwner };
104
+ module.exports = { requireProjectOwner, projectActor, projectActorLabel, grantedView, isProjectOwner };
@@ -25,10 +25,23 @@ const { pool, validateOrRespond } = api;
25
25
  const provisioning = require('../provisioning');
26
26
  const envManifest = require('../env-manifest');
27
27
  const { failFrom } = require('../public-refusal');
28
- const { requireProjectOwner } = require('../route-helpers');
28
+ const { requireProjectOwner, projectActor, projectActorLabel } = require('../route-helpers');
29
29
 
30
30
  const log = api.logger('provisioning');
31
31
  const loadInstance = (id) => provisioning.getInstanceById(pool, id);
32
+ // All four routes also admit a person the owner granted 'operate' access (task 1004161,
33
+ // ADR 0407 §3): a variable NAME changes no running behaviour, and this list exists for the
34
+ // builder sessions that help run the project to read and keep current.
35
+ const GRANTEE = { grantee: 'operate' };
36
+
37
+ // Every change to the list is an audit row naming who made it, so a grantee's change never
38
+ // reads as the owner's (ADR 0407 §4). Best-effort: the change is already saved.
39
+ function recordManifestChange(req, what) {
40
+ return provisioning.recordEvent(pool, {
41
+ instanceId: req.project.id, ownerBuilderId: req.project.owner_builder_id, event: 'env-manifest',
42
+ detail: `${what} by ${projectActorLabel(req)}`, actor: projectActor(req),
43
+ }).catch(() => {});
44
+ }
32
45
 
33
46
  // The `:varId` path segment. parseId reads `:id`, which is the PROJECT here, so the
34
47
  // declaration's id is parsed separately. A non-numeric id is a 400 naming the field
@@ -91,7 +104,7 @@ module.exports = function buildEnvManifestRouter() {
91
104
 
92
105
  // GET — the declared surface. This is the route a BUILDER SESSION reads to learn which
93
106
  // variables a project has, which is the whole reason the manifest exists.
94
- router.get('/provisioning/instances/:id/env-manifest', api.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
107
+ router.get('/provisioning/instances/:id/env-manifest', api.requireBuilder, requireProjectOwner(loadInstance, GRANTEE), async (req, res) => {
95
108
  try {
96
109
  const inst = req.project;
97
110
  const vars = await envManifest.listEnvVars(pool, inst.id);
@@ -113,7 +126,7 @@ module.exports = function buildEnvManifestRouter() {
113
126
 
114
127
  // POST — declare a variable, or re-declare one that exists (an edit, not a duplicate:
115
128
  // see ../env-manifest.js's declareEnvVar).
116
- router.post('/provisioning/instances/:id/env-manifest', api.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
129
+ router.post('/provisioning/instances/:id/env-manifest', api.requireBuilder, requireProjectOwner(loadInstance, GRANTEE), async (req, res) => {
117
130
  if (refusesValue(req, res)) return; // before the strict validator — see refusesValue
118
131
  if (validateOrRespond(req, res, {
119
132
  name: { required: true, type: 'string', maxLength: envManifest.ENV_NAME_MAX },
@@ -131,6 +144,7 @@ module.exports = function buildEnvManifestRouter() {
131
144
  environments: req.body.environments,
132
145
  required: req.body.required,
133
146
  });
147
+ await recordManifestChange(req, `${created ? 'declared' : 're-declared'} ${row.name}`);
134
148
  res.status(created ? 201 : 200).json({ ok: true, created, env_var: row, values_held: false });
135
149
  } catch (err) {
136
150
  log.error('[provisioning] POST /provisioning/instances/:id/env-manifest', err);
@@ -140,7 +154,7 @@ module.exports = function buildEnvManifestRouter() {
140
154
 
141
155
  // PATCH — edit one declaration. Merge-patch: an omitted field is left alone, so the
142
156
  // hall can fix a description without restating the environments.
143
- router.patch('/provisioning/instances/:id/env-manifest/:varId', api.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
157
+ router.patch('/provisioning/instances/:id/env-manifest/:varId', api.requireBuilder, requireProjectOwner(loadInstance, GRANTEE), async (req, res) => {
144
158
  if (refusesValue(req, res)) return; // before the strict validator — see refusesValue
145
159
  if (validateOrRespond(req, res, {
146
160
  name: { type: 'string', maxLength: envManifest.ENV_NAME_MAX },
@@ -157,6 +171,7 @@ module.exports = function buildEnvManifestRouter() {
157
171
  // Not that project's declaration → the same 404 a missing one gets, so a guessed id
158
172
  // never confirms what another project declares.
159
173
  if (!row) return res.fail('env_var_not_found', 404);
174
+ await recordManifestChange(req, `edited ${row.name}`);
160
175
  res.json({ ok: true, env_var: row, values_held: false });
161
176
  } catch (err) {
162
177
  log.error('[provisioning] PATCH /provisioning/instances/:id/env-manifest/:varId', err);
@@ -167,13 +182,14 @@ module.exports = function buildEnvManifestRouter() {
167
182
  // DELETE — remove one declaration. Nothing outside this table is affected: the value,
168
183
  // wherever it lives, is untouched, and the response says so rather than letting an
169
184
  // owner believe removing the name removed the secret.
170
- router.delete('/provisioning/instances/:id/env-manifest/:varId', api.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
185
+ router.delete('/provisioning/instances/:id/env-manifest/:varId', api.requireBuilder, requireProjectOwner(loadInstance, GRANTEE), async (req, res) => {
171
186
  try {
172
187
  const inst = req.project;
173
188
  const varId = parseVarId(req, res);
174
189
  if (!varId) return;
175
190
  const removed = await envManifest.removeEnvVar(pool, inst.id, varId);
176
191
  if (!removed) return res.fail('env_var_not_found', 404);
192
+ await recordManifestChange(req, `removed declaration ${varId}`);
177
193
  res.json({ ok: true, removed: true,
178
194
  message: 'Declaration removed. The variable\'s value, wherever you keep it, is untouched — this platform never had it.' });
179
195
  } catch (err) {
@@ -0,0 +1,141 @@
1
+ // modules/provisioning/routes/grants.js — "People with access": the owner gives a person access
2
+ // to help run their hosted project, lists it, and takes it back (task 1004161, ADR 0407 §2).
3
+ //
4
+ // The grant itself is provisioning_project_grants, written only by ../grants.js. Who may do
5
+ // what here:
6
+ // • GIVE — the project's owner only, from their own session. NO archon override: an archon
7
+ // already reaches the project for fleet support, and a grant would let them hand someone
8
+ // else's project to a third person without its owner (owner decision, ADR 0407).
9
+ // • LIST — the owner, or an archon (the ordinary owner-or-archon gate).
10
+ // • TAKE BACK — the owner, the grantee themselves (leaving), or an archon. A revoke only ever
11
+ // removes access, so the archon door here is a safety lever, not a widening.
12
+ // Every give and take-back writes a provisioning_events row naming who did it.
13
+ //
14
+ // Its own routes file (declared in module.json) because routes/provisioning.js sits at the
15
+ // size ratchet.
16
+ 'use strict';
17
+
18
+ const express = require('express');
19
+ const api = require('../../../src/module-api');
20
+ const { pool, validateOrRespond, parseId } = api;
21
+ const provisioning = require('../provisioning');
22
+ const grants = require('../grants');
23
+ const { failFrom } = require('../public-refusal');
24
+ const { requireProjectOwner, projectActor, projectActorLabel, isProjectOwner } = require('../route-helpers');
25
+
26
+ const log = api.logger('provisioning');
27
+ const loadInstance = (id) => provisioning.getInstanceById(pool, id);
28
+
29
+ const GITHUB_LOGIN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
30
+
31
+ function publicGrant(g) {
32
+ return {
33
+ id: String(g.id),
34
+ level: g.level,
35
+ granted_at: g.granted_at,
36
+ builder: g.github_login === undefined ? { id: String(g.grantee_builder_id) } : {
37
+ id: String(g.grantee_builder_id), github_login: g.github_login,
38
+ display_name: g.display_name || null, avatar_url: g.avatar_url || null,
39
+ },
40
+ };
41
+ }
42
+
43
+ module.exports = function buildGrantsRouter() {
44
+ const router = express.Router();
45
+
46
+ // GET /provisioning/instances/:id/grants — the project's live grants.
47
+ // rank: any authenticated builder, own resource (owner; archon may read any). 404 otherwise.
48
+ router.get('/provisioning/instances/:id/grants', api.requireBuilder, requireProjectOwner(loadInstance), async (req, res) => {
49
+ try {
50
+ const rows = await grants.listLiveGrants(pool, req.project.id);
51
+ res.json({ grants: rows.map(publicGrant), levels: grants.GRANT_LEVELS, limit: grants.MAX_LIVE_GRANTS });
52
+ } catch (err) {
53
+ log.error('[provisioning] GET /provisioning/instances/:id/grants', err);
54
+ failFrom(res, err, 'grants_read_failed');
55
+ }
56
+ });
57
+
58
+ // POST /provisioning/instances/:id/grants { github_login } — give a person access.
59
+ // rank: any authenticated builder, own resource, OWNER ONLY (no archon override). 404 for
60
+ // anyone else, so a stranger learns nothing about the id.
61
+ router.post('/provisioning/instances/:id/grants', api.requireBuilder, requireProjectOwner(loadInstance, { archonOverride: false }), async (req, res) => {
62
+ if (validateOrRespond(req, res, {
63
+ github_login: { required: true, type: 'string', maxLength: 39 },
64
+ })) return;
65
+ const login = String(req.body.github_login).trim().replace(/^@/, '');
66
+ if (!GITHUB_LOGIN.test(login)) {
67
+ return res.fail('bad_github_login', { status: 400, message: 'That is not a GitHub username.' });
68
+ }
69
+ try {
70
+ const inst = req.project;
71
+ const person = await grants.findGranteeByLogin(pool, login);
72
+ if (!person) {
73
+ return res.fail('grantee_not_found', { status: 404,
74
+ message: `Nobody called ${login} has signed in to the hub yet. Ask them to sign in once, then try again.` });
75
+ }
76
+ const out = await grants.grantProjectAccess(pool, {
77
+ instanceId: inst.id, granteeBuilderId: person.id, level: 'operate', grantedByBuilderId: req.builder.id,
78
+ });
79
+ if (out.refused === 'grantee_is_owner') {
80
+ return res.fail('grantee_is_owner', { status: 400, message: 'You own this project already.' });
81
+ }
82
+ if (out.refused === 'grant_limit') {
83
+ return res.fail('grant_limit', { status: 409,
84
+ message: `A project can give access to at most ${grants.MAX_LIVE_GRANTS} people. Take someone off first.` });
85
+ }
86
+ if (out.refused) return res.fail('instance_not_found', 404);
87
+ if (out.created) {
88
+ await provisioning.recordEvent(pool, {
89
+ instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'grant-made',
90
+ detail: `access (${out.grant.level}) given to builder ${person.id} (${person.github_login}) by ${projectActorLabel(req)}`,
91
+ actor: projectActor(req),
92
+ }).catch(() => {});
93
+ }
94
+ res.status(out.created ? 201 : 200).json({
95
+ ok: true, created: !!out.created,
96
+ grant: publicGrant({ ...out.grant, ...person, grantee_builder_id: person.id }),
97
+ });
98
+ } catch (err) {
99
+ log.error('[provisioning] POST /provisioning/instances/:id/grants', err);
100
+ failFrom(res, err, 'grant_failed');
101
+ }
102
+ });
103
+
104
+ // DELETE /provisioning/instances/:id/grants/:grantId — take access back.
105
+ // rank: any authenticated builder: the project's owner, an archon, or the grantee removing
106
+ // their OWN grant — decided in the handler, because "whose grant is it" is a fact of the row.
107
+ // Anyone else gets the same 404 a missing project gets.
108
+ router.delete('/provisioning/instances/:id/grants/:grantId', api.requireBuilder, async (req, res) => {
109
+ const id = parseId(req, res);
110
+ if (!id) return;
111
+ const grantId = Number(req.params.grantId);
112
+ if (!Number.isSafeInteger(grantId) || grantId <= 0) return res.fail('bad_id', 400);
113
+ try {
114
+ const inst = await loadInstance(id);
115
+ if (!inst) return res.fail('instance_not_found', 404);
116
+ const grant = await grants.getGrant(pool, inst.id, grantId);
117
+ const isOwner = isProjectOwner(inst, req.builder);
118
+ const isArchon = req.builder.rank === 'archon';
119
+ const isOwnGrant = !!grant && String(grant.grantee_builder_id) === String(req.builder.id);
120
+ if (!isOwner && !isArchon && !isOwnGrant) return res.fail('instance_not_found', 404);
121
+ if (!grant || grant.revoked_at) {
122
+ return res.fail('grant_not_found', { status: 404, message: 'That access was already taken back.' });
123
+ }
124
+ const revoked = await grants.revokeProjectAccess(pool, { instanceId: inst.id, grantId, revokedByBuilderId: req.builder.id });
125
+ if (!revoked) return res.fail('grant_not_found', { status: 404, message: 'That access was already taken back.' });
126
+ req.project = inst;
127
+ req.projectDoor = isOwner ? 'owner' : (isOwnGrant ? 'grantee' : 'archon');
128
+ await provisioning.recordEvent(pool, {
129
+ instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'grant-revoked',
130
+ detail: `access (${revoked.level}) of builder ${revoked.grantee_builder_id} taken back by ${projectActorLabel(req)}`,
131
+ actor: projectActor(req),
132
+ }).catch(() => {});
133
+ res.json({ ok: true, revoked: true });
134
+ } catch (err) {
135
+ log.error('[provisioning] DELETE /provisioning/instances/:id/grants/:grantId', err);
136
+ failFrom(res, err, 'grant_revoke_failed');
137
+ }
138
+ });
139
+
140
+ return router;
141
+ };