@bongos/core 1.20.26 → 1.20.27
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 +78 -33
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +5 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0353-a-hub-invite-is-a-notice-of-the-projects-own-invite.md +1 -1
- package/docs/adr/0355-a-soft-limit-is-a-rule-with-a-reason-and-nobody-enforces-it.md +30 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +103 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +17 -17
- package/docs/copy-registry.json +18 -18
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +61 -59
- package/modules/government/board.js +64 -7
- package/modules/government/charter.js +232 -0
- package/modules/government/routes/government.js +62 -0
- package/modules/hall-ui/public/government.js +11 -0
- package/modules/platform-identity/migrations/platform_identity_028_purge_pre_notice_invites.sql +65 -0
- package/modules/provisioning/planet-physics.js +154 -0
- package/modules/provisioning/provisioning.js +29 -9
- package/modules/provisioning/routes/body-validators.js +25 -7
- package/modules/provisioning/routes/provisioning.js +8 -3
- package/modules/provisioning/screening.js +62 -0
- package/modules/provisioning/soft-limits.js +146 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +14 -0
- package/scripts/gds/run-unit-tests.js +4 -0
- package/src/bongos/route-rank-check.js +3 -0
- package/src/module-api.js +1 -1
- package/tests/government_charter_forms.mjs +283 -0
- package/tests/pending_invite_purge_db.mjs +129 -0
- package/tests/provisioning_planet_physics.mjs +377 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// tests/government_charter_forms.mjs — the genesis charter and legislation
|
|
2
|
+
// stages, on the real constitution (task 1004427 / BV2.PS13, spec decision D10).
|
|
3
|
+
//
|
|
4
|
+
// DB-free (db monkeypatched; the apply runs against a REAL temp file). The
|
|
5
|
+
// invariants:
|
|
6
|
+
//
|
|
7
|
+
// 1. THE REAL NAMES, EACH EXPLAINED — four forms available today (Monarchy /
|
|
8
|
+
// BDFL, Timocratic Republic, Direct Democracy, Foundation), three coming
|
|
9
|
+
// later (Presidential, Cooperative, Federation); seven variables with the
|
|
10
|
+
// government doc's names. Every one carries a short plain explanation.
|
|
11
|
+
// The names are held equal to the hall's charter library, so the genesis
|
|
12
|
+
// stage and the Constitution tab can never call a form two things.
|
|
13
|
+
// 2. TWO VARIABLES ARE LIVE, ONE IS ALWAYS ON, THE REST COME LATER — franchise
|
|
14
|
+
// and pass rule are the real board block; loyalty of enforcement is always
|
|
15
|
+
// on; nothing that is not built claims to be.
|
|
16
|
+
// 3. THE NAME FOLLOWS THE SETTINGS — read from what the constitution SEATS,
|
|
17
|
+
// so two spellings of one board read the same; anything else is "custom".
|
|
18
|
+
// 4. APPLYING WRITES THE REAL CONSTITUTION THROUGH ITS AMENDMENT PATH — the
|
|
19
|
+
// choice becomes an ordinary amendment, validated strictly, decided under
|
|
20
|
+
// the rule in force; under monarchy the founder's yes applies it.
|
|
21
|
+
import assert from 'node:assert/strict';
|
|
22
|
+
import { test } from 'node:test';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import os from 'node:os';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import vm from 'node:vm';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
32
|
+
const GOV = path.join(ROOT, 'modules', 'government');
|
|
33
|
+
const charter = require(path.join(GOV, 'charter.js'));
|
|
34
|
+
const board = require(path.join(GOV, 'board.js'));
|
|
35
|
+
const db = require(path.join(GOV, 'db.js'));
|
|
36
|
+
const config = require(path.join(GOV, 'config.js'));
|
|
37
|
+
const { parseMembershipPredicate } = require(path.join(GOV, 'membership-predicate.js'));
|
|
38
|
+
|
|
39
|
+
const { FORMS, VARIABLES, FRANCHISE_OPTIONS, PASS_RULE_OPTIONS, TRAJECTORIES, readCharter, buildCharterProposal } = charter;
|
|
40
|
+
|
|
41
|
+
const MONARCHY = Object.freeze({
|
|
42
|
+
membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
async function withDb(stubs, fn) {
|
|
46
|
+
const originals = {};
|
|
47
|
+
for (const [k, v] of Object.entries(stubs)) { originals[k] = db[k]; db[k] = v; }
|
|
48
|
+
try { return await fn(); } finally { for (const [k, v] of Object.entries(originals)) db[k] = v; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A plain explanation: present, a sentence or two, and not merely the name again.
|
|
52
|
+
function assertExplained(entry, what) {
|
|
53
|
+
assert.equal(typeof entry.explanation, 'string', `${what} carries an explanation`);
|
|
54
|
+
const sentences = entry.explanation.split(/[.!?](\s|$)/).filter((s) => s && s.trim()).length;
|
|
55
|
+
assert.ok(entry.explanation.length >= 30 && sentences >= 1 && sentences <= 3,
|
|
56
|
+
`${what}: one or two plain sentences, got "${entry.explanation}"`);
|
|
57
|
+
assert.notEqual(entry.explanation.trim().toLowerCase(), String(entry.name).toLowerCase());
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── invariant 1: the real names, each explained ──────────────────────────────
|
|
61
|
+
|
|
62
|
+
test('the forms carry the real academic names — four today, three coming later', () => {
|
|
63
|
+
assert.deepEqual(FORMS.filter((f) => f.available).map((f) => f.name),
|
|
64
|
+
['Monarchy / BDFL', 'Timocratic Republic', 'Direct Democracy', 'Foundation']);
|
|
65
|
+
assert.deepEqual(FORMS.filter((f) => !f.available).map((f) => f.name),
|
|
66
|
+
['Presidential', 'Cooperative', 'Federation']);
|
|
67
|
+
for (const f of FORMS) assertExplained(f, `form ${f.key}`);
|
|
68
|
+
assert.ok(Object.isFrozen(FORMS) && FORMS.every(Object.isFrozen));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('the variables are the government doc\'s seven, each with its question and explanation', () => {
|
|
72
|
+
assert.deepEqual(VARIABLES.map((v) => v.key), [
|
|
73
|
+
'franchise', 'pass_rule', 'veto_points', 'executive_legislative', 'judicial_review',
|
|
74
|
+
'loyalty_of_enforcement', 'trajectory',
|
|
75
|
+
]);
|
|
76
|
+
assert.deepEqual(VARIABLES.map((v) => v.name), [
|
|
77
|
+
'Franchise', 'Pass rule (winning coalition)', 'Veto points', 'Executive–legislative relationship',
|
|
78
|
+
'Judicial review', 'Loyalty of enforcement', 'Trajectory',
|
|
79
|
+
]);
|
|
80
|
+
for (const v of VARIABLES) {
|
|
81
|
+
assertExplained(v, `variable ${v.key}`);
|
|
82
|
+
assert.ok(typeof v.question === 'string' && v.question.endsWith('?'), `${v.key} asks its question plainly`);
|
|
83
|
+
}
|
|
84
|
+
for (const o of [...FRANCHISE_OPTIONS, ...PASS_RULE_OPTIONS, ...TRAJECTORIES]) assertExplained(o, `option ${o.key}`);
|
|
85
|
+
assert.deepEqual(TRAJECTORIES.map((t) => t.name), ['Locked', 'Laddered', 'Scheduled']);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('the names are the hall charter library\'s names — one form, one name, everywhere', () => {
|
|
89
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'government.js'), 'utf8');
|
|
90
|
+
const block = src.slice(src.indexOf('const CHARTERS = ['), src.indexOf('const TRAJECTORIES = ['));
|
|
91
|
+
const hall = [...block.matchAll(/key: '([^']+)', name: '([^']+)', selectable: (true|false)/g)]
|
|
92
|
+
.map((m) => ({ key: m[1], name: m[2], selectable: m[3] === 'true' }));
|
|
93
|
+
assert.equal(hall.length, 7, 'sanity: the hall library lists seven structures');
|
|
94
|
+
const byKey = new Map(hall.map((h) => [h.key, h]));
|
|
95
|
+
for (const f of FORMS) {
|
|
96
|
+
const h = byKey.get(f.key);
|
|
97
|
+
assert.ok(h, `the hall library knows ${f.key}`);
|
|
98
|
+
assert.equal(f.name, h.name, `${f.key} has one name`);
|
|
99
|
+
assert.equal(f.available, h.selectable, `${f.key}: available here iff selectable there`);
|
|
100
|
+
}
|
|
101
|
+
const trajectories = src.slice(src.indexOf('const TRAJECTORIES = ['), src.indexOf('const LADDER ='));
|
|
102
|
+
for (const t of TRAJECTORIES) assert.ok(trajectories.includes(`name: '${t.name}'`), `trajectory ${t.name} matches the hall`);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ── invariant 2: what is live, what is always on, what comes later ───────────
|
|
106
|
+
|
|
107
|
+
test('franchise and pass rule are live; loyalty of enforcement is always on; the rest come later', () => {
|
|
108
|
+
const status = Object.fromEntries(VARIABLES.map((v) => [v.key, v.status]));
|
|
109
|
+
assert.deepEqual(status, {
|
|
110
|
+
franchise: 'live', pass_rule: 'live', veto_points: 'later', executive_legislative: 'later',
|
|
111
|
+
judicial_review: 'later', loyalty_of_enforcement: 'always_on', trajectory: 'later',
|
|
112
|
+
});
|
|
113
|
+
assert.deepEqual([...PASS_RULE_OPTIONS.map((o) => o.key)].sort(), [...config.PASS_RULES].sort(),
|
|
114
|
+
'every pass rule the constitution knows is offered, and nothing else');
|
|
115
|
+
for (const o of FRANCHISE_OPTIONS.filter((x) => x.predicate)) {
|
|
116
|
+
assert.ok(parseMembershipPredicate(o.predicate), `${o.key}'s predicate parses under the REAL grammar`);
|
|
117
|
+
}
|
|
118
|
+
assert.deepEqual(parseMembershipPredicate(FRANCHISE_OPTIONS.find((o) => o.key === 'everyone').predicate).rankKeys,
|
|
119
|
+
['xenos', 'thetes', 'metic', 'archon'], '"every builder" really seats every rank (the + is load-bearing)');
|
|
120
|
+
// The band's pass-rule words (task 1004429) are these options' explanations —
|
|
121
|
+
// one sentence per rule, written once.
|
|
122
|
+
for (const o of PASS_RULE_OPTIONS) assert.equal(board.PASS_RULE_WORDS[o.key], o.explanation);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ── invariant 3: the name follows the settings ───────────────────────────────
|
|
126
|
+
|
|
127
|
+
test('the shipped default reads as Monarchy / BDFL, and each available form reads as itself', () => {
|
|
128
|
+
assert.equal(readCharter(config.BOARD_DEFAULTS).form, 'monarchy');
|
|
129
|
+
for (const f of FORMS.filter((x) => x.available)) {
|
|
130
|
+
const built = buildCharterProposal(MONARCHY, { form: f.key, councilRank: 'trustees' });
|
|
131
|
+
assert.equal(built.ok, true, f.key);
|
|
132
|
+
assert.equal(readCharter(built.proposal).form, f.key, `choosing ${f.key} makes the constitution read as ${f.key}`);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('the name reads what the board SEATS, so two spellings of one board read the same', () => {
|
|
137
|
+
const tim = { ...MONARCHY, pass_rule: 'consent' };
|
|
138
|
+
assert.equal(readCharter({ ...tim, membership: 'rank:metic+' }).form, 'timocratic-republic');
|
|
139
|
+
assert.equal(readCharter({ ...tim, membership: 'rank:metic,archon' }).form, 'timocratic-republic');
|
|
140
|
+
assert.equal(readCharter({ ...tim, membership: 'rank:xenos+' }).form, 'direct-democracy');
|
|
141
|
+
assert.equal(readCharter({ ...tim, membership: 'rank:trustees' }).form, 'foundation');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('any other combination reads "custom" — never the nearest name', () => {
|
|
145
|
+
const custom = [
|
|
146
|
+
{ ...MONARCHY, pass_rule: 'consent' }, // Archons by consent
|
|
147
|
+
{ ...MONARCHY, membership: 'rank:xenos+', pass_rule: 'majority' },
|
|
148
|
+
{ ...MONARCHY, membership: 'rank:metic' }, // exactly metic, no Archons
|
|
149
|
+
{ ...MONARCHY, membership: 'rank:metic+', pass_rule: 'first_ratifier' },
|
|
150
|
+
{ ...MONARCHY, membership: 'not a predicate' },
|
|
151
|
+
];
|
|
152
|
+
for (const b of custom) {
|
|
153
|
+
const r = readCharter(b);
|
|
154
|
+
assert.equal(r.form, 'custom', JSON.stringify(b));
|
|
155
|
+
assert.equal(r.name, 'Custom');
|
|
156
|
+
assertExplained(r, 'the custom reading');
|
|
157
|
+
}
|
|
158
|
+
const r = readCharter({ ...MONARCHY, membership: 'rank:xenos+', pass_rule: 'majority' });
|
|
159
|
+
assert.equal(r.franchise, 'everyone', 'the settings are still read individually');
|
|
160
|
+
assert.equal(r.pass_rule, 'majority');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ── invariant 4: applying writes the real constitution via an amendment ──────
|
|
164
|
+
|
|
165
|
+
test('a charter choice builds a whole, strictly-valid constitution that keeps the fields it does not set', () => {
|
|
166
|
+
const live = { ...MONARCHY, window_minutes: 90, close_early_on_full_turnout: false };
|
|
167
|
+
const out = buildCharterProposal(live, { form: 'direct-democracy' });
|
|
168
|
+
assert.equal(out.ok, true);
|
|
169
|
+
assert.deepEqual(out.proposal, {
|
|
170
|
+
membership: 'rank:xenos+', pass_rule: 'consent', window_minutes: 90, close_early_on_full_turnout: false,
|
|
171
|
+
}, 'the clock and turnout rules are not the charter\'s to change — they are kept');
|
|
172
|
+
assert.equal(board.validateProposedConstitution(out.proposal).ok, true);
|
|
173
|
+
// Legislation fine-tunes on top of a form.
|
|
174
|
+
const tuned = buildCharterProposal(live, { form: 'direct-democracy', passRule: 'majority', windowMinutes: 1440 });
|
|
175
|
+
assert.deepEqual([tuned.proposal.pass_rule, tuned.proposal.window_minutes], ['majority', 1440]);
|
|
176
|
+
assert.equal(readCharter(tuned.proposal).form, 'custom');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('choices that are not real are refused by name, before anything is filed', () => {
|
|
180
|
+
const cases = [
|
|
181
|
+
[{ form: 'presidential' }, 'form_not_available'],
|
|
182
|
+
[{ form: 'anarchy' }, 'unknown_form'],
|
|
183
|
+
[{ franchise: 'the loudest' }, 'unknown_franchise'],
|
|
184
|
+
[{ franchise: 'council' }, 'council_needs_a_rank'],
|
|
185
|
+
[{ franchise: 'council', councilRank: 'metic' }, 'council_needs_a_custom_rank'],
|
|
186
|
+
[{ franchise: 'council', councilRank: 'Not A Key' }, 'council_needs_a_custom_rank'],
|
|
187
|
+
[{ passRule: 'acclamation' }, 'unknown_pass_rule'],
|
|
188
|
+
[{}, 'nothing_chosen'],
|
|
189
|
+
];
|
|
190
|
+
for (const [choice, reason] of cases) {
|
|
191
|
+
const out = buildCharterProposal(MONARCHY, choice);
|
|
192
|
+
assert.equal(out.ok, false, JSON.stringify(choice));
|
|
193
|
+
assert.equal(out.reason, reason, JSON.stringify(choice));
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('the founder picks Direct Democracy and ratifies it: the amendment path writes the real constitution', async () => {
|
|
198
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gov-charter-'));
|
|
199
|
+
const target = path.join(dir, 'government.json');
|
|
200
|
+
const originalApply = config.applyBoardAmendment;
|
|
201
|
+
const applied = [];
|
|
202
|
+
config.applyBoardAmendment = (proposed) => { applied.push(proposed); fs.writeFileSync(target, JSON.stringify({ board: proposed })); return proposed; };
|
|
203
|
+
let filed = null;
|
|
204
|
+
const votes = [];
|
|
205
|
+
try {
|
|
206
|
+
await withDb({
|
|
207
|
+
builderHoldsRankActive: async () => true, // the founder keeps their seat either way
|
|
208
|
+
createAmendment: async (args) => { filed = args; return { id: '5', proposed: args.proposed, proposed_by: args.proposedBy }; },
|
|
209
|
+
openBoardItem: async (args) => ({ id: '21', subject_type: args.subjectType, subject_id: '5', opened_by: args.openedBy, constitution: args.constitution, closes_at: null, closed_at: null }),
|
|
210
|
+
getBoardItemForVote: async () => ({ id: '21', subject_type: 'constitutional_amendment', subject_id: '5', opened_by: '42', constitution: MONARCHY, closed_at: null, expired: false }),
|
|
211
|
+
insertBoardVote: async (row) => { const v = { voter_id: row.voterId, direction: row.direction }; votes.push(v); return v; },
|
|
212
|
+
insertVoteKarma: async () => {},
|
|
213
|
+
getVotesForItem: async () => votes,
|
|
214
|
+
getActiveBuildersWithRank: async () => [{ builder_id: '42', github_login: 'founder', display_name: 'Founder' }],
|
|
215
|
+
getBoardItemAuthor: async () => '42',
|
|
216
|
+
closeBoardItem: async ({ outcome }) => ({ id: '21', subject_type: 'constitutional_amendment', subject_id: '5', opened_by: '42', closed_at: 'T', outcome }),
|
|
217
|
+
insertAuthorRatifyKarma: async () => true,
|
|
218
|
+
getAmendment: async () => ({ id: '5', proposed: filed.proposed }),
|
|
219
|
+
}, async () => {
|
|
220
|
+
const out = await board.proposeCharter({ form: 'direct-democracy', proposedBy: '42' });
|
|
221
|
+
assert.equal(out.ok, true);
|
|
222
|
+
assert.equal(out.reads_as.form, 'direct-democracy');
|
|
223
|
+
assert.equal(filed.proposed.membership, 'rank:xenos+');
|
|
224
|
+
assert.match(filed.rationaleMd, /Direct Democracy/, 'the record says in words what was chosen');
|
|
225
|
+
assert.equal(out.item.constitution.pass_rule, config.loadGovernmentConfig().board.pass_rule,
|
|
226
|
+
'decided under the constitution IN FORCE, not the one proposed');
|
|
227
|
+
await board.castVote({ itemId: '21', voterId: '42', direction: 'yes' });
|
|
228
|
+
const closed = await board.maybeCloseAfterVote('21');
|
|
229
|
+
assert.equal(closed.outcome, 'passed');
|
|
230
|
+
});
|
|
231
|
+
assert.deepEqual(applied, [filed.proposed], 'the ratified text is exactly what was filed');
|
|
232
|
+
assert.equal(JSON.parse(fs.readFileSync(target, 'utf8')).board.membership, 'rank:xenos+');
|
|
233
|
+
} finally {
|
|
234
|
+
config.applyBoardAmendment = originalApply;
|
|
235
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('a charter that changes nothing files nothing, and a council must be a rank that exists', async () => {
|
|
240
|
+
// This checkout runs the neutral monarchy (a guard test pins the vanilla state).
|
|
241
|
+
assert.equal(readCharter(config.loadGovernmentConfig().board).form, 'monarchy');
|
|
242
|
+
await withDb({
|
|
243
|
+
createAmendment: async () => { throw new Error('must not be reached'); },
|
|
244
|
+
getRankByKey: async (key) => (key === 'trustees' ? { rank_key: 'trustees' } : null),
|
|
245
|
+
builderHoldsRankActive: async () => true,
|
|
246
|
+
}, async () => {
|
|
247
|
+
assert.equal((await board.proposeCharter({ form: 'monarchy', proposedBy: '42' })).reason, 'no_change',
|
|
248
|
+
'a sitting on the constitution already in force would decide nothing');
|
|
249
|
+
assert.equal((await board.proposeCharter({ form: 'foundation', councilRank: 'ghosts', proposedBy: '42' })).reason, 'unknown_council_rank');
|
|
250
|
+
assert.equal((await board.proposeCharter({ form: 'direct-democracy', proposedBy: null })).reason, 'missing_proposer');
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ── the surfaces ─────────────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
test('the constitution view carries the charter: forms, variables, and what the rule in force reads as', async () => {
|
|
257
|
+
await withDb({ listAmendmentHistory: async () => [], countActiveBuilders: async () => 1 }, async () => {
|
|
258
|
+
const view = await board.constitutionView();
|
|
259
|
+
assert.equal(view.charter.forms.length, FORMS.length);
|
|
260
|
+
assert.equal(view.charter.variables.length, VARIABLES.length);
|
|
261
|
+
assert.deepEqual(view.charter.reads_as, readCharter(view.board));
|
|
262
|
+
});
|
|
263
|
+
const routes = fs.readFileSync(path.join(GOV, 'routes', 'government.js'), 'utf8');
|
|
264
|
+
assert.match(routes, /router\.post\('\/government\/board\/charter', api\.requireBuilder, api\.requirePermission\('board\.item\.open'\)/);
|
|
265
|
+
assert.match(routes, /proposeCharter: board\.proposeCharter/);
|
|
266
|
+
const pins = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'), 'utf8');
|
|
267
|
+
assert.match(pins, /'POST \/government\/board\/charter': 'perm:board\.item\.open'/);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('the Constitution tab names the form of government in force', () => {
|
|
271
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'government.js'), 'utf8');
|
|
272
|
+
const start = src.indexOf('function formOfGovernmentSentence');
|
|
273
|
+
assert.ok(start > 0, 'the tab has a form-of-government line');
|
|
274
|
+
const fnSrc = src.slice(start, src.indexOf('\n }\n', start) + 4);
|
|
275
|
+
const ctx = { escapeHtml: (s) => String(s) };
|
|
276
|
+
vm.createContext(ctx);
|
|
277
|
+
vm.runInContext(`${fnSrc}; this.f = formOfGovernmentSentence;`, ctx);
|
|
278
|
+
const monarchy = readCharter(MONARCHY);
|
|
279
|
+
assert.match(ctx.f({ reads_as: monarchy }), /Monarchy \/ BDFL/);
|
|
280
|
+
assert.match(ctx.f({ reads_as: monarchy }), new RegExp(monarchy.explanation.slice(0, 20).replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')));
|
|
281
|
+
assert.match(ctx.f({ reads_as: readCharter({ ...MONARCHY, pass_rule: 'consent' }) }), /Custom/);
|
|
282
|
+
assert.equal(ctx.f({}), 'Not available from this server yet.', 'an older server without the charter block is said plainly');
|
|
283
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// tests/pending_invite_purge_db.mjs — task 1004408 (owner decision on ADR 0353's
|
|
2
|
+
// open consequence). Real DB.
|
|
3
|
+
//
|
|
4
|
+
// Proves migration platform_identity_028 against real SQL: it deletes the `pending`
|
|
5
|
+
// rows the OLD hub invite wrote (they can lead to a locked door) and keeps every
|
|
6
|
+
// notice the project wrote since the hub first ran ADR 0353. The cutoff is read from
|
|
7
|
+
// the core_upgrades ledger — the earliest upgrade to 1.20.22 or later — so only a
|
|
8
|
+
// real engine can show the version comparison (numeric, not lexical), the malformed
|
|
9
|
+
// ledger rows it must skip without failing the deploy, and the one-shot guard.
|
|
10
|
+
//
|
|
11
|
+
// Each scenario builds its own tables in a throwaway schema, so the migration's
|
|
12
|
+
// table-wide DELETE can never touch a shared database's rows.
|
|
13
|
+
//
|
|
14
|
+
// Real-DB integration test: self-skips without a reachable Postgres; in the
|
|
15
|
+
// INTEGRATION set (scripts/gds/run-unit-tests.js) so the default DB-free lane
|
|
16
|
+
// never runs it.
|
|
17
|
+
//
|
|
18
|
+
// Run: DATABASE_URL=... node tests/pending_invite_purge_db.mjs
|
|
19
|
+
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
import { readFileSync } from 'node:fs';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { dirname, join } from 'node:path';
|
|
24
|
+
import { strict as assert } from 'node:assert';
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
const { Pool } = require('pg');
|
|
27
|
+
|
|
28
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const PI_MIG = join(here, '..', 'modules', 'platform-identity', 'migrations');
|
|
30
|
+
const CORE_MIG = join(here, '..', 'migrations');
|
|
31
|
+
const piSql = (f) => readFileSync(join(PI_MIG, `platform_identity_${f}.sql`), 'utf8');
|
|
32
|
+
const PURGE = piSql('028_purge_pre_notice_invites');
|
|
33
|
+
|
|
34
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined, max: 1 });
|
|
35
|
+
let client;
|
|
36
|
+
try {
|
|
37
|
+
client = await pool.connect();
|
|
38
|
+
} catch (e) {
|
|
39
|
+
console.log(`pending_invite_purge_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
40
|
+
await pool.end().catch(() => {});
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const q = (sql, params) => client.query(sql, params);
|
|
45
|
+
const at = (hh, mm = 0) => new Date(Date.UTC(2026, 8, 30, hh, mm)).toISOString();
|
|
46
|
+
const schemas = [];
|
|
47
|
+
let pass = 0;
|
|
48
|
+
const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
|
|
49
|
+
|
|
50
|
+
// A fresh hub in its own schema: the memberships table as it stands today (003, then
|
|
51
|
+
// 010 + 021's rename), schema_migrations, and — unless `ledger` is false — the
|
|
52
|
+
// core_upgrades ledger (core_189).
|
|
53
|
+
async function freshHub(tag, { ledger = true } = {}) {
|
|
54
|
+
const schema = `t1004408_${tag}_${process.pid}`;
|
|
55
|
+
schemas.push(schema);
|
|
56
|
+
await q(`CREATE SCHEMA ${schema}`);
|
|
57
|
+
await q(`SET search_path TO ${schema}`);
|
|
58
|
+
await q('CREATE TABLE schema_migrations (version text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())');
|
|
59
|
+
for (const f of ['003_project_memberships', '010_membership_kind', '021_drop_legacy_role']) await q(piSql(f));
|
|
60
|
+
if (ledger) await q(readFileSync(join(CORE_MIG, 'core_189_core_upgrades_ledger.sql'), 'utf8'));
|
|
61
|
+
}
|
|
62
|
+
const member = (client_id, github_id, kind, joinedAt) => q(
|
|
63
|
+
`INSERT INTO platform_identity_project_memberships (client_id, github_id, membership_kind, joined_at, last_active)
|
|
64
|
+
VALUES ($1, $2, $3, $4, $4)`, [client_id, github_id, kind, joinedAt]);
|
|
65
|
+
const upgraded = (toVersion, appliedAt) => q(
|
|
66
|
+
'INSERT INTO core_upgrades (to_version, applied_at) VALUES ($1, $2)', [toVersion, appliedAt]);
|
|
67
|
+
const rows = async () => (await q(
|
|
68
|
+
'SELECT client_id, github_id::int AS id, membership_kind AS kind FROM platform_identity_project_memberships ORDER BY github_id',
|
|
69
|
+
)).rows.map((r) => `${r.id}:${r.kind}`).join(' ');
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
// ── The expected case: the hub has never run ADR 0353 (it served 1.19.1081). ──
|
|
73
|
+
await freshHub('never');
|
|
74
|
+
await upgraded('1.19.1081', at(8));
|
|
75
|
+
await member('spike-harbor', 1, 'pending', at(9)); // the live example's shape
|
|
76
|
+
await member('spike-harbor', 2, 'owner', at(9));
|
|
77
|
+
await member('spike-harbor', 3, 'member', at(9));
|
|
78
|
+
await member('mercury', 4, 'visitor', at(9));
|
|
79
|
+
await member('mercury', 5, 'pending', at(10));
|
|
80
|
+
await q(PURGE);
|
|
81
|
+
ok(await rows() === '2:owner 3:member 4:visitor',
|
|
82
|
+
'no ledger row at 1.20.22+: every pending row is an old one and goes; owners, members and visitors stay');
|
|
83
|
+
ok((await q(`SELECT 1 FROM schema_migrations WHERE version = 'platform_identity_028_purge_pre_notice_invites'`)).rowCount === 1,
|
|
84
|
+
'the migration records itself');
|
|
85
|
+
|
|
86
|
+
// One-shot: a notice written after the deploy survives a replay.
|
|
87
|
+
await member('mercury', 6, 'pending', at(11));
|
|
88
|
+
await q(PURGE);
|
|
89
|
+
ok(await rows() === '2:owner 3:member 4:visitor 6:pending',
|
|
90
|
+
'replaying the recorded migration deletes nothing, so a hand-run can never sweep real notices');
|
|
91
|
+
|
|
92
|
+
// ── The hub already ran ADR 0353 before this deploy: only rows older than that go. ──
|
|
93
|
+
await freshHub('since');
|
|
94
|
+
await upgraded('garbage', at(9));
|
|
95
|
+
await upgraded('1.20.99999999999', at(9, 30)); // malformed: must be skipped, not overflow the cast
|
|
96
|
+
await upgraded('1.19.1081', at(10));
|
|
97
|
+
await upgraded('1.20.21', at(11));
|
|
98
|
+
await upgraded('1.20.22', at(12)); // the first release carrying ADR 0353
|
|
99
|
+
await upgraded('1.20.25', at(13));
|
|
100
|
+
await member('spike-harbor', 11, 'pending', at(11, 30)); // old hub invite: a locked door
|
|
101
|
+
await member('spike-harbor', 12, 'pending', at(12, 30)); // a project's own notice
|
|
102
|
+
await member('spike-harbor', 13, 'member', at(10));
|
|
103
|
+
await q(PURGE);
|
|
104
|
+
ok(await rows() === '12:pending 13:member',
|
|
105
|
+
'the earliest 1.20.22+ upgrade is the cutoff: the older pending row goes, the notice written after it stays');
|
|
106
|
+
|
|
107
|
+
// ── Versions compare as numbers, and a prefix or suffix does not hide one. ──
|
|
108
|
+
await freshHub('numeric');
|
|
109
|
+
await upgraded('1.9.0', at(9)); // '1.9.0' > '1.20.22' as TEXT — must not count
|
|
110
|
+
await upgraded('v1.20.30-rc.1', at(12));
|
|
111
|
+
await member('demo', 21, 'pending', at(11));
|
|
112
|
+
await member('demo', 22, 'pending', at(13));
|
|
113
|
+
await q(PURGE);
|
|
114
|
+
ok(await rows() === '22:pending',
|
|
115
|
+
'1.9.0 is below 1.20.22 (numeric compare), and v1.20.30-rc.1 counts as 1.20.30');
|
|
116
|
+
|
|
117
|
+
// ── An instance with no ledger table at all (the module without core_189). ──
|
|
118
|
+
await freshHub('noledger', { ledger: false });
|
|
119
|
+
await member('demo', 31, 'pending', at(9));
|
|
120
|
+
await member('demo', 32, 'owner', at(9));
|
|
121
|
+
await q(PURGE);
|
|
122
|
+
ok(await rows() === '32:owner', 'no core_upgrades table: treated as never having run ADR 0353');
|
|
123
|
+
|
|
124
|
+
console.log(`\npending_invite_purge_db.mjs: ${pass} passed`);
|
|
125
|
+
} finally {
|
|
126
|
+
for (const s of schemas) await q(`DROP SCHEMA IF EXISTS ${s} CASCADE`).catch(() => {});
|
|
127
|
+
client.release();
|
|
128
|
+
await pool.end().catch(() => {});
|
|
129
|
+
}
|