@bongos/core 1.25.20 → 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.
- package/.bongos-core.json +157 -62
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +10 -0
- package/clients/bongos-client/index.cjs +10 -0
- package/clients/bongos-client/index.d.ts +23 -8
- package/clients/bongos-client/index.mjs +10 -0
- package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +10 -0
- package/docs/adr/0407-a-project-grant-is-made-by-the-owner-not-witnessed-at-sign-in.md +3 -1
- package/docs/api/openapi.json +409 -36
- package/docs/api-reference.md +7 -2
- package/docs/copy-inventory.md +20 -20
- package/docs/copy-registry.json +23 -23
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +2 -2
- package/modules/government/board.js +8 -7
- package/modules/government/charter-advice.js +10 -3
- package/modules/government/charter.js +88 -4
- package/modules/government/migrations/government_039_vote_cast_via_filed.sql +22 -0
- package/modules/government/routes/government.js +12 -12
- package/modules/government/sole-member.js +75 -0
- package/modules/hall-ui/public/genesis-home.js +12 -5
- package/modules/onboarding/genesis-home.js +14 -2
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/project-standing.js +49 -0
- package/modules/platform-identity/routes/my-projects.js +8 -0
- package/modules/provisioning/grants.js +126 -0
- package/modules/provisioning/migrations/provisioning_038_render_promote.sql +43 -0
- package/modules/provisioning/migrations/provisioning_039_project_grants.sql +40 -0
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/project-refusal.js +107 -0
- package/modules/provisioning/provisioning.js +2 -2
- package/modules/provisioning/render-lookup.js +36 -1
- package/modules/provisioning/render-promote.js +80 -0
- package/modules/provisioning/route-helpers.js +70 -16
- package/modules/provisioning/routes/env-manifest.js +21 -5
- package/modules/provisioning/routes/grants.js +141 -0
- package/modules/provisioning/routes/provisioning.js +8 -7
- package/modules/provisioning/routes/render-standup.js +82 -3
- package/modules/public-landing/public/projects.html +99 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +18 -0
- package/scripts/bongos/genesis.js +11 -1
- package/scripts/bongos/government-walk-sole.js +41 -0
- package/scripts/bongos/government-walk.js +15 -16
- package/scripts/bongos/provision-render-common.js +48 -0
- package/scripts/bongos/provision-render-promote.js +93 -0
- package/scripts/bongos/provision-render-sandbox.js +14 -2
- package/scripts/bongos/provision-render.js +4 -36
- package/scripts/bongos/provision.js +1 -0
- package/scripts/bongos/render-api.js +17 -2
- package/scripts/gds/government-walk-sole.js +6 -0
- package/scripts/gds/provision-render-common.js +6 -0
- package/scripts/gds/provision-render-promote.js +6 -0
- package/src/module-api.js +1 -1
- package/tests/fleet_cost_view.mjs +2 -0
- package/tests/genesis_government.mjs +5 -1
- package/tests/genesis_home.mjs +17 -0
- package/tests/government_charter_advice.mjs +49 -0
- package/tests/government_charter_forms.mjs +14 -3
- package/tests/government_seed.mjs +1 -0
- package/tests/government_sole_member.mjs +153 -0
- package/tests/project_catalog_routes.mjs +2 -0
- package/tests/provisioning_env_manifest.mjs +4 -0
- package/tests/provisioning_env_manifest_edit_delete.mjs +2 -0
- package/tests/provisioning_project_grants.mjs +458 -0
- package/tests/provisioning_project_owner_gate.mjs +3 -1
- package/tests/provisioning_project_refusal.mjs +316 -0
- package/tests/provisioning_render_route.mjs +1 -1
- package/tests/provisioning_restart.mjs +2 -0
- package/tests/provisioning_settings.mjs +2 -0
- package/tests/provisioning_settings_apply.mjs +1 -1
- package/tests/provisioning_teardown_intent.mjs +2 -2
- package/tests/render_promote.mjs +309 -0
- package/tests/render_sandbox.mjs +9 -4
|
@@ -137,7 +137,7 @@ const governmentPort = {
|
|
|
137
137
|
// an ordinary amendment under the constitution in force — for the genesis
|
|
138
138
|
// home, which renders the stages from GET /government/constitution's
|
|
139
139
|
// `charter` block. Opening a sitting decides nothing; the vote does.
|
|
140
|
-
proposeCharter:
|
|
140
|
+
proposeCharter: (a) => require('../sole-member').proposeCharter(a), // task 1005130: a board of one decides by filing (sole-member.js), here and on every amendment route
|
|
141
141
|
charterAdvice: (input) => require('../charter-advice').charterAdvice(input), // task 1005119: advice only, never a write
|
|
142
142
|
genesisStageStatus: board.genesisStageStatus,
|
|
143
143
|
// task 1004529 — who decides what. The table, resolved, and the check a
|
|
@@ -972,7 +972,7 @@ module.exports = function buildGovernmentRouter() {
|
|
|
972
972
|
});
|
|
973
973
|
}
|
|
974
974
|
try {
|
|
975
|
-
const out = await
|
|
975
|
+
const out = await require('../sole-member').proposeAmendment({
|
|
976
976
|
proposal: req.body.proposal,
|
|
977
977
|
rationaleMd: req.body.rationale_md ?? null,
|
|
978
978
|
proposedBy: req.builder.id,
|
|
@@ -1002,7 +1002,7 @@ module.exports = function buildGovernmentRouter() {
|
|
|
1002
1002
|
if (wording && typeof wording.flagInvestmentWording === 'function' && typeof req.body.rationale_md === 'string') {
|
|
1003
1003
|
wording.flagInvestmentWording(req.body.rationale_md, { where: 'board item', ref: out.item && out.item.id });
|
|
1004
1004
|
}
|
|
1005
|
-
res.status(201).json({ ok: true, amendment: out.amendment, item: out.item });
|
|
1005
|
+
res.status(201).json({ ok: true, amendment: out.amendment, item: out.item , decided: out.decided });
|
|
1006
1006
|
} catch (err) {
|
|
1007
1007
|
log.error('[gds] POST /government/board/items', err);
|
|
1008
1008
|
res.fail('propose_failed', { status: 500, message: 'internal error' });
|
|
@@ -1033,7 +1033,7 @@ module.exports = function buildGovernmentRouter() {
|
|
|
1033
1033
|
return res.fail('invalid_proposal', 400, { reason: 'bad_window_minutes', expected: 'null (no clock) or a whole number of minutes' });
|
|
1034
1034
|
}
|
|
1035
1035
|
try {
|
|
1036
|
-
const out = await
|
|
1036
|
+
const out = await require('../sole-member').proposeCharter({
|
|
1037
1037
|
form: body.form ?? undefined,
|
|
1038
1038
|
franchise: body.franchise ?? undefined,
|
|
1039
1039
|
councilRank: body.council_rank ?? undefined,
|
|
@@ -1054,7 +1054,7 @@ module.exports = function buildGovernmentRouter() {
|
|
|
1054
1054
|
});
|
|
1055
1055
|
}
|
|
1056
1056
|
if (out.reason === 'no_change') {
|
|
1057
|
-
return res.fail('no_change', 409, { reason: 'the constitution in force already has these settings' });
|
|
1057
|
+
return res.fail('no_change', 409, { reason: 'the constitution in force already has these settings', in_force: out.in_force, complete: true, message: out.message });
|
|
1058
1058
|
}
|
|
1059
1059
|
return res.fail('invalid_charter', 400, {
|
|
1060
1060
|
reason: out.reason,
|
|
@@ -1063,7 +1063,7 @@ module.exports = function buildGovernmentRouter() {
|
|
|
1063
1063
|
...(out.rank_key ? { rank_key: out.rank_key } : {}),
|
|
1064
1064
|
});
|
|
1065
1065
|
}
|
|
1066
|
-
res.status(201).json({ ok: true, amendment: out.amendment, item: out.item, reads_as: out.reads_as });
|
|
1066
|
+
res.status(201).json({ ok: true, amendment: out.amendment, item: out.item, reads_as: out.reads_as, what_happens_next: out.what_happens_next , decided: out.decided });
|
|
1067
1067
|
} catch (err) {
|
|
1068
1068
|
log.error('[gds] POST /government/board/charter', err);
|
|
1069
1069
|
res.fail('charter_failed', { status: 500, message: 'internal error' });
|
|
@@ -1090,7 +1090,7 @@ module.exports = function buildGovernmentRouter() {
|
|
|
1090
1090
|
rationale_md: { type: 'string', maxLength: 5000 },
|
|
1091
1091
|
})) return;
|
|
1092
1092
|
try {
|
|
1093
|
-
const out = await
|
|
1093
|
+
const out = await require('../sole-member').proposeDecisionRules({
|
|
1094
1094
|
rules: req.body.rules,
|
|
1095
1095
|
rationaleMd: req.body.rationale_md ?? null,
|
|
1096
1096
|
proposedBy: req.builder.id,
|
|
@@ -1105,7 +1105,7 @@ module.exports = function buildGovernmentRouter() {
|
|
|
1105
1105
|
...(out.rank_key ? { rank_key: out.rank_key } : {}),
|
|
1106
1106
|
});
|
|
1107
1107
|
}
|
|
1108
|
-
res.status(201).json({ ok: true, amendment: out.amendment, item: out.item, rules: out.rules });
|
|
1108
|
+
res.status(201).json({ ok: true, amendment: out.amendment, item: out.item, rules: out.rules , decided: out.decided });
|
|
1109
1109
|
} catch (err) {
|
|
1110
1110
|
log.error('[gds] POST /government/board/decision-rules', err);
|
|
1111
1111
|
res.fail('propose_failed', { status: 500, message: 'internal error' });
|
|
@@ -1129,7 +1129,7 @@ module.exports = function buildGovernmentRouter() {
|
|
|
1129
1129
|
rationale_md: { type: 'string', maxLength: 5000 },
|
|
1130
1130
|
})) return;
|
|
1131
1131
|
try {
|
|
1132
|
-
const out = await
|
|
1132
|
+
const out = await require('../sole-member').proposeLaws({
|
|
1133
1133
|
enact: req.body.enact,
|
|
1134
1134
|
repeal: req.body.repeal,
|
|
1135
1135
|
rationaleMd: req.body.rationale_md ?? null,
|
|
@@ -1150,7 +1150,7 @@ module.exports = function buildGovernmentRouter() {
|
|
|
1150
1150
|
...(out.setting ? { setting: out.setting } : {}),
|
|
1151
1151
|
});
|
|
1152
1152
|
}
|
|
1153
|
-
res.status(201).json({ ok: true, amendment: out.amendment, item: out.item, proposal: out.proposal });
|
|
1153
|
+
res.status(201).json({ ok: true, amendment: out.amendment, item: out.item, proposal: out.proposal , decided: out.decided });
|
|
1154
1154
|
} catch (err) {
|
|
1155
1155
|
log.error('[gds] POST /government/board/laws', err);
|
|
1156
1156
|
res.fail('propose_failed', { status: 500, message: 'internal error' });
|
|
@@ -1200,14 +1200,14 @@ module.exports = function buildGovernmentRouter() {
|
|
|
1200
1200
|
if (sw.reason === 'preset_in_force') return res.fail('preset_in_force', 409, { preset: req.body.preset });
|
|
1201
1201
|
return res.fail('invalid_preset', 400, { reason: sw.reason, ...(sw.expected ? { expected: sw.expected } : {}) });
|
|
1202
1202
|
}
|
|
1203
|
-
const out = await
|
|
1203
|
+
const out = await require('../sole-member').proposeLaws({ ...sw.proposal, rationaleMd: sw.rationale, proposedBy: req.builder.id });
|
|
1204
1204
|
if (!out.ok) {
|
|
1205
1205
|
if (out.reason === 'already_in_force' || out.reason === 'setting_already_locked' || out.reason === 'not_in_force') {
|
|
1206
1206
|
return res.fail(out.reason, 409, { ...(out.law_id ? { law_id: out.law_id } : {}), ...(out.setting ? { setting: out.setting } : {}) });
|
|
1207
1207
|
}
|
|
1208
1208
|
return res.fail('invalid_preset', 400, { reason: out.reason });
|
|
1209
1209
|
}
|
|
1210
|
-
res.status(201).json({ ok: true, preset: req.body.preset, amendment: out.amendment, item: out.item, proposal: out.proposal });
|
|
1210
|
+
res.status(201).json({ ok: true, preset: req.body.preset, amendment: out.amendment, item: out.item, proposal: out.proposal , decided: out.decided });
|
|
1211
1211
|
} catch (err) {
|
|
1212
1212
|
log.error('[gds] POST /government/board/presets', err);
|
|
1213
1213
|
res.fail('propose_failed', { status: 500, message: 'internal error' });
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// modules/government/sole-member.js — a board of ONE decides by filing (task 1005130,
|
|
3
|
+
// part C; owner feedback 2026-11-07, the ruling task 1005121 shipped for closing a
|
|
4
|
+
// genesis stage, applied wherever it holds).
|
|
5
|
+
//
|
|
6
|
+
// WHY. Under monarchy the board is one person, and a sitting it opens exists only to
|
|
7
|
+
// collect that person's yes. Asking them to vote on their own instruction is a second
|
|
8
|
+
// step that decides nothing: "the government is in place after it is defined, but it
|
|
9
|
+
// still asked me to cast a vote". So when the SOLE member files an amendment, their
|
|
10
|
+
// yes is cast in the same call and the sitting closes.
|
|
11
|
+
//
|
|
12
|
+
// EXACTLY THREE CONDITIONS, read on the server from the item's own snapshot — the
|
|
13
|
+
// constitution in force at the moment of filing — never from the request:
|
|
14
|
+
// 1. the pass rule is first_ratifier (one yes decides);
|
|
15
|
+
// 2. the board that snapshot seats has exactly one member (live rows, uncached);
|
|
16
|
+
// 3. the person filing IS that member.
|
|
17
|
+
// Anything else — two or more members, any other rule, a caller who is not the sole
|
|
18
|
+
// member — returns the filing untouched, byte for byte what it was before. Under
|
|
19
|
+
// first_ratifier the first yes closes regardless of a window or early-close setting,
|
|
20
|
+
// so neither changes the outcome here.
|
|
21
|
+
//
|
|
22
|
+
// NOTHING IS SKIPPED. The filing ran its own permission gate, strict validation and
|
|
23
|
+
// self-removal guard before this is reached; the yes goes through board.castVote,
|
|
24
|
+
// which re-checks membership under the snapshot, the window and the clock; the close
|
|
25
|
+
// goes through board.maybeCloseAfterVote → closeItem, so a passed amendment is applied
|
|
26
|
+
// and recorded exactly as a clicked ballot's would be. The vote is that member's,
|
|
27
|
+
// attributed and timestamped, marked cast_via 'filed' (government_039) so the record
|
|
28
|
+
// tells it from a ballot — the stage-close precedent — and it earns no vote karma.
|
|
29
|
+
//
|
|
30
|
+
// FAIL-OPEN. The filing already stands; if the confirm or the close fails, the
|
|
31
|
+
// sitting is simply left open for an ordinary vote, as before this existed.
|
|
32
|
+
//
|
|
33
|
+
// Composed only from board.js's exports, looked up at call time (genesis-close.js's
|
|
34
|
+
// pattern), so a test's stubs on board's db bite exactly as they do for board itself.
|
|
35
|
+
const board = require('./board.js');
|
|
36
|
+
const { resolveBoardMembers } = require('./board-membership.js');
|
|
37
|
+
const log = require('../../src/module-api').logger('government');
|
|
38
|
+
|
|
39
|
+
// confirmIfSoleMember(out, filedBy) → the filing's answer, plus `decided` when the
|
|
40
|
+
// sole member's filing decided it: { closed, outcome, cast_via, constitution_not_applied? }.
|
|
41
|
+
async function confirmIfSoleMember(out, filedBy) {
|
|
42
|
+
if (!out || !out.ok || !out.item || filedBy == null || filedBy === '') return out;
|
|
43
|
+
const item = out.item;
|
|
44
|
+
const constitution = item.constitution || {};
|
|
45
|
+
if (constitution.pass_rule !== 'first_ratifier') return out;
|
|
46
|
+
try {
|
|
47
|
+
const members = await resolveBoardMembers(constitution);
|
|
48
|
+
if (members.length !== 1 || String(members[0].builder_id) !== String(filedBy)) return out;
|
|
49
|
+
const v = await board.castVote({ itemId: item.id, voterId: filedBy, direction: 'yes', castVia: 'filed' });
|
|
50
|
+
if (!v.ok || !v.fresh) return out;
|
|
51
|
+
const c = await board.maybeCloseAfterVote(item.id);
|
|
52
|
+
return {
|
|
53
|
+
...out,
|
|
54
|
+
decided: {
|
|
55
|
+
closed: !!(c && c.closed),
|
|
56
|
+
outcome: (c && c.outcome) || null,
|
|
57
|
+
cast_via: 'filed',
|
|
58
|
+
...(c && c.constitution_not_applied ? { constitution_not_applied: c.constitution_not_applied } : {}),
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
} catch (err) {
|
|
62
|
+
log.error('[government] sole-member confirm failed (the filing stands; the sitting is left open):', err);
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The amendment filers, each followed by the confirm. Same arguments, same answer,
|
|
68
|
+
// plus `decided` when a board of one decided it.
|
|
69
|
+
const wrap = (name) => async (args = {}) => confirmIfSoleMember(await board[name](args), args.proposedBy);
|
|
70
|
+
const proposeAmendment = wrap('proposeAmendment');
|
|
71
|
+
const proposeCharter = wrap('proposeCharter');
|
|
72
|
+
const proposeLaws = wrap('proposeLaws');
|
|
73
|
+
const proposeDecisionRules = wrap('proposeDecisionRules');
|
|
74
|
+
|
|
75
|
+
module.exports = { proposeAmendment, proposeCharter, proposeLaws, proposeDecisionRules };
|
|
@@ -35,9 +35,11 @@
|
|
|
35
35
|
|
|
36
36
|
// What saving the plan did to the government, when it was not settled in the same
|
|
37
37
|
// click. The server's charter outcome (modules/onboarding/genesis-home.js
|
|
38
|
-
// applyGovernmentChoice)
|
|
38
|
+
// applyGovernmentChoice). The server's own words come first (task 1005130): an
|
|
39
|
+
// already-in-force choice is said plainly, never silently; none needs no sentence.
|
|
39
40
|
function charterNote(c) {
|
|
40
41
|
if (!c) return '';
|
|
42
|
+
if ((c.state === 'in_force' || c.state === 'sitting' || c.state === 'adopted') && c.message) return c.message;
|
|
41
43
|
if (c.state === 'sitting') return 'Your starting government is in the Board Room for a vote.';
|
|
42
44
|
if (c.state === 'needs_council') return 'A Foundation needs a council first. Add a council rank, then choose Foundation on the Charter.';
|
|
43
45
|
if (c.state === 'not_filed') return 'The plan is saved, but the government could not be changed just now. You can change it on the Charter.';
|
|
@@ -133,7 +135,9 @@
|
|
|
133
135
|
|
|
134
136
|
// The starting government: the real academic names, each with its plain sentence
|
|
135
137
|
// (spec D10), the recommended one picked. Read from the government module's own
|
|
136
|
-
// constitution view so the names and explanations are never typed twice.
|
|
138
|
+
// constitution view so the names and explanations are never typed twice. Task
|
|
139
|
+
// 1005130: what is in force is said first and its form is marked, and each form says
|
|
140
|
+
// who decides and what closing something takes, in the server's own words.
|
|
137
141
|
function governmentHtml(gov) {
|
|
138
142
|
if (!gov || !gov.forms || !gov.forms.length) return '';
|
|
139
143
|
var rec = gov.recommended && gov.recommended.key;
|
|
@@ -142,9 +146,12 @@
|
|
|
142
146
|
return '<fieldset class="genesis__pick genesis__pick--gov"><legend>Starting government</legend>'
|
|
143
147
|
+ '<label class="genesis__expected">Builders you expect in the first year '
|
|
144
148
|
+ '<input type="number" min="1" max="100000" value="' + esc(String(gov.headcount || 1)) + '" data-expected></label>'
|
|
149
|
+
+ (gov.in_force && gov.in_force.line ? '<p class="genesis__later">' + esc(gov.in_force.line) + '</p>' : '')
|
|
145
150
|
+ avail.map(function (f) {
|
|
146
|
-
var
|
|
147
|
-
|
|
151
|
+
var duty = f.who_decides ? ' ' + f.who_decides + ' ' + (f.to_close || '') : '';
|
|
152
|
+
var hint = f.explanation + duty + (f.key === rec && gov.recommended.why ? ' Recommended: ' + gov.recommended.why : '');
|
|
153
|
+
var tags = (f.current ? ' · in force now' : '') + (f.key === rec ? ' · recommended' : '');
|
|
154
|
+
return radio('gov', f.key, f.name + tags, hint, f.key === rec);
|
|
148
155
|
}).join('')
|
|
149
156
|
+ (later.length ? '<p class="genesis__later">Coming later: ' + later.map(function (f) { return esc(f.name); }).join(', ') + '.</p>' : '')
|
|
150
157
|
+ '</fieldset>';
|
|
@@ -385,7 +392,7 @@
|
|
|
385
392
|
var q = expected ? '?headcount=' + encodeURIComponent(expected) : '';
|
|
386
393
|
return send('GET', '/government/constitution' + q).then(function (c) {
|
|
387
394
|
var ch = c && c.charter;
|
|
388
|
-
return ch ? { forms: ch.forms || [], recommended: c.recommended || null, headcount: expected || c.headcount || 1 } : null;
|
|
395
|
+
return ch ? { forms: ch.forms || [], in_force: ch.in_force || null, recommended: c.recommended || null, headcount: expected || c.headcount || 1 } : null;
|
|
389
396
|
}).catch(function () { return null; });
|
|
390
397
|
}
|
|
391
398
|
|
|
@@ -395,7 +395,10 @@ async function savePlan(body, builderId, deps = {}) {
|
|
|
395
395
|
// Never refuses the plan — the plan is saved first, and a choice that cannot be filed
|
|
396
396
|
// says why in `state`:
|
|
397
397
|
// none no government was picked
|
|
398
|
-
// 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
|
|
399
402
|
// sitting filed; `item_id`, and `one_vote_decides` when one yes carries it
|
|
400
403
|
// needs_council Foundation, which needs a council rank before it can seat anyone
|
|
401
404
|
// not_filed anything else (`reason`), or no government module to file it with
|
|
@@ -411,16 +414,25 @@ async function applyGovernmentChoice(form, builderId, s) {
|
|
|
411
414
|
}
|
|
412
415
|
if (out && out.ok) {
|
|
413
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
|
+
}
|
|
414
424
|
return {
|
|
415
425
|
state: 'sitting',
|
|
416
426
|
form,
|
|
417
427
|
item_id: out.item ? String(out.item.id) : null,
|
|
418
428
|
one_vote_decides: snap.pass_rule === 'first_ratifier',
|
|
419
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
|
|
420
431
|
};
|
|
421
432
|
}
|
|
422
433
|
const reason = (out && out.reason) || 'charter_failed';
|
|
423
|
-
|
|
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 };
|
|
424
436
|
if (reason === 'council_needs_a_rank') return { state: 'needs_council', form };
|
|
425
437
|
return { state: 'not_filed', form, reason };
|
|
426
438
|
}
|
|
@@ -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,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;
|