@bongos/core 1.19.310 → 1.19.312
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 -15
- package/docs/design/demonolith-plan.md +105 -0
- package/modules/government/db.js +3 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/routes/_helpers.js +63 -0
- package/src/bongos/routes.js +9 -0
- package/src/module-api.js +5 -1
- package/tests/async_handler_500.mjs +167 -0
- package/tests/government_rank_sync.mjs +90 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.312",
|
|
6
|
+
"core_contract": "1.19.312",
|
|
7
|
+
"source_commit": "1115918f0f231eb592993c52535bccf7459ba201",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-23T20:
|
|
9
|
+
"built_at": "2026-08-23T20:51:36.647Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 323,
|
|
13
13
|
"agent_docs_stubbed": 22,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1421,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1766,
|
|
20
|
+
"tree_sha256": "8fa5a70e1592609c53386120743ab236e02cd64aaf8c3911891bd79d6a8da6ea",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -1459,6 +1459,11 @@
|
|
|
1459
1459
|
"mode": "0000644",
|
|
1460
1460
|
"sha256": "9623542a240479c3ecec12dd0c9050d4b79b2791a414bc2cd6b05d99530a9c44"
|
|
1461
1461
|
},
|
|
1462
|
+
{
|
|
1463
|
+
"path": "docs/design/demonolith-plan.md",
|
|
1464
|
+
"mode": "0000644",
|
|
1465
|
+
"sha256": "49b67aafae295e023be8583d668f1e3232c0d8ce18b8c0032c8fb77b347acd32"
|
|
1466
|
+
},
|
|
1462
1467
|
{
|
|
1463
1468
|
"path": "docs/design/front-door-and-hub-direction.md",
|
|
1464
1469
|
"mode": "0000644",
|
|
@@ -3052,7 +3057,7 @@
|
|
|
3052
3057
|
{
|
|
3053
3058
|
"path": "modules/government/db.js",
|
|
3054
3059
|
"mode": "0000644",
|
|
3055
|
-
"sha256": "
|
|
3060
|
+
"sha256": "e48b8e01738969ca589496ee0bea94b5a31995e853a1347f63ca83895d4d28fd"
|
|
3056
3061
|
},
|
|
3057
3062
|
{
|
|
3058
3063
|
"path": "modules/government/membership-predicate.js",
|
|
@@ -4577,12 +4582,12 @@
|
|
|
4577
4582
|
{
|
|
4578
4583
|
"path": "package-lock.json",
|
|
4579
4584
|
"mode": "0000644",
|
|
4580
|
-
"sha256": "
|
|
4585
|
+
"sha256": "57b834edcb83e8b41bb7535bbacc1740537b0f3551726295884ea35615a88686"
|
|
4581
4586
|
},
|
|
4582
4587
|
{
|
|
4583
4588
|
"path": "package.json",
|
|
4584
4589
|
"mode": "0000644",
|
|
4585
|
-
"sha256": "
|
|
4590
|
+
"sha256": "7e8d9420573b8eabbe2ad7f96f0317d4b454fcf5788b68bfc6afdf6fb45b3cb4"
|
|
4586
4591
|
},
|
|
4587
4592
|
{
|
|
4588
4593
|
"path": "public-docs/index.html",
|
|
@@ -6122,7 +6127,7 @@
|
|
|
6122
6127
|
{
|
|
6123
6128
|
"path": "src/bongos/routes.js",
|
|
6124
6129
|
"mode": "0000644",
|
|
6125
|
-
"sha256": "
|
|
6130
|
+
"sha256": "d0d7cd19a80e20946fa8d3ecdcb41f74871194d2c4e4518aeddc07a30e274630"
|
|
6126
6131
|
},
|
|
6127
6132
|
{
|
|
6128
6133
|
"path": "src/bongos/routes/CLAUDE.md",
|
|
@@ -6132,7 +6137,7 @@
|
|
|
6132
6137
|
{
|
|
6133
6138
|
"path": "src/bongos/routes/_helpers.js",
|
|
6134
6139
|
"mode": "0000644",
|
|
6135
|
-
"sha256": "
|
|
6140
|
+
"sha256": "46ca1aee5960ea03c97c52de6ca246b06130185a7e2fe31fde79d117a6be29dc"
|
|
6136
6141
|
},
|
|
6137
6142
|
{
|
|
6138
6143
|
"path": "src/bongos/routes/audit-log.js",
|
|
@@ -6252,7 +6257,7 @@
|
|
|
6252
6257
|
{
|
|
6253
6258
|
"path": "src/module-api.js",
|
|
6254
6259
|
"mode": "0000644",
|
|
6255
|
-
"sha256": "
|
|
6260
|
+
"sha256": "a35a7a2ca59f77d06b12aaaec28b4d82bbe5a4713289c072d4e9cc45d0d467e5"
|
|
6256
6261
|
},
|
|
6257
6262
|
{
|
|
6258
6263
|
"path": "src/module-loader/catalog.js",
|
|
@@ -6399,6 +6404,11 @@
|
|
|
6399
6404
|
"mode": "0000644",
|
|
6400
6405
|
"sha256": "39830ca32d20cfe1eab9a47c93c8f96866f4c04f13a98823d7c35b8383828ff9"
|
|
6401
6406
|
},
|
|
6407
|
+
{
|
|
6408
|
+
"path": "tests/async_handler_500.mjs",
|
|
6409
|
+
"mode": "0000644",
|
|
6410
|
+
"sha256": "798f8c188f81d35361834954e7ffaed77cb771402bdb48866c98394fcbe26f32"
|
|
6411
|
+
},
|
|
6402
6412
|
{
|
|
6403
6413
|
"path": "tests/atlas.mjs",
|
|
6404
6414
|
"mode": "0000644",
|
|
@@ -7377,7 +7387,7 @@
|
|
|
7377
7387
|
{
|
|
7378
7388
|
"path": "tests/government_rank_sync.mjs",
|
|
7379
7389
|
"mode": "0000644",
|
|
7380
|
-
"sha256": "
|
|
7390
|
+
"sha256": "8e430f7286ec798eceaba7db962bb3725c1de2e9cbaeee70739aae6287695d7e"
|
|
7381
7391
|
},
|
|
7382
7392
|
{
|
|
7383
7393
|
"path": "tests/government_require_permission.mjs",
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# De-monolith plan — no first-party file over 1,500 lines
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-08-23 · **Task:** task 1003203 (spike, R03 of goal 1000079 "Code quality") · **Author:** example-owner
|
|
4
|
+
**Feeds:** task 1003204 (db.js), task 1003205 (ship.js), task 1003206 (the rest), task 1003207 (dead-code/baselines)
|
|
5
|
+
**Companion:** [`../audits/ai-dev-redteam-followup.md`](../audits/ai-dev-redteam-followup.md) §5
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## The target
|
|
10
|
+
|
|
11
|
+
Goal 1000079's `monoliths-eliminated` criterion (aggressive scope): **`oversized_file_count` → 0.** The ratchet ([`fitness-ratchets.js`](../../scripts/gds/fitness-ratchets.js)) counts tracked first-party `.js` files over **1,500 lines** (`OVERSIZE_LINES`), after excluding `node_modules/`, `public-docs/`, `*.min.js`, `redoc.standalone.js`, and `vendor/`. Baseline today: **12 files, `excess_lines_total` ≈ 12.3k.** Shrink-only — lower the baseline in [`config/fitness-baselines.json`](../../config/fitness-baselines.json) as each file drops under; never raise it.
|
|
12
|
+
|
|
13
|
+
The 12 (current line counts on `main`):
|
|
14
|
+
|
|
15
|
+
| # | File | Lines | Module | Decision | R-task |
|
|
16
|
+
|---|---|---|---|---|---|
|
|
17
|
+
| 1 | `modules/lifecycle/db.js` | 5,748 | lifecycle | **SPLIT** (7 seams) | 1003204 |
|
|
18
|
+
| 2 | `scripts/gds/ship.js` | 4,613 | lifecycle | **SPLIT** (7 seams) | 1003205 |
|
|
19
|
+
| 3 | `modules/hall-ui/public/builders.js` | 2,558 | hall-ui | **SPLIT** (3 seams) | 1003206 |
|
|
20
|
+
| 4 | `scripts/gds/provision.js` | 2,498 | provisioning | **SPLIT** (4 seams) | 1003206 |
|
|
21
|
+
| 5 | `modules/grading/grader.js` | 2,083 | grading | **SPLIT** (5 seams) | 1003206 |
|
|
22
|
+
| 6 | `scripts/gds/fitness.js` | 1,990 | devsecops | **SPLIT** (checks→modules) | 1003206 |
|
|
23
|
+
| 7 | `modules/hall-ui/public/goals-page.js` | 1,988 | hall-ui | **SPLIT** (extend existing carve) | 1003206 |
|
|
24
|
+
| 8 | `scripts/gds/seed-v3-tasks.js` | 1,981 | (kernel) | **DELETE** (else allowlist) | 1003207 |
|
|
25
|
+
| 9 | `modules/lifecycle/routes/tasks.js` | 1,884 | lifecycle | **SPLIT** (sub-routers) | 1003206 |
|
|
26
|
+
| 10 | `src/bongos/auth.js` | 1,866 | kernel | **SPLIT** (4 seams) — highest risk | 1003206 |
|
|
27
|
+
| 11 | `modules/lifecycle/routes/goals.js` | 1,535 | lifecycle | **SPLIT** (extend existing carve) | 1003206 |
|
|
28
|
+
| 12 | `scripts/gds/box.js` | 1,519 | dev-box | **SPLIT** (2 seams — only 19 over) | 1003206 |
|
|
29
|
+
|
|
30
|
+
**One rule above all:** these are structural moves, not rewrites. Every split preserves behaviour, the module boundary (a module reaches core only via `src/module-api.js`, never a core internal or another module), and the public port surface. A split that changes a contract is out of scope — file it separately.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Per-file seams
|
|
35
|
+
|
|
36
|
+
### 1. `db.js` → 7 seams (task 1003204) — highest coupling
|
|
37
|
+
The lifecycle data layer, ~97 functions sharing one `withTx`/`seams` doorway. Split by domain, keeping the **state machine whole** and the **transaction-participant seams intact** (the claim/confirm/ship/grade/credit functions open the tx and call `resolveOptional('reward'|'onboarding')` inside it — ADR 0093 §2; do not sever a function from its tx). Candidate modules (rough sizes):
|
|
38
|
+
- `db-rank-authz.js` (~450) — rank/permission/graduation: `claimPermissionsFor`, `rankAllowsTask`, `xenosClaimAllowed`, `_applyRankChangeTx`, `maybeAutoGraduateToThetes`, `syncGovernanceRankRole`, the grade-count SQL fragments.
|
|
39
|
+
- `db-versions.js` (~150) — `listVersions`, `getVersion`, `versionProgress`, `createVersion`.
|
|
40
|
+
- `db-goals.js` (~700) — goals + membership + criteria links + scope/visibility/successor/transfer.
|
|
41
|
+
- `db-claims.js` (~600) — the claim refusal ladder, `claimTask`, `resolveClaim`, active-claims, release requests.
|
|
42
|
+
- `db-tasks.js` (~700) — task CRUD, `createTask`, rescope, classifier hooks, dead-dep helpers.
|
|
43
|
+
- `db-ship.js` (~700) — `confirmTask`/`shipTask`/`applyGrade` + criterion auto-close cascade (the tx-participant core).
|
|
44
|
+
- `db-analytics.js` (~500) — estimation drift, grade trend, AI-dev curve, work-tracking reads; plus `tallyPeerVotes` + merge-lease (or a small `db-peer-votes.js`/`db-merge-lease.js`).
|
|
45
|
+
- `db.js` remains a thin re-export/facade so `lifecycle.js` and existing requires keep working unchanged.
|
|
46
|
+
**Risk: high.** Land behind the full lifecycle test suite (`coreB-carve-proof.sh` + the DB-integration tests named in the module CLAUDE.md); the boundary-forced duplications (`sealSessionLog`, `_applyRankChangeTx`, `gradePassCount`) must stay in lockstep with core.
|
|
47
|
+
|
|
48
|
+
### 2. `ship.js` → 7 seams (task 1003205)
|
|
49
|
+
Phases are the seams (its own doc says "search by phase keyword"):
|
|
50
|
+
- `ship-surprise.js` — surprise/learning capture.
|
|
51
|
+
- `ship-memory-sync.js` — memory sync-on-ship.
|
|
52
|
+
- `ship-session-upload.js` — session digest + transcript upload + the ADR-0022 scrubber.
|
|
53
|
+
- `ship-git.js` — git/shell/gh helpers, `buildMergeArgv`, `sanitizeGitHubRefs`.
|
|
54
|
+
- `ship-deploy-target.js` — deploy-mode/target resolution.
|
|
55
|
+
- `ship-grade.js` — the subagent-grader runner + baseline computation + grade formatting.
|
|
56
|
+
- `ship-land.js` — merge/land/deploy orchestration + the ship card.
|
|
57
|
+
- `ship.js` remains argv parsing + the phase orchestration (`main`). **Risk: medium** — pure CLI, no server tests, but it is the release path; smoke-test a real ship after.
|
|
58
|
+
|
|
59
|
+
### 3. `builders.js` → 3 seams (task 1003206) — client JS
|
|
60
|
+
IIFE attaching to `window.OTB`; extract to sibling `<script>` files that attach to the same namespace (no bundler): `ranks.js` (rank weight/normalize/badge — already mirrored per the source), `hall-widgets.js` (`ensureStandingGroups`/`widgetHost`/`mountHallWidgets`), leaving roster/table rendering in `builders.js`. **Risk: medium** (load-order in the page HTML).
|
|
61
|
+
|
|
62
|
+
### 4. `provision.js` → 4 seams (task 1003206)
|
|
63
|
+
`provision-db.js` (create/migrate/seed cmds), `provision-repo.js` (scaffold + repo auth + deploy keys + core-pin refresh), `provision-systemd.js` (service/backup/timer unit templates), `provision-env.js` (web.env body + federation). Keep `provision.js` as the intent-runner + `CONFIG`. **Risk: medium** (control-plane ops; unit-template tests exist).
|
|
64
|
+
|
|
65
|
+
### 5. `grader.js` → 5 seams (task 1003206)
|
|
66
|
+
`grader-prepass.js` (permission-path + route-rank deterministic pre-passes), `grader-model.js` (model selection), `grader-prompt.js` (prompt + context bundle), `grader-score.js` (validators, server-side guards, aggregation), `grader-subagent.js` (spawn/one-shot/cached). Keep `grader.js` as `gradeSubagent`/`gradeDeterministic` orchestration + the port surface. **Risk: medium.**
|
|
67
|
+
|
|
68
|
+
### 6. `fitness.js` → checks-as-modules (task 1003206) — cleanest
|
|
69
|
+
Already the pattern: `fitness-ratchets.js` and `control-manifest.js` are separate check modules registered here. Extend it — move check clusters to `fitness-checks-boundary.js`, `-docs.js`, `-migrations.js`, `-identity.js`; keep `fitness.js` as the `CHECKS` registry + runner. Each check is independent → low coupling. **Risk: low.**
|
|
70
|
+
|
|
71
|
+
### 7. `goals-page.js` → extend the existing carve (task 1003206)
|
|
72
|
+
Carve already begun (`goals-lib.js` per task 1003135, shared `hall-kit.js`). Finish it: move the remaining presentational helpers (`goalRenderFacts`, `goalRowHtml`, `goalCardHtml`, `statusBadge`, `goalStampHtml`) into `goals-lib.js`; keep the page as fetch/filter/render control flow. **Risk: low.**
|
|
73
|
+
|
|
74
|
+
### 8. `seed-v3-tasks.js` → DELETE, else allowlist (task 1003207)
|
|
75
|
+
81 task literals + 1 idempotent seeder function — pure historical data for GDS-V3, which has **shipped**. First choice: **delete** it (verify nothing requires it — it's an idempotent one-shot; `seed-gds-v4-tasks.js` is the sibling and also a candidate). If a reason to keep it surfaces, add a narrow, documented exclusion to `EXCLUDE_RE` for that specific file (not a blanket `seed-*.js` rule, which could hide a genuinely oversized seeder with logic). **Risk: low.**
|
|
76
|
+
|
|
77
|
+
### 9. `routes/tasks.js` → sub-routers (task 1003206)
|
|
78
|
+
~29 routes across clear resources. Split into `routes/tasks/{crud,dependencies,criteria,lifecycle,peer-votes,publish,overrides}.js`, each a function that registers its handlers on the shared router; `tasks.js` becomes the index that calls them. The two heavyweight handlers — `POST /tasks` (~220 lines) and `PATCH /tasks/:id` (~290 lines) — carry most of the bulk; their validation blocks can move to `task-write-validators.js`. **Risk: medium** (route tests exist; keep paths identical).
|
|
79
|
+
|
|
80
|
+
### 10. `auth.js` → 4 seams (task 1003206) — HIGHEST RISK (kernel/protected)
|
|
81
|
+
`auth-github.js` (OAuth creds/scopes, web + device flow, `fetchGithubUser`), `auth-idp.js` (federation hub assertion + logout token), `auth-discord.js` (Discord OAuth + guild membership), `auth-admission.js` (membership/admission/first-admin/rank-role sync). Keep `auth.js` as session-token mgmt + `extractTokens` + the middleware surface. **Risk: highest** — it is the identity path and a protected kernel file; land it last, behind the auth tests, with extra review. Sub-Metic builders may not touch it (behavioral rule).
|
|
82
|
+
|
|
83
|
+
### 11. `routes/goals.js` → extend the existing carve (task 1003206)
|
|
84
|
+
Already extracting (`goalSuggest.*`, `goalEdits.*` handlers imported). Continue: move the membership-request / invitation / members / conflicts / archive-reopen-scope-transfer handlers into `goal-*` helper modules the router wires up. **Risk: low–medium.**
|
|
85
|
+
|
|
86
|
+
### 12. `box.js` → 2 seams (task 1003206) — only 19 lines over
|
|
87
|
+
12 `cmd*` subcommands. Extract the two largest (`cmdProvision` ~300, `cmdReconcile*`) into `box-commands.js`; `box.js` stays the dispatcher. Trivial to get under 1,500. **Risk: low.**
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Ratchet exclusion-list update (for task 1003207)
|
|
92
|
+
|
|
93
|
+
- **Do not** broaden `EXCLUDE_RE` to hide real code. The only content-based exclusion warranted is a **single-file** exception for `seed-v3-tasks.js` *if* it is kept rather than deleted (prefer deletion).
|
|
94
|
+
- `redoc.standalone.js` / `public-docs/` / `*.min.js` / `vendor/` stay excluded (correct — vendored/generated).
|
|
95
|
+
- As each file lands under 1,500, **lower** `oversized_file_count` and `excess_lines_total` in one PR with the extraction, so CI proves the drop.
|
|
96
|
+
|
|
97
|
+
## Suggested sequencing (low-risk first, prove the loop)
|
|
98
|
+
|
|
99
|
+
1. **box.js, fitness.js, goals-page.js, routes/goals.js** — low risk, existing carve patterns → immediate baseline drops, validates the ratchet-lowering flow.
|
|
100
|
+
2. **seed-v3-tasks.js delete** — removes the largest single count with the least code risk.
|
|
101
|
+
3. **routes/tasks.js, provision.js, grader.js, builders.js** — medium risk, module-local.
|
|
102
|
+
4. **ship.js (1003205)** — medium risk, release path; smoke a real ship after.
|
|
103
|
+
5. **db.js (1003204)** and **auth.js** — highest risk, land last, each behind its full test suite and (for auth.js) extra review.
|
|
104
|
+
|
|
105
|
+
Every step is independently shippable and lowers the baseline — there is no big-bang cutover.
|
package/modules/government/db.js
CHANGED
|
@@ -94,9 +94,9 @@ async function syncRank(client, { builderId, rank } = {}) {
|
|
|
94
94
|
return { target: null, added: [], removed: [] };
|
|
95
95
|
}
|
|
96
96
|
const q = client || pool;
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
const plan = planRankSync({ rank,
|
|
97
|
+
const seededRanks = await getSeededRanks(q);
|
|
98
|
+
const currentRanks = await getRankAssignmentsForBuilder(q, builderId);
|
|
99
|
+
const plan = planRankSync({ rank, currentRanks, seededRanks });
|
|
100
100
|
|
|
101
101
|
if (plan.remove.length > 0) {
|
|
102
102
|
await q.query(
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.312",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.312",
|
|
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.19.
|
|
3
|
+
"version": "1.19.312",
|
|
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",
|
|
@@ -343,6 +343,67 @@ function asyncHandler(tag, handler, { message = true, errorCode = 'server_error'
|
|
|
343
343
|
};
|
|
344
344
|
}
|
|
345
345
|
|
|
346
|
+
// ── async-rejection hardening (task 1002673 / idea 1000475) ──────────────────
|
|
347
|
+
//
|
|
348
|
+
// Express 4 does NOT catch a rejected promise from an async handler: the
|
|
349
|
+
// rejection never reaches next(err), the response is never written, and the
|
|
350
|
+
// request hangs until the CDN gives up with a 524 (bug 1675 — a validator threw
|
|
351
|
+
// on undefined.join() while BUILDING a 400 body, outside every try/catch).
|
|
352
|
+
// errorEnvelopeHandler is already the trailing net for next(err); these two
|
|
353
|
+
// functions are the missing conversion step. hardenRouterStack() runs ONCE at
|
|
354
|
+
// the end of buildGdsRouter(), walks the fully-composed router (core routers +
|
|
355
|
+
// loader-mounted module routers alike), and swaps every handler for a
|
|
356
|
+
// promise-aware shim — so every current AND future route is covered without a
|
|
357
|
+
// per-file sweep, and per-route context comes free from errorEnvelopeHandler's
|
|
358
|
+
// method+originalUrl log line.
|
|
359
|
+
//
|
|
360
|
+
// Sync semantics are unchanged (Express already forwards a sync throw to
|
|
361
|
+
// next(err)); error middleware (4-arg) is skipped because Express dispatches on
|
|
362
|
+
// fn.length. The `_asyncHardened` marker makes the pass idempotent and is what
|
|
363
|
+
// tests/async_handler_500.mjs walks to PROVE repo-wide coverage.
|
|
364
|
+
function promiseSafeHandler(fn) {
|
|
365
|
+
if (typeof fn !== 'function' || fn._asyncHardened || fn.length >= 4) return fn;
|
|
366
|
+
const wrapped = function (req, res, next) {
|
|
367
|
+
try {
|
|
368
|
+
const out = fn.call(this, req, res, next);
|
|
369
|
+
if (out && typeof out.catch === 'function') out.catch((err) => next(err));
|
|
370
|
+
return out;
|
|
371
|
+
} catch (err) {
|
|
372
|
+
next(err);
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
Object.assign(wrapped, fn); // preserve any props a middleware factory attached
|
|
376
|
+
wrapped._asyncHardened = true;
|
|
377
|
+
try { Object.defineProperty(wrapped, 'name', { value: fn.name || 'promiseSafeHandler' }); } catch { /* cosmetic */ }
|
|
378
|
+
return wrapped;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Recursively wrap every handler in a composed Express router: route-layer
|
|
382
|
+
// handlers (router.<method>), plain middleware layers (router.use), and nested
|
|
383
|
+
// routers (a mounted Router is a function carrying its own .stack). Returns the
|
|
384
|
+
// number of functions wrapped. Mutates layer.handle in place — Express Layers
|
|
385
|
+
// call `this.handle(…)`, so reassignment is the supported seam.
|
|
386
|
+
function hardenRouterStack(router, seen = new Set()) {
|
|
387
|
+
const stack = router && router.stack;
|
|
388
|
+
if (!Array.isArray(stack) || seen.has(stack)) return 0;
|
|
389
|
+
seen.add(stack);
|
|
390
|
+
let wrapped = 0;
|
|
391
|
+
for (const layer of stack) {
|
|
392
|
+
if (layer.route && Array.isArray(layer.route.stack)) {
|
|
393
|
+
for (const rl of layer.route.stack) {
|
|
394
|
+
const next = promiseSafeHandler(rl.handle);
|
|
395
|
+
if (next !== rl.handle) { rl.handle = next; wrapped++; }
|
|
396
|
+
}
|
|
397
|
+
} else if (typeof layer.handle === 'function' && Array.isArray(layer.handle.stack)) {
|
|
398
|
+
wrapped += hardenRouterStack(layer.handle, seen); // nested Router
|
|
399
|
+
} else {
|
|
400
|
+
const next = promiseSafeHandler(layer.handle);
|
|
401
|
+
if (next !== layer.handle) { layer.handle = next; wrapped++; }
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return wrapped;
|
|
405
|
+
}
|
|
406
|
+
|
|
346
407
|
// corsPublicGet — express middleware for the unauthenticated read-only public
|
|
347
408
|
// surface (ADR 0029 / #566). The off-box status page reads these cross-origin.
|
|
348
409
|
// Read-only, no-auth, no-secret routes, so a static '*' is safe; the caller
|
|
@@ -373,6 +434,8 @@ module.exports = {
|
|
|
373
434
|
VALIDATION_REASONS,
|
|
374
435
|
parseId,
|
|
375
436
|
asyncHandler,
|
|
437
|
+
promiseSafeHandler,
|
|
438
|
+
hardenRouterStack,
|
|
376
439
|
corsPublicGet,
|
|
377
440
|
PAGINATION,
|
|
378
441
|
parsePagination,
|
package/src/bongos/routes.js
CHANGED
|
@@ -65,6 +65,7 @@ const moduleLoader = require('../module-loader/loader');
|
|
|
65
65
|
const { auditMiddleware } = require('./middleware/audit');
|
|
66
66
|
const { rateLimitMiddleware } = require('./middleware/rate-limit');
|
|
67
67
|
const { attachFail, errorEnvelopeHandler } = require('./middleware/error-envelope');
|
|
68
|
+
const { hardenRouterStack } = require('./routes/_helpers');
|
|
68
69
|
|
|
69
70
|
function buildGdsRouter() {
|
|
70
71
|
const router = express.Router();
|
|
@@ -162,6 +163,14 @@ function buildGdsRouter() {
|
|
|
162
163
|
// handler that already responded is untouched (res.headersSent short-circuits).
|
|
163
164
|
router.use(errorEnvelopeHandler);
|
|
164
165
|
|
|
166
|
+
// Async-rejection hardening (task 1002673) — MUST run after every mount above.
|
|
167
|
+
// Walks the fully-composed stack (core + module routers) and swaps each handler
|
|
168
|
+
// for a promise-aware shim, so an async handler's rejection reaches next(err)
|
|
169
|
+
// and the errorEnvelopeHandler answers a fast, logged 500 instead of the
|
|
170
|
+
// request hanging until the CDN 524s (Express 4 does not do this itself).
|
|
171
|
+
// Error middleware (4-arg, incl. errorEnvelopeHandler) is skipped by design.
|
|
172
|
+
hardenRouterStack(router);
|
|
173
|
+
|
|
165
174
|
return router;
|
|
166
175
|
}
|
|
167
176
|
|
package/src/module-api.js
CHANGED
|
@@ -1047,8 +1047,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1047
1047
|
// landed since 1.19.308 with no explicit bump. run 32664868080. (task 1002620)
|
|
1048
1048
|
// 1.19.310 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1049
1049
|
// landed since 1.19.309 with no explicit bump. run 32665109518. (task 1002620)
|
|
1050
|
+
// 1.19.311 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1051
|
+
// landed since 1.19.310 with no explicit bump. run 32665535518. (task 1002620)
|
|
1052
|
+
// 1.19.312 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1053
|
+
// landed since 1.19.311 with no explicit bump. run 32665664699. (task 1002620)
|
|
1050
1054
|
// ---------------------------------------------------------------------------
|
|
1051
|
-
const CORE_VERSION = '1.19.
|
|
1055
|
+
const CORE_VERSION = '1.19.312'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1052
1056
|
|
|
1053
1057
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1054
1058
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// tests/async_handler_500.mjs — async route rejections must 500 fast, never hang
|
|
2
|
+
// (task 1002673 / idea 1000475; surfaced by bug 1675).
|
|
3
|
+
//
|
|
4
|
+
// THE BUG. Express 4 does not catch a rejected promise from an async route
|
|
5
|
+
// handler: the rejection never reaches next(err), no response is written, and
|
|
6
|
+
// the request hangs until the CDN gives up with a 524 — a mystery hang instead
|
|
7
|
+
// of an obvious 500. The wild case threw on undefined.join() while building a
|
|
8
|
+
// 400 body, OUTSIDE the handler's try/catch.
|
|
9
|
+
//
|
|
10
|
+
// THE FIX PINNED HERE. buildGdsRouter() ends with hardenRouterStack(router)
|
|
11
|
+
// (src/bongos/routes/_helpers.js): every handler in the composed stack — core
|
|
12
|
+
// routers, loader-mounted module routers, per-route middleware — is swapped for
|
|
13
|
+
// a promise-aware shim that pipes a rejection into next(err), where the existing
|
|
14
|
+
// errorEnvelopeHandler answers a logged 500 envelope. Error middleware (4-arg)
|
|
15
|
+
// is skipped because Express dispatches on fn.length.
|
|
16
|
+
//
|
|
17
|
+
// COVERAGE PROOF (the task's done-when): rather than trusting a sweep, the last
|
|
18
|
+
// test builds the REAL buildGdsRouter() and walks its full stack asserting every
|
|
19
|
+
// handler is either shim-marked (_asyncHardened) or 4-arg error middleware — so
|
|
20
|
+
// a future route added in ANY file is covered the moment it mounts, and this
|
|
21
|
+
// test fails if the hardening call is ever dropped.
|
|
22
|
+
//
|
|
23
|
+
// Run: node --test tests/async_handler_500.mjs
|
|
24
|
+
|
|
25
|
+
import assert from 'node:assert/strict';
|
|
26
|
+
import { test } from 'node:test';
|
|
27
|
+
import http from 'node:http';
|
|
28
|
+
import { createRequire } from 'node:module';
|
|
29
|
+
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
const express = require('express');
|
|
32
|
+
const { hardenRouterStack, promiseSafeHandler } = require('../src/bongos/routes/_helpers.js');
|
|
33
|
+
const { attachFail, errorEnvelopeHandler } = require('../src/bongos/middleware/error-envelope.js');
|
|
34
|
+
|
|
35
|
+
// Serve `app` on an ephemeral port, GET `path`, resolve { status, body, ms }.
|
|
36
|
+
// The 3s timeout is the hang detector: an unhardened rejection never responds.
|
|
37
|
+
function get(app, path, { timeoutMs = 3000 } = {}) {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
const server = http.createServer(app);
|
|
40
|
+
server.listen(0, '127.0.0.1', () => {
|
|
41
|
+
const { port } = server.address();
|
|
42
|
+
const t0 = Date.now();
|
|
43
|
+
const req = http.get({ host: '127.0.0.1', port, path }, (res) => {
|
|
44
|
+
let body = '';
|
|
45
|
+
res.on('data', (c) => { body += c; });
|
|
46
|
+
res.on('end', () => { server.close(); resolve({ status: res.statusCode, body, ms: Date.now() - t0 }); });
|
|
47
|
+
});
|
|
48
|
+
req.setTimeout(timeoutMs, () => { req.destroy(); server.close(); reject(new Error(`no response within ${timeoutMs}ms (hang)`)); });
|
|
49
|
+
req.on('error', (e) => { server.close(); reject(e); });
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function appWith(router) {
|
|
55
|
+
const app = express();
|
|
56
|
+
app.use(attachFail);
|
|
57
|
+
app.use(router);
|
|
58
|
+
app.use(errorEnvelopeHandler);
|
|
59
|
+
return app;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
test('a rejecting async handler answers a fast 500 envelope (not a hang) once hardened', async () => {
|
|
63
|
+
const router = express.Router();
|
|
64
|
+
router.get('/boom', async () => { throw new Error('kaboom: undefined.join()'); });
|
|
65
|
+
router.get('/late-boom', async (req, res) => {
|
|
66
|
+
await new Promise((r) => setTimeout(r, 10)); // reject after an await tick
|
|
67
|
+
throw new Error('late kaboom');
|
|
68
|
+
});
|
|
69
|
+
router.get('/fine', async (req, res) => { res.json({ ok: true }); });
|
|
70
|
+
const wrapped = hardenRouterStack(router);
|
|
71
|
+
assert.ok(wrapped >= 3, `expected all three handlers wrapped, got ${wrapped}`);
|
|
72
|
+
|
|
73
|
+
const app = appWith(router);
|
|
74
|
+
const boom = await get(app, '/boom');
|
|
75
|
+
assert.equal(boom.status, 500);
|
|
76
|
+
assert.ok(boom.ms < 2000, `must fail fast, took ${boom.ms}ms`);
|
|
77
|
+
const parsed = JSON.parse(boom.body);
|
|
78
|
+
assert.equal(parsed.error.code, 'internal_error');
|
|
79
|
+
assert.ok(!boom.body.includes('kaboom'), 'raw error message never leaks to the client');
|
|
80
|
+
|
|
81
|
+
const late = await get(app, '/late-boom');
|
|
82
|
+
assert.equal(late.status, 500);
|
|
83
|
+
|
|
84
|
+
const fine = await get(app, '/fine');
|
|
85
|
+
assert.equal(fine.status, 200);
|
|
86
|
+
assert.equal(JSON.parse(fine.body).ok, true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('control: the SAME rejecting handler without hardening hangs (proves the shim is load-bearing)', async () => {
|
|
90
|
+
// Runs in a CHILD process: the unhandled rejection is the bug itself, and the
|
|
91
|
+
// node:test runner in THIS process would (correctly) flag it. The child prints
|
|
92
|
+
// HANG when no response arrives within 1200ms — which is the expected outcome
|
|
93
|
+
// on Express 4 without the shim. If Express ever starts catching async
|
|
94
|
+
// rejections natively (v5), this prints RESPONDED and the shim is obsolete.
|
|
95
|
+
const { execFileSync } = await import('node:child_process');
|
|
96
|
+
const child = `
|
|
97
|
+
process.on('unhandledRejection', () => {}); // the bug: nothing else handles it
|
|
98
|
+
const express = require('express');
|
|
99
|
+
const http = require('http');
|
|
100
|
+
const app = express();
|
|
101
|
+
const router = express.Router();
|
|
102
|
+
router.get('/boom', async () => { throw new Error('unharnessed'); });
|
|
103
|
+
app.use(router);
|
|
104
|
+
const server = http.createServer(app);
|
|
105
|
+
server.listen(0, '127.0.0.1', () => {
|
|
106
|
+
const { port } = server.address();
|
|
107
|
+
const req = http.get({ host: '127.0.0.1', port, path: '/boom' }, () => {
|
|
108
|
+
console.log('RESPONDED'); process.exit(0);
|
|
109
|
+
});
|
|
110
|
+
req.setTimeout(1200, () => { console.log('HANG'); process.exit(0); });
|
|
111
|
+
req.on('error', () => { console.log('HANG'); process.exit(0); });
|
|
112
|
+
});
|
|
113
|
+
`;
|
|
114
|
+
const out = execFileSync(process.execPath, ['-e', child], { encoding: 'utf8', timeout: 10_000 });
|
|
115
|
+
assert.match(out, /HANG/, 'unhardened async rejection should hang (if RESPONDED, Express now catches natively and the shim can retire)');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('promiseSafeHandler: sync handlers and error middleware are untouched semantics', async () => {
|
|
119
|
+
// 4-arg error middleware must be returned as-is (Express dispatches on length).
|
|
120
|
+
const errMw = (err, req, res, next) => next(err);
|
|
121
|
+
assert.equal(promiseSafeHandler(errMw), errMw);
|
|
122
|
+
// Idempotent: a hardened fn is not re-wrapped.
|
|
123
|
+
const once = promiseSafeHandler(async () => {});
|
|
124
|
+
assert.equal(promiseSafeHandler(once), once);
|
|
125
|
+
// A sync throw still becomes a 500 through the same path.
|
|
126
|
+
const router = express.Router();
|
|
127
|
+
router.get('/sync-boom', () => { throw new Error('sync kaboom'); });
|
|
128
|
+
hardenRouterStack(router);
|
|
129
|
+
const res = await get(appWith(router), '/sync-boom');
|
|
130
|
+
assert.equal(res.status, 500);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('REPO-WIDE PROOF: every handler in the real composed GDS router is hardened or error middleware', async () => {
|
|
134
|
+
const { buildGdsRouter } = require('../src/bongos/routes.js');
|
|
135
|
+
const router = buildGdsRouter();
|
|
136
|
+
|
|
137
|
+
let routeHandlers = 0;
|
|
138
|
+
let unhardened = [];
|
|
139
|
+
const seen = new Set();
|
|
140
|
+
const walk = (r, trail) => {
|
|
141
|
+
const stack = r && r.stack;
|
|
142
|
+
if (!Array.isArray(stack) || seen.has(stack)) return;
|
|
143
|
+
seen.add(stack);
|
|
144
|
+
for (const layer of stack) {
|
|
145
|
+
if (layer.route && Array.isArray(layer.route.stack)) {
|
|
146
|
+
for (const rl of layer.route.stack) {
|
|
147
|
+
routeHandlers++;
|
|
148
|
+
if (typeof rl.handle === 'function' && !rl.handle._asyncHardened && rl.handle.length < 4) {
|
|
149
|
+
unhardened.push(`${trail} ${Object.keys(layer.route.methods).join(',')} ${layer.route.path}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} else if (typeof layer.handle === 'function' && Array.isArray(layer.handle.stack)) {
|
|
153
|
+
walk(layer.handle, `${trail}>`);
|
|
154
|
+
} else if (typeof layer.handle === 'function') {
|
|
155
|
+
if (!layer.handle._asyncHardened && layer.handle.length < 4) {
|
|
156
|
+
unhardened.push(`${trail} [middleware ${layer.handle.name || 'anonymous'}]`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
walk(router, 'gds');
|
|
162
|
+
|
|
163
|
+
assert.ok(routeHandlers >= 250,
|
|
164
|
+
`expected the composed router to carry hundreds of route handlers, saw ${routeHandlers} — did module mounting break?`);
|
|
165
|
+
assert.deepEqual(unhardened, [],
|
|
166
|
+
`every handler must be async-hardened or 4-arg error middleware; naked: ${unhardened.slice(0, 10).join('; ')}`);
|
|
167
|
+
});
|
|
@@ -229,3 +229,93 @@ test('every governance migration keeps its objects namespaced and additive', ()
|
|
|
229
229
|
assert.ok(m[1].startsWith('governance_'), `object ${m[1]} is not governance_-namespaced`);
|
|
230
230
|
}
|
|
231
231
|
});
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// the writer, EXECUTED (task 1003213)
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
//
|
|
237
|
+
// Everything above this line proves the DECISION (planRankSync, called directly
|
|
238
|
+
// with the right argument names) and the SQL TEXT (regex over db.js source). That
|
|
239
|
+
// pair has a hole exactly the width of the call site: nothing asserted that
|
|
240
|
+
// db.syncRank passes planRankSync the parameters it actually destructures.
|
|
241
|
+
//
|
|
242
|
+
// It did not, for a week. The ADR 0174 governance→government rename landed the
|
|
243
|
+
// call site as `planRankSync({ rank, currentRoles, seededRoles })` (14dcedd) and
|
|
244
|
+
// then renamed the planner's parameters to `currentRanks`/`seededRanks` (f7cdcce).
|
|
245
|
+
// The planner therefore read an EMPTY seed list, returned target:null / add:[],
|
|
246
|
+
// and syncRank silently wrote NOTHING — no throw, so safeSyncRankRole logged
|
|
247
|
+
// nothing either. Every builder whose first sign-in came after their instance's
|
|
248
|
+
// run-once governance_002 backfill held ZERO permissions (getPermissionKeysForBuilder
|
|
249
|
+
// resolves only through government_builder_ranks), including a freshly seated
|
|
250
|
+
// Archon owner locked out of their own hall.
|
|
251
|
+
//
|
|
252
|
+
// So these RUN the writer against a recording client. A rename that desynchronises
|
|
253
|
+
// the call site from the planner fails here, where source-text assertions cannot see it.
|
|
254
|
+
|
|
255
|
+
const govDb = require(path.join(GOV, 'db.js'));
|
|
256
|
+
|
|
257
|
+
// A stand-in for the caller's transaction client that records every statement and
|
|
258
|
+
// answers the writer's two reads. Order matters: the assignments read also names
|
|
259
|
+
// `government_ranks` (it joins it), so match the more specific table first.
|
|
260
|
+
function recordingClient({ seeded = SEEDED, held = [] } = {}) {
|
|
261
|
+
const calls = [];
|
|
262
|
+
return {
|
|
263
|
+
calls,
|
|
264
|
+
find: (re) => calls.find((c) => re.test(c.sql)),
|
|
265
|
+
async query(sql, params) {
|
|
266
|
+
calls.push({ sql, params });
|
|
267
|
+
if (/INSERT INTO|DELETE FROM/.test(sql)) return { rows: [] };
|
|
268
|
+
if (/FROM government_builder_ranks br/.test(sql)) return { rows: held };
|
|
269
|
+
if (/FROM government_ranks/.test(sql)) return { rows: seeded };
|
|
270
|
+
return { rows: [] };
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
test('the writer EXECUTES its plan: a rank-less builder really gets the INSERT', async () => {
|
|
276
|
+
const c = recordingClient({ held: [] });
|
|
277
|
+
const res = await govDb.syncRank(c, { builderId: 42, rank: 'archon' });
|
|
278
|
+
|
|
279
|
+
// The verdict must survive the call site — target:null here is the regression.
|
|
280
|
+
assert.equal(res.target, 'archon',
|
|
281
|
+
'syncRank must pass planRankSync the parameter names it destructures (currentRanks/seededRanks)');
|
|
282
|
+
assert.deepEqual([...res.added], ['archon']);
|
|
283
|
+
|
|
284
|
+
const insert = c.find(/INSERT INTO government_builder_ranks/);
|
|
285
|
+
assert.ok(insert, 'the assignment INSERT must actually be issued, not merely present in the source');
|
|
286
|
+
assert.deepEqual(insert.params, [42, ['archon']],
|
|
287
|
+
'the INSERT must name the builder and the seeded rank the plan chose');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('the writer EXECUTES its plan: a demotion really issues the DELETE', async () => {
|
|
291
|
+
// The trust-boundary half: a demoted Archon must lose `archon` on the next request.
|
|
292
|
+
const c = recordingClient({ held: [seededRank_('archon')] });
|
|
293
|
+
const res = await govDb.syncRank(c, { builderId: 7, rank: 'thetes' });
|
|
294
|
+
|
|
295
|
+
assert.equal(res.target, 'thetes');
|
|
296
|
+
assert.deepEqual([...res.removed], ['archon']);
|
|
297
|
+
|
|
298
|
+
const del = c.find(/DELETE FROM government_builder_ranks/);
|
|
299
|
+
assert.ok(del, 'the superseded assignment DELETE must actually be issued');
|
|
300
|
+
assert.deepEqual(del.params, [7, ['archon']]);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('the writer EXECUTES its plan: an unchanged rank writes nothing (idempotence)', async () => {
|
|
304
|
+
const c = recordingClient({ held: [seededRank_('metic')] });
|
|
305
|
+
const res = await govDb.syncRank(c, { builderId: 9, rank: 'metic' });
|
|
306
|
+
|
|
307
|
+
assert.deepEqual([...res.added], []);
|
|
308
|
+
assert.deepEqual([...res.removed], []);
|
|
309
|
+
assert.ok(!c.find(/INSERT INTO|DELETE FROM/),
|
|
310
|
+
'a builder already holding the right rank must cost reads only — no write');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('the writer EXECUTES its plan: a custom rank survives a rank change', async () => {
|
|
314
|
+
const c = recordingClient({ held: [seededRank_('archon'), customRank('release-manager')] });
|
|
315
|
+
const res = await govDb.syncRank(c, { builderId: 11, rank: 'metic' });
|
|
316
|
+
|
|
317
|
+
assert.deepEqual([...res.removed], ['archon'],
|
|
318
|
+
'only seeded ranks rotate — an Archon-assigned custom rank must never be revoked');
|
|
319
|
+
const del = c.find(/DELETE FROM government_builder_ranks/);
|
|
320
|
+
assert.deepEqual(del.params, [11, ['archon']]);
|
|
321
|
+
});
|