@bongos/core 1.21.81 → 1.21.82
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 +25 -25
- 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 +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +75 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +53 -50
- package/docs/copy-registry.json +79 -52
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +20 -20
- package/modules/government/db.js +45 -0
- package/modules/government/routes/government.js +71 -0
- package/modules/hall-ui/public/government.js +38 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/bongos/route-rank-check.js +1 -0
- package/src/module-api.js +1 -1
- package/tests/government_routes.mjs +72 -0
package/modules/government/db.js
CHANGED
|
@@ -207,6 +207,50 @@ async function resetRankGrants({ rankKey, permissionKeys }) {
|
|
|
207
207
|
return permissionKeys.length;
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
// Delete a CUSTOM rank (task 1004481) — or refuse, naming who still holds it.
|
|
211
|
+
// The rank row is locked FOR UPDATE before the holders are read: an assignment's
|
|
212
|
+
// foreign-key check takes a KEY SHARE lock on that same row, so a concurrent
|
|
213
|
+
// assign either finishes first (and is seen here as a holder) or waits until the
|
|
214
|
+
// rank is gone (and then fails its FK). Grants go with the rank by CASCADE.
|
|
215
|
+
// A seeded rank never matches (`is_system = false`), whatever the caller sent.
|
|
216
|
+
// Returns { deleted: true } | { deleted: false, reason: 'not_found' | 'held', holders }.
|
|
217
|
+
async function deleteCustomRank(rankKey) {
|
|
218
|
+
const client = await pool.connect();
|
|
219
|
+
try {
|
|
220
|
+
await client.query('BEGIN');
|
|
221
|
+
const { rows: locked } = await client.query(
|
|
222
|
+
`SELECT rank_key FROM government_ranks
|
|
223
|
+
WHERE rank_key = $1 AND is_system = false
|
|
224
|
+
FOR UPDATE`,
|
|
225
|
+
[rankKey],
|
|
226
|
+
);
|
|
227
|
+
if (locked.length === 0) {
|
|
228
|
+
await client.query('ROLLBACK');
|
|
229
|
+
return { deleted: false, reason: 'not_found', holders: [] };
|
|
230
|
+
}
|
|
231
|
+
const { rows: holders } = await client.query(
|
|
232
|
+
`SELECT br.builder_id, b.github_login, b.display_name
|
|
233
|
+
FROM government_builder_ranks br
|
|
234
|
+
JOIN builders b ON b.id = br.builder_id
|
|
235
|
+
WHERE br.rank_key = $1
|
|
236
|
+
ORDER BY br.builder_id`,
|
|
237
|
+
[rankKey],
|
|
238
|
+
);
|
|
239
|
+
if (holders.length > 0) {
|
|
240
|
+
await client.query('ROLLBACK');
|
|
241
|
+
return { deleted: false, reason: 'held', holders };
|
|
242
|
+
}
|
|
243
|
+
await client.query(`DELETE FROM government_ranks WHERE rank_key = $1 AND is_system = false`, [rankKey]);
|
|
244
|
+
await client.query('COMMIT');
|
|
245
|
+
return { deleted: true, holders: [] };
|
|
246
|
+
} catch (err) {
|
|
247
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
248
|
+
throw err;
|
|
249
|
+
} finally {
|
|
250
|
+
client.release();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
210
254
|
async function listAssignments() {
|
|
211
255
|
const { rows } = await pool.query(
|
|
212
256
|
`SELECT br.builder_id, b.github_login, b.display_name, b.rank,
|
|
@@ -885,6 +929,7 @@ module.exports = {
|
|
|
885
929
|
grantRankPermission,
|
|
886
930
|
revokeRankPermission,
|
|
887
931
|
resetRankGrants,
|
|
932
|
+
deleteCustomRank,
|
|
888
933
|
listAssignments,
|
|
889
934
|
assignRank,
|
|
890
935
|
unassignRank,
|
|
@@ -28,6 +28,8 @@ const board = require('../board');
|
|
|
28
28
|
const boardWithdraw = require('../board-withdraw');
|
|
29
29
|
const recommendations = require('../recommendations');
|
|
30
30
|
const docket = require('../docket');
|
|
31
|
+
const governmentConfig = require('../config');
|
|
32
|
+
const membershipPredicate = require('../membership-predicate');
|
|
31
33
|
// task 1003208: structured logging (pino via the doorway) — was console.*.
|
|
32
34
|
const log = api.logger('government');
|
|
33
35
|
|
|
@@ -476,6 +478,75 @@ module.exports = function buildGovernmentRouter() {
|
|
|
476
478
|
}
|
|
477
479
|
});
|
|
478
480
|
|
|
481
|
+
// rank: perm government.manage — delete a CUSTOM rank (task 1004481). Ranks
|
|
482
|
+
// added from a recommended set used to be permanent; the owner hesitated to
|
|
483
|
+
// try one for exactly that reason. Three refusals, each naming what to do:
|
|
484
|
+
// - a seeded rank, never (`seeded_rank_immutable`) — the ladder is the system;
|
|
485
|
+
// - a rank someone still holds (`rank_still_held`, with the holders) — owner
|
|
486
|
+
// decision 2026-10-08: no auto-unassign, so nobody loses a rank as a side
|
|
487
|
+
// effect of a cleanup;
|
|
488
|
+
// - a rank the constitution or the who-decides table seats
|
|
489
|
+
// (`rank_in_use_by_government`) — deleting it would leave that seat empty,
|
|
490
|
+
// and only an amendment may change who decides.
|
|
491
|
+
router.delete('/government/ranks/:rankKey', api.requireBuilder, api.requirePermission('government.manage'), async (req, res) => {
|
|
492
|
+
const rankKey = rankKeyParam(req, res);
|
|
493
|
+
if (rankKey === null) return;
|
|
494
|
+
try {
|
|
495
|
+
const rank = await db.getRankByKey(rankKey);
|
|
496
|
+
if (!rank) return res.fail('rank_not_found', 404, { rank_key: rankKey });
|
|
497
|
+
if (rank.is_system) {
|
|
498
|
+
return res.fail('seeded_rank_immutable', 403, { rank_key: rankKey, reason: 'a ladder rank is part of the system and can never be deleted' });
|
|
499
|
+
}
|
|
500
|
+
const seatedIn = await seatsNamingRank(rankKey);
|
|
501
|
+
if (seatedIn.length > 0) {
|
|
502
|
+
return res.fail('rank_in_use_by_government', 409, {
|
|
503
|
+
rank_key: rankKey, seated_in: seatedIn,
|
|
504
|
+
reason: 'this rank decides something; propose an amendment that seats someone else first',
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
const out = await db.deleteCustomRank(rankKey);
|
|
508
|
+
if (out.reason === 'not_found') return res.fail('rank_not_found', 404, { rank_key: rankKey });
|
|
509
|
+
if (out.reason === 'held') {
|
|
510
|
+
return res.fail('rank_still_held', 409, {
|
|
511
|
+
rank_key: rankKey,
|
|
512
|
+
holders: out.holders.map((h) => ({ builder_id: h.builder_id, github_login: h.github_login, display_name: h.display_name })),
|
|
513
|
+
reason: 'remove this rank from everyone who holds it, then delete it',
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
res.json({ ok: true, rank_key: rankKey, deleted: true });
|
|
517
|
+
} catch (err) {
|
|
518
|
+
log.error('[gds] DELETE /government/ranks/:rankKey', err);
|
|
519
|
+
res.fail('delete_failed', { status: 500, message: 'internal error' });
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// Where the live government seats a rank by name: the constitution's board
|
|
524
|
+
// membership and every who-decides row that names a sitting.
|
|
525
|
+
//
|
|
526
|
+
// loadGovernmentConfig() reads two small files synchronously, which config.js
|
|
527
|
+
// says belongs at boot. This route is the documented exception, as board.js's
|
|
528
|
+
// window-opening is: an Archon deleting a rank is rare and never a hot path, and
|
|
529
|
+
// the answer must be the constitution IN FORCE NOW (an amendment applied a moment
|
|
530
|
+
// ago rewrites the file), which a boot-time copy would not be.
|
|
531
|
+
//
|
|
532
|
+
// FAIL-CLOSED on an unparseable predicate: it seats nobody today, but if it
|
|
533
|
+
// spells this rank's key it is refused, so a typo someone later fixes cannot
|
|
534
|
+
// point at a rank that was deleted in the meantime.
|
|
535
|
+
async function seatsNamingRank(rankKey) {
|
|
536
|
+
const names = (predicate) => {
|
|
537
|
+
const parsed = membershipPredicate.parseMembershipPredicate(predicate);
|
|
538
|
+
if (parsed) return parsed.rankKeys.includes(rankKey);
|
|
539
|
+
return typeof predicate === 'string' && predicate.includes(rankKey);
|
|
540
|
+
};
|
|
541
|
+
const out = [];
|
|
542
|
+
const { board: live } = governmentConfig.loadGovernmentConfig();
|
|
543
|
+
if (live && names(live.membership)) out.push('the board');
|
|
544
|
+
for (const row of await db.listDecisionRules()) {
|
|
545
|
+
if (names(row.decided_by)) out.push(`who decides at ${row.area}`);
|
|
546
|
+
}
|
|
547
|
+
return out;
|
|
548
|
+
}
|
|
549
|
+
|
|
479
550
|
// rank: perm government.manage — assign a CUSTOM rank to a builder.
|
|
480
551
|
// assigned_by is ALWAYS the session builder, never input (the audit trail's
|
|
481
552
|
// "who did this" must be the authenticated actor).
|
|
@@ -268,6 +268,15 @@
|
|
|
268
268
|
btn.addEventListener('click', () => resetRank(rank, btn));
|
|
269
269
|
resetBox.appendChild(btn);
|
|
270
270
|
}
|
|
271
|
+
// A custom rank can be deleted (task 1004481); the ladder never.
|
|
272
|
+
if (rank && !rank.is_system) {
|
|
273
|
+
const btn = document.createElement('button');
|
|
274
|
+
btn.type = 'button';
|
|
275
|
+
btn.className = 'btn-ghost';
|
|
276
|
+
btn.textContent = 'Delete rank';
|
|
277
|
+
btn.addEventListener('click', () => deleteRank(rank, btn));
|
|
278
|
+
resetBox.appendChild(btn);
|
|
279
|
+
}
|
|
271
280
|
|
|
272
281
|
grid.innerHTML = '';
|
|
273
282
|
// Principals are rendered in their own section further down the page; a machine
|
|
@@ -303,6 +312,35 @@
|
|
|
303
312
|
}
|
|
304
313
|
}
|
|
305
314
|
|
|
315
|
+
// Delete a custom rank. The server refuses while anyone holds it and names
|
|
316
|
+
// them, so the toast can say exactly whose rank to remove first.
|
|
317
|
+
async function deleteRank(rank, btn) {
|
|
318
|
+
const label = rank.label || rank.rank_key;
|
|
319
|
+
if (!window.confirm(`Delete the ${label} rank?\n\nIts permissions go with it. This cannot be undone, though you can create a rank with the same name again.`)) return;
|
|
320
|
+
btn.disabled = true;
|
|
321
|
+
const res = await api.request('DELETE', `${API}/government/ranks/${encodeURIComponent(rank.rank_key)}`);
|
|
322
|
+
if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); return; }
|
|
323
|
+
if (res.ok) {
|
|
324
|
+
toast(`${label} deleted.`);
|
|
325
|
+
await reloadRanks();
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
btn.disabled = false;
|
|
329
|
+
toast(deleteRefusal((res.data || {}).error || {}, label));
|
|
330
|
+
}
|
|
331
|
+
function deleteRefusal(err, label) {
|
|
332
|
+
const d = err.details || {};
|
|
333
|
+
if (err.code === 'rank_still_held' && Array.isArray(d.holders) && d.holders.length) {
|
|
334
|
+
const names = d.holders.map((h) => h.display_name || h.github_login || `builder ${h.builder_id}`);
|
|
335
|
+
const shown = names.length > 3 ? `${names.slice(0, 3).join(', ')} and ${names.length - 3} more` : names.join(', ');
|
|
336
|
+
return `${label} is still held by ${shown}. Remove it from them below, then delete it.`;
|
|
337
|
+
}
|
|
338
|
+
if (err.code === 'rank_in_use_by_government' && Array.isArray(d.seated_in) && d.seated_in.length) {
|
|
339
|
+
return `${label} still decides something (${d.seated_in.join(', ')}). Propose an amendment that seats someone else first.`;
|
|
340
|
+
}
|
|
341
|
+
return `Could not delete: ${friendlyError(err.code) || err.message || 'the server refused'}`;
|
|
342
|
+
}
|
|
343
|
+
|
|
306
344
|
async function toggleGrant(rank, permissionKey, grant, btn) {
|
|
307
345
|
btn.disabled = true;
|
|
308
346
|
try {
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.82",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.21.
|
|
9
|
+
"version": "1.21.82",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.82",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -9312,5 +9312,11 @@
|
|
|
9312
9312
|
"id": "1004442",
|
|
9313
9313
|
"text": "When the board turns someone's idea into a goal, every task later finished under that goal now pays the idea's author, as promised. Before this, only work promoted straight from the idea paid them, so a goal full of finished w"
|
|
9314
9314
|
}
|
|
9315
|
+
],
|
|
9316
|
+
"1.21.82": [
|
|
9317
|
+
{
|
|
9318
|
+
"id": "1004481",
|
|
9319
|
+
"text": "Custom ranks can now be deleted from the Government page, so trying a recommended rank set is no longer a permanent choice. A rank someone still holds can't be deleted, and the page says who holds it. The four built-in ranks c"
|
|
9320
|
+
}
|
|
9315
9321
|
]
|
|
9316
9322
|
}
|
|
@@ -261,6 +261,7 @@ const GOVERNMENT_PINS = {
|
|
|
261
261
|
'PUT /government/ranks/:rankKey/permissions/:permissionKey': 'perm:government.manage',
|
|
262
262
|
'DELETE /government/ranks/:rankKey/permissions/:permissionKey': 'perm:government.manage',
|
|
263
263
|
'POST /government/ranks/:rankKey/reset': 'perm:government.manage',
|
|
264
|
+
'DELETE /government/ranks/:rankKey': 'perm:government.manage',
|
|
264
265
|
'POST /government/rank-sets/apply': 'perm:government.manage',
|
|
265
266
|
'POST /government/assignments': 'perm:government.manage',
|
|
266
267
|
'DELETE /government/assignments/:builderId/:rankKey': 'perm:government.manage',
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.21.
|
|
78
|
+
const CORE_VERSION = '1.21.82'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -35,6 +35,7 @@ const api = require(path.join(ROOT, 'src', 'module-api.js'));
|
|
|
35
35
|
const seams = require(path.join(ROOT, 'src', 'module-seams.js'));
|
|
36
36
|
const catalog = require(path.join(ROOT, 'modules', 'government', 'catalog.js'));
|
|
37
37
|
const govDb = require(path.join(ROOT, 'modules', 'government', 'db.js'));
|
|
38
|
+
const governmentConfig = require(path.join(ROOT, 'modules', 'government', 'config.js'));
|
|
38
39
|
const rrc = require(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'));
|
|
39
40
|
const { attachFail } = require(path.join(ROOT, 'src', 'bongos', 'middleware', 'error-envelope.js'));
|
|
40
41
|
const express = require('express');
|
|
@@ -58,6 +59,7 @@ test('the routes file declares the full R110 surface', () => {
|
|
|
58
59
|
'PUT /government/ranks/:rankKey/permissions/:permissionKey',
|
|
59
60
|
'DELETE /government/ranks/:rankKey/permissions/:permissionKey',
|
|
60
61
|
'POST /government/ranks/:rankKey/reset',
|
|
62
|
+
'DELETE /government/ranks/:rankKey',
|
|
61
63
|
'POST /government/assignments',
|
|
62
64
|
'DELETE /government/assignments/:builderId/:rankKey',
|
|
63
65
|
'POST /government/rank-sets/apply',
|
|
@@ -125,6 +127,7 @@ test('checkRouteRanks passes over the real tree (no governance findings)', () =>
|
|
|
125
127
|
|
|
126
128
|
// The db stub: every function the routes call, recording args. Reset per test.
|
|
127
129
|
const calls = [];
|
|
130
|
+
let deleteAnswer = { deleted: true, holders: [] };
|
|
128
131
|
const stub = {
|
|
129
132
|
listRanksWithGrants: async () => [{ rank_key: 'metic', label: 'Metic', description: null, is_system: true, seeded_rank: 'metic', created_by: null, created_at: 't', permissions: ['task.create'] }],
|
|
130
133
|
listAssignments: async () => [{ builder_id: 7, github_login: 'x', display_name: 'X', rank: 'metic', rank_key: 'metic', role_label: 'Metic', is_system: true, seeded_rank: 'metic', assigned_by: null, assigned_at: 't' }],
|
|
@@ -141,6 +144,8 @@ const stub = {
|
|
|
141
144
|
grantRankPermission: async () => true,
|
|
142
145
|
revokeRankPermission: async () => true,
|
|
143
146
|
resetRankGrants: async ({ permissionKeys }) => permissionKeys.length,
|
|
147
|
+
// task 1004481: `deleteAnswer` scripts what the locked delete found.
|
|
148
|
+
deleteCustomRank: async () => deleteAnswer,
|
|
144
149
|
assignRank: async () => true,
|
|
145
150
|
unassignRank: async () => true,
|
|
146
151
|
// task 1004529: who decides at People. [] = the as-today default row.
|
|
@@ -412,6 +417,73 @@ test('POST reset: refused on a custom role (no catalog default to return to)', a
|
|
|
412
417
|
assert.equal(calls.filter(([k]) => k === 'resetRankGrants').length, 0);
|
|
413
418
|
});
|
|
414
419
|
|
|
420
|
+
// --- delete a custom rank (task 1004481) --------------------------------------
|
|
421
|
+
|
|
422
|
+
test('DELETE a rank: a custom rank nobody holds is deleted', async () => {
|
|
423
|
+
deleteAnswer = { deleted: true, holders: [] };
|
|
424
|
+
const r = await call('DELETE', '/government/ranks/triage-helper');
|
|
425
|
+
assert.equal(r.status, 200);
|
|
426
|
+
assert.equal(r.json.deleted, true);
|
|
427
|
+
const c = calls.find(([k]) => k === 'deleteCustomRank');
|
|
428
|
+
assert.equal(c[1], 'triage-helper');
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test('DELETE a rank: refused while held, and the refusal names who holds it', async () => {
|
|
432
|
+
deleteAnswer = { deleted: false, reason: 'held', holders: [{ builder_id: 9, github_login: 'ana', display_name: 'Ana', extra: 'x' }] };
|
|
433
|
+
const r = await call('DELETE', '/government/ranks/triage-helper');
|
|
434
|
+
assert.equal(r.status, 409);
|
|
435
|
+
assert.equal(r.json.error.code, 'rank_still_held');
|
|
436
|
+
assert.deepEqual(r.json.error.details.holders, [{ builder_id: 9, github_login: 'ana', display_name: 'Ana' }], 'names them, and only those fields');
|
|
437
|
+
assert.equal(calls.filter(([k]) => k === 'unassignRank').length, 0, 'never unassigns on the way');
|
|
438
|
+
deleteAnswer = { deleted: true, holders: [] };
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test('DELETE a rank: every seeded rank is refused before the db is asked', async () => {
|
|
442
|
+
for (const k of catalog.RANK_ORDER) {
|
|
443
|
+
const r = await call('DELETE', `/government/ranks/${k}`);
|
|
444
|
+
assert.equal(r.status, 403, k);
|
|
445
|
+
assert.equal(r.json.error.code, 'seeded_rank_immutable');
|
|
446
|
+
assert.equal(calls.filter(([n]) => n === 'deleteCustomRank').length, 0);
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test('DELETE a rank: a missing rank 404s, from the read or from the locked delete', async () => {
|
|
451
|
+
assert.equal((await call('DELETE', '/government/ranks/missing')).status, 404);
|
|
452
|
+
deleteAnswer = { deleted: false, reason: 'not_found', holders: [] };
|
|
453
|
+
assert.equal((await call('DELETE', '/government/ranks/triage-helper')).status, 404);
|
|
454
|
+
deleteAnswer = { deleted: true, holders: [] };
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
test('DELETE a rank: refused while the board or a who-decides row seats it', async () => {
|
|
458
|
+
const realLoad = governmentConfig.loadGovernmentConfig;
|
|
459
|
+
try {
|
|
460
|
+
governmentConfig.loadGovernmentConfig = () => ({ board: { membership: 'rank:archon,triage-helper' } });
|
|
461
|
+
let r = await call('DELETE', '/government/ranks/triage-helper');
|
|
462
|
+
assert.equal(r.status, 409);
|
|
463
|
+
assert.equal(r.json.error.code, 'rank_in_use_by_government');
|
|
464
|
+
assert.deepEqual(r.json.error.details.seated_in, ['the board']);
|
|
465
|
+
assert.equal(calls.filter(([k]) => k === 'deleteCustomRank').length, 0);
|
|
466
|
+
|
|
467
|
+
governmentConfig.loadGovernmentConfig = () => ({ board: { membership: 'rank:archon' } });
|
|
468
|
+
stub.listDecisionRules = async () => [{ area: 'people', decided_by: 'rank:triage-helper' }, { area: 'treasury', decided_by: 'gate' }];
|
|
469
|
+
r = await call('DELETE', '/government/ranks/triage-helper');
|
|
470
|
+
assert.equal(r.status, 409);
|
|
471
|
+
assert.deepEqual(r.json.error.details.seated_in, ['who decides at people']);
|
|
472
|
+
r = await call('DELETE', '/government/ranks/other-rank');
|
|
473
|
+
assert.equal(r.status, 200, 'a rank nothing seats is still deletable');
|
|
474
|
+
|
|
475
|
+
// fail-closed: an unparseable predicate that spells the key still refuses
|
|
476
|
+
stub.listDecisionRules = async () => [];
|
|
477
|
+
governmentConfig.loadGovernmentConfig = () => ({ board: { membership: 'rank:triage-helper+' } });
|
|
478
|
+
r = await call('DELETE', '/government/ranks/triage-helper');
|
|
479
|
+
assert.equal(r.status, 409, 'rank:<custom>+ is unparseable, and still names the rank');
|
|
480
|
+
assert.deepEqual(r.json.error.details.seated_in, ['the board']);
|
|
481
|
+
} finally {
|
|
482
|
+
governmentConfig.loadGovernmentConfig = realLoad;
|
|
483
|
+
stub.listDecisionRules = async () => [];
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
|
|
415
487
|
// --- assignments -------------------------------------------------------------
|
|
416
488
|
|
|
417
489
|
test('POST /government/assignments assigns a custom role, assigned_by = the session builder', async () => {
|