@bongos/core 1.20.26 → 1.20.28
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 +117 -47
- 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/0335-an-invite-is-a-pre-approved-row-on-the-projects-own-instance.md +1 -1
- package/docs/adr/0353-a-hub-invite-is-a-notice-of-the-projects-own-invite.md +2 -2
- package/docs/adr/0355-a-soft-limit-is-a-rule-with-a-reason-and-nobody-enforces-it.md +30 -0
- package/docs/adr/0356-a-recruit-invite-expires-30-days-after-it-is-sent-read-time.md +46 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +105 -5
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +37 -36
- package/docs/copy-registry.json +52 -43
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +70 -68
- package/migrations/core_266_access_requests_invited_login_idx.sql +31 -0
- 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/hall-ui/public/watch.js +9 -3
- package/modules/onboarding/routes/access-requests.js +43 -20
- package/modules/platform-identity/migrations/platform_identity_028_purge_pre_notice_invites.sql +65 -0
- package/modules/platform-identity/platform-identity.js +12 -8
- package/modules/platform-identity/tests/platform-identity.mjs +4 -2
- 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 +24 -0
- package/scripts/gds/fitness.js +4 -0
- package/scripts/gds/login.js +6 -0
- package/scripts/gds/run-unit-tests.js +8 -0
- package/src/bongos/db-kernel.js +6 -3
- package/src/bongos/invite-expiry.js +47 -0
- package/src/bongos/route-rank-check.js +3 -0
- package/src/module-api.js +10 -1
- package/tests/access_requests_invite.mjs +2 -1
- package/tests/application_lifecycle.mjs +164 -5
- package/tests/bongos_login.mjs +2 -0
- package/tests/government_charter_forms.mjs +283 -0
- package/tests/hub_invite_notices.mjs +5 -2
- package/tests/invite_expiry.mjs +37 -0
- package/tests/invite_expiry_db.mjs +196 -0
- package/tests/module_api.mjs +1 -0
- package/tests/pending_invite_purge_db.mjs +129 -0
- package/tests/project_admin_console_model.mjs +12 -5
- 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
|
+
});
|
|
@@ -107,8 +107,11 @@ test('notify seeds a PENDING row on the calling project, and cannot touch a real
|
|
|
107
107
|
const w = writes();
|
|
108
108
|
assert.equal(w.length, 1);
|
|
109
109
|
assert.match(w[0].sql, /VALUES \(\$1, \$2, 'pending'\)/, 'a notice is a pending row, never a membership');
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
// The conflict branch restamps ONLY joined_at, and only on a pending row past its 30
|
|
111
|
+
// days (a re-invite, task 1002969): a current member is left exactly as they are.
|
|
112
|
+
assert.match(w[0].sql, /ON CONFLICT \(client_id, github_id\) DO UPDATE SET joined_at = now\(\)\s+WHERE m\.membership_kind = 'pending' AND m\.joined_at <= now\(\) - make_interval\(days => \$3\)/,
|
|
113
|
+
'a current member is left exactly as they are');
|
|
114
|
+
assert.deepEqual(w[0].params, ['mercury', '4242', 30], 'keyed on the authenticated client and the account the LOGIN resolves to');
|
|
112
115
|
});
|
|
113
116
|
|
|
114
117
|
test('clear drops ONLY a pending row on the calling project', async () => {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// tests/invite_expiry.mjs — task 1002969 (privacy spec D4, ADR 0356). DB-free.
|
|
2
|
+
//
|
|
3
|
+
// The one definition of when a recruit invite's door closes. The behaviour is proved
|
|
4
|
+
// in tests/application_lifecycle.mjs (interpreted) and tests/invite_expiry_db.mjs
|
|
5
|
+
// (real Postgres); this pins the definition itself: the owner-signed window, that
|
|
6
|
+
// only an invite can expire, that "live" is exactly "not expired", and that a table
|
|
7
|
+
// alias can never carry SQL into the statements that interpolate it.
|
|
8
|
+
//
|
|
9
|
+
// Run: node --test tests/invite_expiry.mjs
|
|
10
|
+
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { test } from 'node:test';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const expiry = require('../src/bongos/invite-expiry.js');
|
|
16
|
+
const api = require('../src/module-api.js');
|
|
17
|
+
|
|
18
|
+
test('the window is the owner-signed 30 days, and the doorway serves this same module', () => {
|
|
19
|
+
assert.equal(expiry.INVITE_EXPIRY_DAYS, 30);
|
|
20
|
+
for (const k of ['INVITE_EXPIRY_DAYS', 'inviteExpiredSql', 'inviteLiveSql', 'inviteExpiresAtSql']) {
|
|
21
|
+
assert.equal(api.inviteExpiry[k], expiry[k], `${k}: modules read the definition core reads, not a copy`);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('only an invite expires, and live is exactly not-expired', () => {
|
|
26
|
+
assert.equal(expiry.inviteExpiredSql(), "(kind = 'invite' AND created_at <= now() - interval '30 days')");
|
|
27
|
+
assert.equal(expiry.inviteLiveSql('ar'), `NOT ${expiry.inviteExpiredSql('ar')}`);
|
|
28
|
+
assert.match(expiry.inviteExpiresAtSql('ar'), /^\(CASE WHEN ar\.kind = 'invite' THEN ar\.created_at \+ interval '30 days' END\)$/,
|
|
29
|
+
'an application has no closing day');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('an alias is an identifier or nothing', () => {
|
|
33
|
+
assert.match(expiry.inviteLiveSql('x'), /x\.kind = 'invite' AND x\.created_at/);
|
|
34
|
+
for (const bad of ['ar;', "a'", 'a b', 'A', '1a', 'a.b']) {
|
|
35
|
+
assert.throws(() => expiry.inviteExpiredSql(bad), /bad table alias/, JSON.stringify(bad));
|
|
36
|
+
}
|
|
37
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// tests/invite_expiry_db.mjs — task 1002969 (privacy spec D4, ADR 0356). Real DB.
|
|
2
|
+
//
|
|
3
|
+
// A recruit invite's door closes 30 days after it is sent, computed at read time.
|
|
4
|
+
// tests/application_lifecycle.mjs pins the behaviour over an interpreter that reads
|
|
5
|
+
// each statement's WHERE; this file runs the SAME statements on Postgres, because an
|
|
6
|
+
// interpreter cannot tell you that Postgres would reject a statement, or what
|
|
7
|
+
// `now() - interval '30 days'` does at the boundary. Two halves:
|
|
8
|
+
//
|
|
9
|
+
// the instance — the real onboarding router and the real db-kernel admission reads,
|
|
10
|
+
// over access_requests built from its real migrations;
|
|
11
|
+
// the hub — inviteToProject and listPendingInvitesForAccount over the real
|
|
12
|
+
// memberships table: an expired notice is hidden, a re-invite
|
|
13
|
+
// restamps it, and a member's row is never touched.
|
|
14
|
+
//
|
|
15
|
+
// Each half builds its tables in a throwaway schema, so a shared database's rows are
|
|
16
|
+
// never seen or changed.
|
|
17
|
+
//
|
|
18
|
+
// Real-DB integration test: self-skips without a reachable Postgres; in the
|
|
19
|
+
// INTEGRATION set (scripts/gds/run-unit-tests.js) so the default DB-free lane
|
|
20
|
+
// never runs it.
|
|
21
|
+
//
|
|
22
|
+
// Run: DATABASE_URL=... node tests/invite_expiry_db.mjs
|
|
23
|
+
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
import { readFileSync } from 'node:fs';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
import { dirname, join } from 'node:path';
|
|
28
|
+
import { strict as assert } from 'node:assert';
|
|
29
|
+
import http from 'node:http';
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
const { Pool } = require('pg');
|
|
32
|
+
const express = require('express');
|
|
33
|
+
|
|
34
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
const coreSql = (f) => readFileSync(join(here, '..', 'migrations', `${f}.sql`), 'utf8');
|
|
36
|
+
const piSql = (f) => readFileSync(join(here, '..', 'modules', 'platform-identity', 'migrations', `platform_identity_${f}.sql`), 'utf8');
|
|
37
|
+
|
|
38
|
+
const pgPool = new Pool({ connectionString: process.env.DATABASE_URL || undefined, max: 1 });
|
|
39
|
+
let client;
|
|
40
|
+
try {
|
|
41
|
+
client = await pgPool.connect();
|
|
42
|
+
} catch (e) {
|
|
43
|
+
console.log(`invite_expiry_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
44
|
+
await pgPool.end().catch(() => {});
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const q = (sql, params) => client.query(sql, params);
|
|
49
|
+
const SCHEMA = `t1002969_${process.pid}`;
|
|
50
|
+
let pass = 0;
|
|
51
|
+
const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
|
|
52
|
+
|
|
53
|
+
// Core's admission reads go through ./pool, and the router through the doorway's
|
|
54
|
+
// pool: both are pointed at this one connection, whose search_path is the schema.
|
|
55
|
+
const poolPath = require.resolve('../src/bongos/pool.js');
|
|
56
|
+
require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool: client } };
|
|
57
|
+
const apiPath = require.resolve('../src/module-api.js');
|
|
58
|
+
const realApi = require(apiPath);
|
|
59
|
+
let notices = [];
|
|
60
|
+
require.cache[apiPath].exports = {
|
|
61
|
+
...realApi,
|
|
62
|
+
pool: client,
|
|
63
|
+
projectJoinDoor: () => 'apply',
|
|
64
|
+
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
|
65
|
+
requireBuilder: (req, _res, next) => { req.builder = { id: 42, github_login: 'owner' }; next(); },
|
|
66
|
+
requirePermission: () => (_req, _res, next) => next(),
|
|
67
|
+
readApplicationVouch: () => null,
|
|
68
|
+
notifyHubOfInvite: (login, opts) => { notices.push({ login, invited: opts && opts.invited }); },
|
|
69
|
+
};
|
|
70
|
+
const broadcastPath = require.resolve('../modules/onboarding/approval-broadcast.js');
|
|
71
|
+
require.cache[broadcastPath] = { id: broadcastPath, filename: broadcastPath, loaded: true, exports: { postMemberApprovalRequest: async () => ({}) } };
|
|
72
|
+
|
|
73
|
+
const dbKernel = require('../src/bongos/db-kernel.js');
|
|
74
|
+
const buildAccessRequestsRouter = require('../modules/onboarding/routes/access-requests.js');
|
|
75
|
+
const pi = require('../modules/platform-identity/platform-identity.js');
|
|
76
|
+
const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
|
|
77
|
+
|
|
78
|
+
const app = express();
|
|
79
|
+
app.use(express.json());
|
|
80
|
+
app.use(attachFail);
|
|
81
|
+
app.use('/api/gds', buildAccessRequestsRouter());
|
|
82
|
+
const server = http.createServer(app);
|
|
83
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
84
|
+
const port = server.address().port;
|
|
85
|
+
function call(method, path, body) {
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
const payload = body === undefined ? null : Buffer.from(JSON.stringify(body));
|
|
88
|
+
const req = http.request({ host: '127.0.0.1', port, method, path,
|
|
89
|
+
headers: payload ? { 'content-type': 'application/json', 'content-length': payload.length } : {} }, (res) => {
|
|
90
|
+
const chunks = [];
|
|
91
|
+
res.on('data', (c) => chunks.push(c));
|
|
92
|
+
res.on('end', () => resolve({ status: res.statusCode, json: JSON.parse(Buffer.concat(chunks).toString('utf8') || 'null') }));
|
|
93
|
+
});
|
|
94
|
+
req.on('error', reject);
|
|
95
|
+
req.end(payload);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Backdate one login's rows by `days` (an invite's resolved_at is its created_at).
|
|
100
|
+
const age = (login, days) => q(
|
|
101
|
+
`UPDATE access_requests SET created_at = now() - make_interval(days => $2),
|
|
102
|
+
resolved_at = CASE WHEN kind = 'invite' THEN now() - make_interval(days => $2) ELSE resolved_at END
|
|
103
|
+
WHERE github_login = $1`, [login, days]);
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
await q(`CREATE SCHEMA ${SCHEMA}`);
|
|
107
|
+
await q(`SET search_path TO ${SCHEMA}`);
|
|
108
|
+
await q('CREATE TABLE schema_migrations (version text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())');
|
|
109
|
+
await q('CREATE TABLE builders (id bigserial PRIMARY KEY, github_login text NOT NULL)');
|
|
110
|
+
await q(`INSERT INTO builders (id, github_login) VALUES (42, 'owner')`);
|
|
111
|
+
for (const f of ['060_access_requests', 'core_230_access_requests_applicant_vouch',
|
|
112
|
+
'core_254_access_requests_applicant_handle', 'core_255_access_requests_invite_kind',
|
|
113
|
+
'core_266_access_requests_invited_login_idx']) await q(coreSql(f));
|
|
114
|
+
|
|
115
|
+
// ── The instance: the door, the queues, the probe ────────────────────────
|
|
116
|
+
console.log('the instance:');
|
|
117
|
+
for (const login of ['eve', 'fay']) assert.equal((await call('POST', '/api/gds/access-requests/invite', { github_login: login })).status, 201);
|
|
118
|
+
await q(`INSERT INTO access_requests (github_login, status, resolved_at, created_at) VALUES ('cy', 'invited', now(), now() - interval '60 days')`);
|
|
119
|
+
await age('eve', 29);
|
|
120
|
+
await age('fay', 31);
|
|
121
|
+
ok(await dbKernel.hasInvitedAccessRequest('eve') === true, 'day 29: the invite still admits');
|
|
122
|
+
ok(await dbKernel.hasInvitedAccessRequest('fay') === false, 'day 31: the sign-in gate refuses it');
|
|
123
|
+
ok(await dbKernel.hasInvitedAccessRequest('cy') === true, 'an application approved 60 days ago is not an invite and still admits');
|
|
124
|
+
ok((await dbKernel.accessStateByLogin('fay')).invited === false, 'the waiting page agrees with the gate');
|
|
125
|
+
|
|
126
|
+
// core_266: the gate's own statement, captured as it runs, can use the invited-login
|
|
127
|
+
// index. Seq scans are switched off only so a tiny table still shows the plan.
|
|
128
|
+
const seen = [];
|
|
129
|
+
const realQuery = client.query.bind(client);
|
|
130
|
+
client.query = (text, params) => { seen.push({ text, params }); return realQuery(text, params); };
|
|
131
|
+
await dbKernel.hasInvitedAccessRequest('eve');
|
|
132
|
+
client.query = realQuery;
|
|
133
|
+
await q('SET enable_seqscan = off');
|
|
134
|
+
const plan = (await q(`EXPLAIN ${seen[0].text}`, seen[0].params)).rows.map((r) => r['QUERY PLAN']).join('\n');
|
|
135
|
+
await q('RESET enable_seqscan');
|
|
136
|
+
ok(/idx_access_requests_invited_login/.test(plan), 'the sign-in gate can read invited rows through core_266\'s index');
|
|
137
|
+
|
|
138
|
+
const hist = Object.fromEntries((await call('GET', '/api/gds/access-requests?status=invited')).json.requests.map((r) => [r.github_login, r]));
|
|
139
|
+
ok(hist.fay.expired === true && hist.eve.expired === false && hist.cy.expired === false, 'the queue marks only the expired invite');
|
|
140
|
+
ok(hist.cy.expires_at === null && new Date(hist.fay.expires_at) <= new Date() && new Date(hist.eve.expires_at) > new Date(),
|
|
141
|
+
'and says when each invite closes; an application has no such day');
|
|
142
|
+
|
|
143
|
+
const probe = (await call('GET', '/api/gds/access-requests/status?github_login=fay')).json;
|
|
144
|
+
ok(probe.status === 'expired' && probe.admitted === false, 'the status probe reports expired, not invited');
|
|
145
|
+
|
|
146
|
+
notices = [];
|
|
147
|
+
const again = await call('POST', '/api/gds/access-requests/invite', { github_login: 'fay' });
|
|
148
|
+
ok(again.status === 201 && await dbKernel.hasInvitedAccessRequest('fay') === true, 're-inviting an expired login opens a fresh door');
|
|
149
|
+
ok(notices.length === 1 && notices[0].invited === true, 'and tells the hub');
|
|
150
|
+
const out = (await call('GET', '/api/gds/access-requests/invites')).json.invites;
|
|
151
|
+
ok(out.filter((r) => r.github_login === 'fay').length === 1 && out.find((r) => r.github_login === 'fay').expired === false,
|
|
152
|
+
'the outstanding list shows the live invite in place of the expired one');
|
|
153
|
+
const mine = (await call('GET', '/api/gds/access-requests/invites?mine=1')).json.invites;
|
|
154
|
+
ok(mine.filter((r) => r.github_login === 'fay').length === 1, 'and so does the sender\'s own list (?mine=1)');
|
|
155
|
+
|
|
156
|
+
notices = [];
|
|
157
|
+
await call('PATCH', `/api/gds/access-requests/${again.json.request.id}`, { status: 'dismissed' });
|
|
158
|
+
ok(notices.length === 1 && notices[0].invited === false, 'rescinding the live invite clears the hub notice; the expired row does not stand');
|
|
159
|
+
|
|
160
|
+
// ── The hub: the notice follows the door ──────────────────────────────────
|
|
161
|
+
console.log('the hub:');
|
|
162
|
+
for (const f of ['001_tables', '003_project_memberships', '010_membership_kind', '021_drop_legacy_role']) await q(piSql(f));
|
|
163
|
+
const joined = async (id) => (await q(
|
|
164
|
+
`SELECT membership_kind, joined_at FROM platform_identity_project_memberships WHERE client_id = 'demo' AND github_id = $1`, [id])).rows[0];
|
|
165
|
+
const backdate = (id, days) => q(
|
|
166
|
+
`UPDATE platform_identity_project_memberships SET joined_at = now() - make_interval(days => $2) WHERE client_id = 'demo' AND github_id = $1`, [id, days]);
|
|
167
|
+
|
|
168
|
+
ok((await pi.inviteToProject({ clientId: 'demo', githubId: 1 }, { pool: client })).invited === true, 'a first invite writes a notice');
|
|
169
|
+
await backdate(1, 10);
|
|
170
|
+
const before = (await joined(1)).joined_at;
|
|
171
|
+
ok((await pi.inviteToProject({ clientId: 'demo', githubId: 1 }, { pool: client })).invited === false
|
|
172
|
+
&& (await joined(1)).joined_at.getTime() === before.getTime(), 'a re-send while the invite is live leaves the notice as it was');
|
|
173
|
+
|
|
174
|
+
await pi.inviteToProject({ clientId: 'demo', githubId: 2 }, { pool: client });
|
|
175
|
+
await backdate(2, 31);
|
|
176
|
+
const listed = async (id) => (await pi.listPendingInvitesForAccount(id, { pool: client })).length;
|
|
177
|
+
ok(await listed(1) === 1, 'a 10-day-old notice is listed');
|
|
178
|
+
ok(await listed(2) === 0, 'a 31-day-old notice is not: its door no longer opens');
|
|
179
|
+
ok((await pi.inviteToProject({ clientId: 'demo', githubId: 2 }, { pool: client })).invited === true && await listed(2) === 1,
|
|
180
|
+
'a re-invite after the 30 days restamps the notice, so it shows again');
|
|
181
|
+
|
|
182
|
+
await q(`INSERT INTO platform_identity_project_memberships (client_id, github_id, membership_kind, joined_at)
|
|
183
|
+
VALUES ('demo', 3, 'member', now() - interval '400 days')`);
|
|
184
|
+
const member = await joined(3);
|
|
185
|
+
ok((await pi.inviteToProject({ clientId: 'demo', githubId: 3 }, { pool: client })).invited === false, 'inviting a member writes nothing');
|
|
186
|
+
const after = await joined(3);
|
|
187
|
+
ok(after.membership_kind === 'member' && after.joined_at.getTime() === member.joined_at.getTime(),
|
|
188
|
+
'and their row is untouched, however old it is');
|
|
189
|
+
|
|
190
|
+
console.log(`\ninvite_expiry_db.mjs: ${pass} passed`);
|
|
191
|
+
} finally {
|
|
192
|
+
await new Promise((r) => server.close(r));
|
|
193
|
+
await q(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`).catch(() => {});
|
|
194
|
+
client.release();
|
|
195
|
+
await pgPool.end().catch(() => {});
|
|
196
|
+
}
|
package/tests/module_api.mjs
CHANGED
|
@@ -49,6 +49,7 @@ const PUBLISHED_SURFACE = [
|
|
|
49
49
|
'artistGate', // added by task 1003575 (ADR 0241): the artist gate — off | advisory | strict, plus the pure verdict helpers over it. Two modules ask it two different questions ("file this review?" and "does an open one hold the deploy?") and both must resolve the knob through this one reader, never branding().project
|
|
50
50
|
'readApplicantProfileFromHub', // added by task 1002972 (privacy spec D7): the reviewer queue's LIVE per-render read of one applicant's hub profile view. NARROW by design — the underlying call is authenticated with this instance's hub client secret, and loadIdpConfig stays OFF the doorway so no module ever holds the credential that speaks for the whole project
|
|
51
51
|
'notifyHubOfInvite', // added by task 1002818 (ADR 0353): tell the hub about one of THIS project's invites (or its rescind) so the invitee sees it in My Projects. The same NARROW-port reason as readApplicantProfileFromHub: the call carries this instance's hub client secret, so a module passes one login and one direction and can neither widen nor re-address it
|
|
52
|
+
'inviteExpiry', // added by task 1002969 (ADR 0356): the ONE read-time definition of when a recruit invite's door closes. The sign-in gate reads it in core; the onboarding queues and the hub's notice list are modules, so without the doorway each would restate the 30 days and the predicate, and two copies can close a door on different days
|
|
52
53
|
'readApplicationVouch', // added by task 1002285 (ADR 0201 D2): the public access-request write's check of the hub's application vouch. NARROW for the same reason — core supplies the hub key and this instance's client_id, so a module passes a token and a login and can neither swap the key nor re-address the audience
|
|
53
54
|
'enabledDisciplines', // BV1.R81: instance offered-disciplines (1.9.0; onboarding restock backstop)
|
|
54
55
|
'isModuleEnabled', // task 1003881: is ANOTHER module present? onboarding's Discord approve/decline control is only actionable when the `discord` module's reaction handler is loaded, so the posting half has to be able to ask
|