@bongos/core 1.21.80 → 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 +29 -29
- 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 +4 -0
- package/docs/page-readings.json +20 -20
- package/modules/economy/credits.js +29 -3
- package/modules/government/board.js +3 -3
- 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 +12 -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/tests/idea_task_ship_credit.mjs +45 -0
- package/tests/profile_earnings.mjs +1 -1
|
@@ -602,10 +602,36 @@ async function awardIdeaTaskShipCredit(client, { taskId, config = rewardConfig()
|
|
|
602
602
|
LIMIT 1`,
|
|
603
603
|
[taskId]
|
|
604
604
|
);
|
|
605
|
-
|
|
605
|
+
// THE GOAL AN IDEA BECAME (task 1004442). A Full Idea ratified into a goal
|
|
606
|
+
// writes ideas_goal_ratifications and NO lineage row, so every task later
|
|
607
|
+
// filed under that goal shipped paying the idea nothing. With no lineage row
|
|
608
|
+
// the goal's idea is the source, at the same full share; a lineage row wins.
|
|
609
|
+
let source = rows[0];
|
|
610
|
+
let viaGoalId = null;
|
|
611
|
+
if (!source) {
|
|
612
|
+
const born = await client.query(
|
|
613
|
+
`SELECT r.idea_id,
|
|
614
|
+
r.goal_id,
|
|
615
|
+
i.captured_by,
|
|
616
|
+
COALESCE(g.grade, 'quick') AS grade,
|
|
617
|
+
g.completeness_score AS completeness,
|
|
618
|
+
g.developed_by AS developed_by
|
|
619
|
+
FROM tasks t
|
|
620
|
+
JOIN ideas_goal_ratifications r ON r.goal_id = t.goal_id
|
|
621
|
+
JOIN idea_inbox i ON i.id = r.idea_id
|
|
622
|
+
LEFT JOIN ideas_grades g ON g.idea_id = r.idea_id
|
|
623
|
+
WHERE t.id = $1
|
|
624
|
+
ORDER BY r.ratified_at ASC, r.idea_id ASC
|
|
625
|
+
LIMIT 1`,
|
|
626
|
+
[taskId]
|
|
627
|
+
);
|
|
628
|
+
source = born.rows[0];
|
|
629
|
+
viaGoalId = source ? source.goal_id : null;
|
|
630
|
+
}
|
|
631
|
+
if (!source || source.captured_by == null) {
|
|
606
632
|
return { credited: 0, reason: 'no_source_idea', ideaId: null, authorId: null };
|
|
607
633
|
}
|
|
608
|
-
const { idea_id: ideaId, captured_by: authorId, grade, completeness, developed_by: developedBy } =
|
|
634
|
+
const { idea_id: ideaId, captured_by: authorId, grade, completeness, developed_by: developedBy } = source;
|
|
609
635
|
const factor = ideaGradeFactor(grade, completeness);
|
|
610
636
|
const delta = Math.round(IDEA_TASK_SHIP_BASE * factor);
|
|
611
637
|
if (delta <= 0) {
|
|
@@ -625,7 +651,7 @@ async function awardIdeaTaskShipCredit(client, { taskId, config = rewardConfig()
|
|
|
625
651
|
payees,
|
|
626
652
|
reason,
|
|
627
653
|
taskId,
|
|
628
|
-
describe: (p) => `Task #${taskId} shipped under idea #${ideaId} (${gradeLabel}) → ${p.delta}c to its ${p.role.replace(/_/g, ' ')} (ideator lane stream 1, ADR 0172${p.role === 'developer' || p.role === 'spark_author' ? '; split ADR 0185' : ''})`,
|
|
654
|
+
describe: (p) => `Task #${taskId} shipped under ${viaGoalId != null ? `goal #${viaGoalId}, ratified from ` : ''}idea #${ideaId} (${gradeLabel}) → ${p.delta}c to its ${p.role.replace(/_/g, ' ')} (ideator lane stream 1, ADR 0172${p.role === 'developer' || p.role === 'spark_author' ? '; split ADR 0185' : ''})`,
|
|
629
655
|
});
|
|
630
656
|
return credited > 0
|
|
631
657
|
? { credited, reason: 'paid', ideaId: Number(ideaId), authorId: Number(authorId), payees: booked }
|
|
@@ -556,10 +556,10 @@ function passPayload(closed, { authorId, completenessScore, passedAt }) {
|
|
|
556
556
|
// credit is booked through the `reward` PORT inside the same transaction that
|
|
557
557
|
// writes the outcome — if the credit write fails, the close rolls back and the
|
|
558
558
|
// item stays open, so the vote is retried rather than silently unpaid.
|
|
559
|
-
//
|
|
560
|
-
//
|
|
559
|
+
// The payout is economy's `awardIdeaGovernancePassCredit` (stream 3, IC5). A null
|
|
560
|
+
// `resolveOptional` (no economy), or a port without it (an older one), is a CLEAN
|
|
561
561
|
// NO-OP: the item still closes, nothing is booked, and the durable
|
|
562
|
-
// `outcome = 'passed'` row
|
|
562
|
+
// `outcome = 'passed'` row is what a re-drive sweep would pay from. The port is
|
|
563
563
|
// resolved and the payload's reads happen BEFORE the transaction opens.
|
|
564
564
|
//
|
|
565
565
|
// `board.item.passed` is then emitted AFTER commit, emitAsync, for non-money
|
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
|
@@ -9306,5 +9306,17 @@
|
|
|
9306
9306
|
"id": "1004393",
|
|
9307
9307
|
"text": "The tweak editor is ready to show landing and status pages side by side with the words, instead of in a separate window. It stays off until a small web-server change is made on the host; those steps are waiting in the owner's"
|
|
9308
9308
|
}
|
|
9309
|
+
],
|
|
9310
|
+
"1.21.81": [
|
|
9311
|
+
{
|
|
9312
|
+
"id": "1004442",
|
|
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
|
+
}
|
|
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
|
+
}
|
|
9309
9321
|
]
|
|
9310
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 () => {
|
|
@@ -113,5 +113,50 @@ await test('the lane opted out, or a task with no source idea, pays nothing', as
|
|
|
113
113
|
assert.equal(r.reason, 'no_source_idea');
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
+
// task 1004442: a Full Idea ratified into a GOAL writes no lineage row, so a
|
|
117
|
+
// task filed later under that goal is found through the goal instead.
|
|
118
|
+
function goalStubClient(lineage, born) {
|
|
119
|
+
const c = stubClient(lineage);
|
|
120
|
+
const inner = c.query;
|
|
121
|
+
c.goalAsked = 0;
|
|
122
|
+
c.query = async (sql, params) => {
|
|
123
|
+
if (/FROM tasks t\s+JOIN ideas_goal_ratifications/i.test(sql)) {
|
|
124
|
+
c.goalAsked++;
|
|
125
|
+
assert.equal(params[0], 100, 'asks about the shipping task');
|
|
126
|
+
assert.match(sql, /r\.goal_id = t\.goal_id/, 'joined on the task\'s own goal');
|
|
127
|
+
return { rows: born ? [born] : [] };
|
|
128
|
+
}
|
|
129
|
+
return inner(sql, params);
|
|
130
|
+
};
|
|
131
|
+
return c;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
await test('a task filed under a goal RATIFIED FROM an idea pays that idea, at the full share', async () => {
|
|
135
|
+
const c = goalStubClient(null, { idea_id: 9, goal_id: 55, captured_by: 4, grade: 'full', completeness: 80 });
|
|
136
|
+
const r = await credits.awardIdeaTaskShipCredit(c, { taskId: 100, config: ON });
|
|
137
|
+
assert.equal(r.credited, 4, '5 × 0.80, the same as a lineage task');
|
|
138
|
+
assert.equal(r.ideaId, 9);
|
|
139
|
+
assert.equal(c.credited[0].builder_id, 4);
|
|
140
|
+
assert.equal(c.credited[0].reason, 'idea.credit.task_ship:9:100', 'the stream-1 key, so size and the trail count it');
|
|
141
|
+
const again = await credits.awardIdeaTaskShipCredit(c, { taskId: 100, config: ON });
|
|
142
|
+
assert.equal(again.credited, 0, 'once per (idea, task)');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await test('a lineage row WINS: the goal is not asked and its idea is not paid as well', async () => {
|
|
146
|
+
const c = goalStubClient({ idea_id: 7, captured_by: 3, grade: 'full', completeness: 80 },
|
|
147
|
+
{ idea_id: 9, goal_id: 55, captured_by: 4, grade: 'full', completeness: 80 });
|
|
148
|
+
const r = await credits.awardIdeaTaskShipCredit(c, { taskId: 100, config: ON });
|
|
149
|
+
assert.equal(r.ideaId, 7);
|
|
150
|
+
assert.equal(c.goalAsked, 0);
|
|
151
|
+
assert.equal(c.credited.length, 1);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await test('a goal no idea was ratified into pays nothing', async () => {
|
|
155
|
+
const c = goalStubClient(null, null);
|
|
156
|
+
const r = await credits.awardIdeaTaskShipCredit(c, { taskId: 100, config: ON });
|
|
157
|
+
assert.equal(r.reason, 'no_source_idea');
|
|
158
|
+
assert.equal(c.goalAsked, 1);
|
|
159
|
+
});
|
|
160
|
+
|
|
116
161
|
console.log(`\nidea_task_ship_credit: ${passed} passed, ${failed} failed`);
|
|
117
162
|
if (failed > 0) process.exit(1);
|
|
@@ -47,7 +47,7 @@ await test('a descent hop is told from a direct payout by the WRITER\'s own word
|
|
|
47
47
|
const src = fs.readFileSync(path.join(ROOT, 'modules/economy/credits.js'), 'utf8');
|
|
48
48
|
// the descent writer and the direct writer, by their own describe() lines
|
|
49
49
|
const descent = src.match(/describe: \(p\) => `Task #\$\{taskId\} shipped \$\{depth\} hop[^\n]*/);
|
|
50
|
-
const direct = src.match(/describe: \(p\) => `Task #\$\{taskId\} shipped under
|
|
50
|
+
const direct = src.match(/describe: \(p\) => `Task #\$\{taskId\} shipped under [^\n]*/); // "under idea" or, since task 1004442, "under goal …, ratified from idea"
|
|
51
51
|
assert.ok(descent && direct, 'both writers are where this test reads them');
|
|
52
52
|
assert.ok(descent[0].includes(earnings.DESCENT_MARK.trim()), 'the descent writer says DESCENT_MARK');
|
|
53
53
|
assert.ok(!direct[0].includes(earnings.DESCENT_MARK.trim()), 'and the direct one does not, or every idea payout would read as descent');
|