@bongos/core 1.19.309 → 1.19.311
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 +23 -13
- package/docs/design/demonolith-plan.md +105 -0
- 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/.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.311",
|
|
6
|
+
"core_contract": "1.19.311",
|
|
7
|
+
"source_commit": "e1c43e441e7fc621f31480ab2306aa29cf6fabfa",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-23T20:
|
|
9
|
+
"built_at": "2026-08-23T20:49:01.750Z",
|
|
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": "52b0f6698975b1156c233e14471c23249226712403aaad8498402f78a7a3c5d4",
|
|
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",
|
|
@@ -4577,12 +4582,12 @@
|
|
|
4577
4582
|
{
|
|
4578
4583
|
"path": "package-lock.json",
|
|
4579
4584
|
"mode": "0000644",
|
|
4580
|
-
"sha256": "
|
|
4585
|
+
"sha256": "ac29f684d093a69db95b7e41ff40c70dccf70ea5a9bce2e78bf2a5b02ec302e7"
|
|
4581
4586
|
},
|
|
4582
4587
|
{
|
|
4583
4588
|
"path": "package.json",
|
|
4584
4589
|
"mode": "0000644",
|
|
4585
|
-
"sha256": "
|
|
4590
|
+
"sha256": "118f6dad272f412617ba4f4693317498f42580f7bb84cdca272705b3a696fe22"
|
|
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": "c936d7dda32ef492b30f7b1230157e27d088606066056188794b0fd1b42f34f7"
|
|
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",
|
|
@@ -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/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.311",
|
|
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.311",
|
|
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.311",
|
|
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
|
@@ -1045,8 +1045,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1045
1045
|
// landed since 1.19.307 with no explicit bump. run 32664559221. (task 1002620)
|
|
1046
1046
|
// 1.19.309 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1047
1047
|
// landed since 1.19.308 with no explicit bump. run 32664868080. (task 1002620)
|
|
1048
|
+
// 1.19.310 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
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)
|
|
1048
1052
|
// ---------------------------------------------------------------------------
|
|
1049
|
-
const CORE_VERSION = '1.19.
|
|
1053
|
+
const CORE_VERSION = '1.19.311'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1050
1054
|
|
|
1051
1055
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1052
1056
|
// 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
|
+
});
|