castrum 0.9.6 → 0.9.8

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.
Files changed (88) hide show
  1. package/Cargo.lock +4 -3
  2. package/Cargo.toml +1 -1
  3. package/castrum.darwin-arm64.node +0 -0
  4. package/castrum.darwin-x64.node +0 -0
  5. package/castrum.linux-arm64-gnu.node +0 -0
  6. package/castrum.linux-arm64-musl.node +0 -0
  7. package/castrum.linux-x64-gnu.node +0 -0
  8. package/castrum.linux-x64-musl.node +0 -0
  9. package/castrum.linux-x64-v3-gnu.node +0 -0
  10. package/castrum.win32-x64-msvc.node +0 -0
  11. package/dist/index.d.ts +501 -5
  12. package/dist/index.js +1658 -1314
  13. package/docs/ARCHITECTURE.md +1 -1
  14. package/docs/BENCHMARKS.md +13 -0
  15. package/docs/CASE_STUDY.md +2 -2
  16. package/docs/ENVIRONMENT.md +1 -1
  17. package/docs/FFI_BUN_GUIDE.md +13 -1
  18. package/docs/HOW_TO_ADD_AN_OP.md +24 -0
  19. package/docs/INGRESS.md +4 -4
  20. package/docs/REPO_MAP.md +8 -6
  21. package/docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md +617 -0
  22. package/index.ts +1 -0
  23. package/package.json +3 -2
  24. package/rust/crypto/aead.rs +44 -0
  25. package/rust/crypto/argon2.rs +32 -2
  26. package/rust/crypto/jwt/tests.rs +19 -0
  27. package/rust/crypto/jwt/token.rs +43 -28
  28. package/rust/crypto/session.rs +170 -7
  29. package/rust/ffi/crypto.rs +182 -61
  30. package/rust/ffi/hashing.rs +48 -33
  31. package/rust/ffi/http.rs +62 -36
  32. package/rust/ffi/metrics.rs +6 -2
  33. package/rust/ffi/payload.rs +47 -27
  34. package/rust/ffi/task.rs +51 -32
  35. package/rust/ffi/tests.rs +197 -0
  36. package/rust/ffi/util.rs +40 -0
  37. package/rust/http/headers.rs +13 -0
  38. package/rust/http/multipart.rs +139 -0
  39. package/rust/ingress/native_route.rs +21 -3
  40. package/rust/task/completion.rs +7 -0
  41. package/rust/task/mod.rs +1 -1
  42. package/rust/task/tests.rs +30 -0
  43. package/src/ingress/decode/baked-result.ts +13 -5
  44. package/src/ingress/decode/fast-result.ts +1 -1
  45. package/src/ingress/decode/result-base.ts +16 -2
  46. package/src/ingress/handlers.ts +103 -31
  47. package/src/ingress/headers/memoized-headers.ts +40 -0
  48. package/src/ingress/native-route.ts +25 -6
  49. package/src/ingress/options.ts +26 -2
  50. package/src/ingress/packing/gather-raw-headers.ts +24 -8
  51. package/src/ingress/response/baked-response.ts +118 -28
  52. package/src/ingress/router.ts +14 -2
  53. package/src/ingress/routes/common.ts +26 -10
  54. package/src/ingress/routes/echo.ts +50 -7
  55. package/src/ingress/routes/json-write.ts +21 -8
  56. package/src/ingress/routes/responder.ts +4 -4
  57. package/src/ingress/server-node.ts +33 -14
  58. package/src/ingress/server.ts +3 -4
  59. package/src/ingress/shared.ts +1 -1
  60. package/src/ingress/types.ts +15 -0
  61. package/src/loader/ops.ts +1 -1
  62. package/src/native/ffi/build/codecs-selftest.ts +305 -0
  63. package/src/native/ffi/build/codecs.ts +0 -296
  64. package/src/native/ffi/build/instances-selftest.ts +264 -0
  65. package/src/native/ffi/build/instances.ts +39 -241
  66. package/src/native/ffi/build/metrics.ts +0 -7
  67. package/src/native/ffi/build/parse-selftest.ts +238 -0
  68. package/src/native/ffi/build/parse.ts +11 -234
  69. package/src/native/ffi/build.ts +5 -1
  70. package/src/native/ffi/selftest.ts +3 -3
  71. package/src/native/ffi/symbols.ts +460 -0
  72. package/src/native/ffi/types.ts +17 -0
  73. package/src/native/ffi.ts +2 -422
  74. package/src/native/ingress-binding.ts +39 -0
  75. package/src/runtime/codec.ts +24 -0
  76. package/src/runtime/types.ts +11 -0
  77. package/src/rust-ffi/batch/build.ts +18 -8
  78. package/src/rust-ffi/batch/types.ts +15 -0
  79. package/src/rust-ffi/options.ts +19 -0
  80. package/src/rust-ffi/scalar/crypto.ts +8 -0
  81. package/src/rust-ffi/scalar/factories.ts +47 -20
  82. package/src/rust-ffi/scalar/http.ts +60 -10
  83. package/src/rust-ffi/scalar/interface.ts +4 -1
  84. package/src/rust-ffi/scalar/payload.ts +27 -1
  85. package/src/shared/buffer-pool.ts +44 -9
  86. package/src/shared/codec.ts +26 -0
  87. package/src/shared/packed/parsers.ts +64 -7
  88. package/src/task/runtime.ts +29 -28
@@ -0,0 +1,617 @@
1
+ # castrum hardening & maintainability sweep — Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use
4
+ > `superpowers:subagent-driven-development` (recommended) or
5
+ > `superpowers:executing-plans` to implement this plan task-by-task. Steps use
6
+ > checkbox (`- [ ]`) syntax for tracking.
7
+
8
+ **Goal:** Fix the verified safety, security, and correctness defects in castrum,
9
+ finish/repair the in-flight performance WIP, and remove maintainability traps —
10
+ without changing the two ingress wire formats.
11
+
12
+ **Architecture:** Changes are grouped by risk: (P0) native handle/lifecycle
13
+ safety, (P1) WIP correctness + ingress security, (P2) crypto hardening +
14
+ maintainability. Each task is independently testable and leaves the run green.
15
+ No task may alter the success/error body shapes or `ratelimit-*` header names
16
+ (`docs/adr/0001-two-wire-formats.md`, AGENTS.md).
17
+
18
+ **Tech Stack:** TypeScript (Bun-first, Node fallback), Rust cdylib via napi-rs +
19
+ `bun:ffi`, Biome, `bun test`, `cargo test`.
20
+
21
+ **Spec:** This plan is its own spec — it was produced from a read-only audit of
22
+ the working tree at commit `adc5d8f` plus uncommitted WIP in
23
+ `bench/cost/ingress-cost.ts`, `src/ingress/handlers.ts`,
24
+ `src/ingress/packing/gather-raw-headers.ts`, `src/shared/buffer-pool.ts`.
25
+ Findings were verified line-by-line against source; anything unverified is
26
+ marked `[verify]`.
27
+
28
+ ## Global Constraints
29
+
30
+ - Bun-first; never slow or complicate the Bun path for Node compatibility.
31
+ - One numeric layout source: `rust/ingress/output.rs` → `src/ingress/constants.ts`. Never hardcode.
32
+ - Do NOT unify the two ingress wire formats.
33
+ - Every fallible `extern "C"` export routes through `panic_guard`; needed-size
34
+ convention `0` = error, `w > out_cap` = exact required size.
35
+ - No `static mut`; cross-thread state via atomics/mutex; thread-local scratch only.
36
+ - New public exports need JSDoc (`bun run check:jsdoc`) and a `PROVEN_SELECTION` /
37
+ `selection.json` entry if they are `rust.*` ops.
38
+ - Docs must match code (RULES.md §7).
39
+ - Baseline at plan time: `bun run typecheck`, `bun run lint`, `check:clean`,
40
+ `check:jsdoc` (501/502) pass; 823 TS tests pass. Record `bun --version` and
41
+ `cargo test` count before starting.
42
+
43
+ ---
44
+
45
+ ## Phase 0 — Baseline capture
46
+
47
+ ### Task 0: Record the green baseline and bun version
48
+
49
+ **Files:** none (evidence only)
50
+
51
+ - [ ] **Step 1: Capture runtime + test baseline**
52
+
53
+ Run:
54
+ ```bash
55
+ bun --version
56
+ cargo test 2>&1 | tail -20
57
+ ```
58
+ Expected: all Rust tests pass. Save the pass count and bun version in the task log.
59
+
60
+ - [ ] **Step 2: Commit the plan (and any pre-existing WIP in a separate commit)**
61
+
62
+ ```bash
63
+ git add docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md
64
+ git commit -m "docs(plan): hardening & maintainability sweep"
65
+ ```
66
+ Leave the four WIP source files uncommitted here; Task 7–10 finish them.
67
+
68
+ ---
69
+
70
+ ## Phase 1 — P0: native lifecycle & memory safety
71
+
72
+ ### Task 1: Task-runtime doorbell use-after-free + restart-after-shutdown (CRITICAL)
73
+
74
+ **Files:**
75
+ - Modify: `rust/task/completion.rs` (add `clear_doorbell`)
76
+ - Modify: `rust/task/runtime.rs` (shutdown latch; refuse submit after shutdown)
77
+ - Modify: `rust/ffi/task.rs:160-166` (`castrum_task_shutdown` clears the doorbell first)
78
+ - Modify: `rust/task/mod.rs` (re-export)
79
+ - Modify: `src/task/runtime.ts:554-564` (zero the native doorbell before `close()`)
80
+ - Test: `rust/task/tests.rs`, `test/unit/task/runtime.test.ts`
81
+
82
+ **Interfaces:**
83
+ - Produces: `rust::task::completion::clear_doorbell()`; `runtime::submit` returns
84
+ `false` once `shutdown()` has run (until an explicit `init`).
85
+
86
+ **Why:** `push()` (`completion.rs:62-77`) calls the registered trampoline.
87
+ `shutdown()` closes the JS `JSCallback` but `DOORBELL` is never reset, and
88
+ `runtime::submit` auto-restarts the pool (`runtime.rs:199-203`), so a later
89
+ completion can call a freed trampoline.
90
+
91
+ - [ ] **Step 1: Add a failing JS lifecycle test**
92
+
93
+ ```ts
94
+ // test/unit/task/runtime.test.ts
95
+ test('shutdown is terminal: a later submit rejects, never restarts the pool', async () => {
96
+ const runtime = createTaskRuntime()
97
+ await runtime.gzipCompress(new Uint8Array([1, 2, 3]))
98
+ runtime.shutdown()
99
+ await expect(runtime.gzipCompress(new Uint8Array([1, 2, 3]))).rejects.toThrow()
100
+ })
101
+ ```
102
+
103
+ - [ ] **Step 2: Run it to confirm it currently hangs/restarts**
104
+
105
+ Run: `bun test test/unit/task/runtime.test.ts -t terminal`
106
+ Expected: FAIL (resolves instead of rejecting, or restarts the pool).
107
+
108
+ - [ ] **Step 3: Implement the Rust latch + doorbell clear**
109
+
110
+ In `rust/task/completion.rs` add:
111
+ ```rust
112
+ /// Disable the doorbell trampoline (`0`). Called on shutdown so a late
113
+ /// completion can never call a freed JS callback.
114
+ pub fn clear_doorbell() {
115
+ DOORBELL.store(0, Ordering::Release);
116
+ }
117
+ ```
118
+ In `rust/task/runtime.rs` add:
119
+ ```rust
120
+ use std::sync::atomic::AtomicBool;
121
+ static SHUTDOWN: AtomicBool = AtomicBool::new(false);
122
+
123
+ pub fn shutdown() {
124
+ SHUTDOWN.store(true, Ordering::Release);
125
+ if let Some(p) = POOL.get() { p.shutdown(); }
126
+ }
127
+
128
+ pub fn submit<F>(job: F) -> bool where F: FnOnce() + Send + 'static {
129
+ if SHUTDOWN.load(Ordering::Acquire) { return false; }
130
+ let p = POOL.get_or_init(TaskPool::new);
131
+ if !p.running.load(Ordering::Acquire) { p.start(default_threads()); }
132
+ p.enqueue(Box::new(job));
133
+ true
134
+ }
135
+ ```
136
+ Reset `SHUTDOWN` in `init()` (`init` is the explicit restart path).
137
+ In `rust/ffi/task.rs`:
138
+ ```rust
139
+ #[no_mangle]
140
+ pub extern "C" fn castrum_task_shutdown() -> u32 {
141
+ task::completion::clear_doorbell(); // BEFORE stopping: no late ring
142
+ task::shutdown();
143
+ task::completion::clear();
144
+ 0
145
+ }
146
+ ```
147
+ Re-export `clear_doorbell` from `rust/task/mod.rs`.
148
+
149
+ - [ ] **Step 4: Update the JS runtime**
150
+
151
+ In `src/task/runtime.ts` `shutdown()`:
152
+ ```ts
153
+ shutdown() {
154
+ for (const [id, deferred] of inflight) { ffi.taskCancel(id); deferred.reject(cancelledError()) }
155
+ inflight.clear()
156
+ ffi.taskSetDoorbell(0) // native side can no longer call the trampoline
157
+ ffi.taskShutdown()
158
+ release()
159
+ doorbell?.close()
160
+ doorbell = null
161
+ }
162
+ ```
163
+ Make the `submit`/`submitInto`/`submitSlice` helpers treat a `false` return as
164
+ an error: reject with `new Error('task runtime: pool is shut down')`.
165
+
166
+ - [ ] **Step 5: Run tests**
167
+
168
+ Run: `cargo test task && bun test test/unit/task/runtime.test.ts`
169
+ Expected: PASS.
170
+
171
+ - [ ] **Step 6: Commit**
172
+
173
+ ```bash
174
+ git add rust/task rust/ffi/task.rs src/task/runtime.ts test/unit/task/runtime.test.ts
175
+ git commit -m "fix(task): clear doorbell + latch shutdown to prevent callback use-after-free"
176
+ ```
177
+
178
+ ---
179
+
180
+ ### Task 2: `NativeRoute` double-free / use-after-free (HIGH)
181
+
182
+ **Files:**
183
+ - Modify: `src/ingress/native-route.ts:66-159`
184
+ - Test: `test/unit/ingress/native-route.test.ts`
185
+
186
+ **Why:** `ffiHandle` is `const`; `destroy()` frees unconditionally and is
187
+ documented "idempotent" but is not; `runFrame()` after destroy passes a freed
188
+ handle into Rust (`&*(handle …)`).
189
+
190
+ - [ ] **Step 1: Add failing tests**
191
+
192
+ ```ts
193
+ test('destroy is idempotent and run-after-destroy throws', () => {
194
+ const route = createNativeRoute({ parseQuery: true })
195
+ route.destroy()
196
+ route.destroy() // must not double-free
197
+ expect(() => route.run('a=1', '', null)).toThrow()
198
+ })
199
+ ```
200
+
201
+ - [ ] **Step 2: Confirm failure**
202
+
203
+ Run: `bun test test/unit/ingress/native-route.test.ts`
204
+ Expected: FAIL (throws on second destroy / does not throw on run).
205
+
206
+ - [ ] **Step 3: Make the handle mutable and guarded**
207
+
208
+ ```ts
209
+ let ffiHandle = bunFFI !== null ? compileFfi(bunFFI, descriptor) : 0
210
+ // Fall back to napi when FFI is absent OR compile failed (0 = invalid plan).
211
+ const napiRoute =
212
+ bunFFI === null || ffiHandle === 0 ? compileNapi(descriptor) : null
213
+
214
+ const ensureLive = (): void => {
215
+ if (ffiHandle === 0 && napiRoute === null) throw new Error('native route: destroyed')
216
+ }
217
+ ```
218
+ In `runFrame` call `ensureLive()` first; in `destroy()`:
219
+ ```ts
220
+ destroy() {
221
+ if (ffiHandle !== 0 && bunFFI !== null) { bunFFI.routeDestroy(ffiHandle); ffiHandle = 0 }
222
+ napiRoute?.destroy?.()
223
+ }
224
+ ```
225
+ Update the JSDoc to describe the real contract (idempotent, run-after-destroy throws).
226
+
227
+ - [ ] **Step 4: Run tests**
228
+
229
+ Run: `bun test test/unit/ingress/native-route.test.ts`
230
+ Expected: PASS.
231
+
232
+ - [ ] **Step 5: Commit**
233
+
234
+ ```bash
235
+ git add src/ingress/native-route.ts test/unit/ingress/native-route.test.ts
236
+ git commit -m "fix(native-route): idempotent destroy + guard run-after-destroy"
237
+ ```
238
+
239
+ ---
240
+
241
+ ### Task 3: `MetricsRegistry` double-free (MEDIUM)
242
+
243
+ **Files:**
244
+ - Modify: `src/rust-ffi/scalar/factories.ts:381-383`
245
+ - Test: `test/unit/rust-ffi/metrics.test.ts` (add case)
246
+
247
+ - [ ] **Step 1: Add a failing idempotency test**
248
+
249
+ ```ts
250
+ test('MetricsRegistry.destroy is idempotent', () => {
251
+ const m = createMetricsRegistry()
252
+ m.destroy()
253
+ expect(() => m.destroy()).not.toThrow()
254
+ })
255
+ ```
256
+
257
+ - [ ] **Step 2: Implement the guard**
258
+
259
+ Make `handle` a `let`; in `destroy()`: `if (handle !== 0) { ffi.metricsDestroy(handle); handle = 0 }`.
260
+ Give `record`/`render`/`snapshot` a `if (handle === 0) throw new Error('metrics: destroyed')` guard.
261
+
262
+ - [ ] **Step 3: Run + commit**
263
+
264
+ ```bash
265
+ bun test test/unit/rust-ffi
266
+ git add src/rust-ffi/scalar/factories.ts test/unit/rust-ffi
267
+ git commit -m "fix(metrics): idempotent destroy + use-after-destroy guard"
268
+ ```
269
+
270
+ ---
271
+
272
+ ### Task 4: Missing `panic_guard` on fallible exports (MEDIUM)
273
+
274
+ **Files:**
275
+ - Modify: `rust/ffi/crypto.rs` (`castrum_password_verify`, `castrum_password_verify_bcrypt`)
276
+ - Modify: `rust/ffi/http.rs` (`*_parse_packed` ×4)
277
+ - Modify: `rust/ffi/hashing.rs` (`castrum_json_sum_ids`, `castrum_hex_validate_batch`, `castrum_regex_escape`)
278
+ - Modify: `rust/ffi/payload.rs` (`castrum_ws_accept_key`, `castrum_ws_frame_encode`, `castrum_ws_frame_decode_packed`)
279
+ - Modify: `rust/ffi/task.rs` (`castrum_task_submit*`)
280
+ - Test: existing per-module tests (`cargo test`)
281
+
282
+ **Why:** a panic through `extern "C"` aborts the process. Wrap each body in
283
+ `panic_guard(|| core, <safe-fallback>)` where the fallback is `0` for byte
284
+ counts, `1` for the `password_verify` u32 protocol (verify: confirm 0 is the
285
+ "fail" sentinel), and `std::ptr::null()` for cstring returns.
286
+
287
+ - [ ] **Step 1: Wrap each export** (read the exact return type first; never guess the fallback).
288
+ - [ ] **Step 2: `cargo test` and `cargo clippy`** — expected PASS/clean.
289
+ - [ ] **Step 3: `bun run build:all`** (rebuild baseline + v3; stale `.node` segfaults).
290
+ - [ ] **Step 4: `bun test test/unit/native`** then commit.
291
+
292
+ ```bash
293
+ git commit -am "fix(ffi): panic_guard the remaining fallible extern \"C\" exports"
294
+ ```
295
+
296
+ ---
297
+
298
+ ### Task 5: `cstring` NUL truncation on rate-limit + accept-negotiation keys (MEDIUM)
299
+
300
+ **Files:**
301
+ - Modify: `src/native/ffi/build/instances.ts:82-88,196-206`
302
+ - Test: `test/unit/native/cstring-nul.test.ts`
303
+
304
+ **Why:** `rateLimiterCheck(inner, key, …)` and
305
+ `acceptNegotiatorNegotiateServer` pass user-reachable strings to a `cstring` arg
306
+ with no `hasNul` guard (`hasNul` is already imported in this file). A NUL
307
+ truncates the key → distinct keys share one bucket.
308
+
309
+ - [ ] **Step 1:** Check whether a `_bytes` Rust sibling exists for each symbol. If
310
+ yes, route to it. If no, guard with `hasNul` and throw a clear error (fail
311
+ closed) rather than silently truncating.
312
+ - [ ] **Step 2:** Add tests asserting a NUL-containing key does not silently
313
+ collide with the NUL-free key.
314
+ - [ ] **Step 3:** `bun test test/unit/native` + commit.
315
+
316
+ ```bash
317
+ git commit -am "fix(ffi): reject/route NUL-containing rate-limit and accept keys"
318
+ ```
319
+
320
+ ---
321
+
322
+ ### Task 6: Native route fails open on `validateBody` without `requireJsonBody` (MEDIUM)
323
+
324
+ **Files:**
325
+ - Modify: `rust/ingress/native_route.rs:224-254`
326
+ - Modify: `src/ingress/native-route.ts` and/or `src/ingress/routes/native.ts`
327
+ - Test: `rust/ingress/native_route.rs` tests + `test/unit/ingress/native-route.test.ts`
328
+
329
+ **Why:** an absent/oversized body with `validate_body && !require_json_body`
330
+ leaves `error_code == 0`, so the JS handler (which only checks `!== 0`) reports
331
+ success and the schema is never applied.
332
+
333
+ - [ ] **Step 1: Add a failing Rust test** where `validate_body=true`,
334
+ `require_json_body=false`, empty frame body → expect `ERR_BODY_NOT_JSON`.
335
+ - [ ] **Step 2: Fail closed in `run()`**
336
+
337
+ ```rust
338
+ if self.require_json_body || self.validate_body {
339
+ if !has_body || body.len() > self.max_body_bytes {
340
+ body_valid_json = false;
341
+ if self.require_json_body || self.validate_body {
342
+ error_code = ERR_BODY_NOT_JSON;
343
+ }
344
+ } else { /* unchanged */ }
345
+ }
346
+ ```
347
+ - [ ] **Step 3: Make the TS handler also check `ROUTE_FLAG.OK`** rather than only
348
+ `errorCode !== 0` (defense in depth), or document that `errorCode` is now the
349
+ single source.
350
+ - [ ] **Step 4:** `cargo test` + `bun test test/unit/ingress` + commit.
351
+
352
+ ```bash
353
+ git commit -am "fix(native-route): fail closed when validateBody has no body"
354
+ ```
355
+
356
+ ---
357
+
358
+ ## Phase 2 — P1: finish the WIP correctly + ingress security
359
+
360
+ ### Task 7: Unbounded per-origin header cache under wildcard CORS (MEDIUM)
361
+
362
+ **Files:**
363
+ - Modify: `src/ingress/handlers.ts:330-339` (comment is wrong) and
364
+ `src/ingress/response/baked-response.ts:100-119`
365
+ - Test: `test/unit/ingress/baked-response.test.ts`
366
+
367
+ **Why:** `originHeaderCache` is an unbounded `Map` keyed by attacker-supplied
368
+ `Origin`; wildcard CORS allows every origin → remote memory-exhaustion DoS.
369
+
370
+ - [ ] **Step 1: Add a test** asserting the cache does not grow past a fixed cap
371
+ when many distinct origins pass through.
372
+ - [ ] **Step 2:** Bound the map (e.g. `ORIGIN_HEADER_CACHE_MAX = 64`, evict
373
+ oldest / clear when full) or skip caching unless CORS is an exact allowlist.
374
+ Fix the comment at `handlers.ts:330-338`.
375
+ - [ ] **Step 3:** `bun test test/unit/ingress` + commit.
376
+
377
+ ```bash
378
+ git commit -am "fix(ingress): bound the per-origin header cache (wildcard CORS DoS)"
379
+ ```
380
+
381
+ ---
382
+
383
+ ### Task 8: WIP prewarm warms the wrong variant (MEDIUM)
384
+
385
+ **Files:**
386
+ - Modify: `src/ingress/handlers.ts:395-421`
387
+ - Test: `test/unit/ingress/baked-response.test.ts` (add a prewarm-hit assertion)
388
+
389
+ **Why:** the loop calls `responseHeaders(HV_CORS_SIMPLE, …)` (variant `&31 = 24`)
390
+ while the native success variant is `HV_JSON | HV_CORS_SIMPLE` (`&31 = 31`); the
391
+ cached array is unreachable. The comment also claims it fills the memoized
392
+ `Headers` WeakMap, but `memoizedHeaders` is never called.
393
+
394
+ - [ ] **Step 1:** Prewarm with the real success variant (and the terminal variant
395
+ if applicable). Call `memoizedHeaders(...)` in the prewarm, or delete the
396
+ claim. Verify against `rust/ingress/output.rs` when the success variant is set.
397
+ - [ ] **Step 2:** Add a regression test that a prewarmed `(variant, origin)` is a
398
+ cache hit for the success path.
399
+ - [ ] **Step 3:** `bun run bench:http:smoke` (wire-format guard) + commit.
400
+
401
+ ```bash
402
+ git commit -am "fix(ingress): prewarm the actual success header variant"
403
+ ```
404
+
405
+ ---
406
+
407
+ ### Task 9: WIP `prefill` multiplies memory across router routes (HIGH-impact)
408
+
409
+ **Files:**
410
+ - Modify: `src/ingress/handlers.ts:110-120,296-307`
411
+ - Modify: `src/ingress/router.ts:199` (one handler per route)
412
+ - Test: `test/unit/shared/buffer-pool.test.ts`, `test/unit/ingress/router.test.ts`
413
+
414
+ **Why:** `prefill: 16` eagerly allocates `outputBufferSize × 16` per handler;
415
+ `createIngressRouter` creates one handler per route and `outputBufferSize` may be
416
+ up to 64 MiB → up to 1 GiB per route.
417
+
418
+ - [ ] **Step 1:** Bound prefill by total bytes, not just count:
419
+ `prefill = min(PREWARM_POOL_BUFFERS, floor(MAX_PREWARM_BYTES / initialSize))`
420
+ with `MAX_PREWARM_BYTES` small (e.g. 4 MiB). Document the trade in
421
+ `docs/INGRESS.md`.
422
+ - [ ] **Step 2:** Unit-test `BufferPool` `prefill`: free count, `createdCount`,
423
+ and that `prefill` never exceeds `maxBuffers - 1`.
424
+ - [ ] **Step 3:** `bun run bench:http:smoke` + commit.
425
+
426
+ ```bash
427
+ git commit -am "fix(ingress): bound pool prefill by bytes (router memory)"
428
+ ```
429
+
430
+ ---
431
+
432
+ ### Task 10: Bench WIP correctness (instance UAF, timing skew)
433
+
434
+ **Files:**
435
+ - Modify: `bench/cost/ingress-cost.ts`, `ingress-cost-post.ts`, `ingress-js-breakdown.ts`, `ingress-refresh-breakdown.ts`, `ingress-native-decompose.ts`, `router-minimal-breakdown.ts`
436
+ - Modify: `src/ingress/packing/gather-raw-headers.ts:173-183` (filter `*`)
437
+
438
+ - [ ] **Step 1:** Retain every discarded `new NativeIngress(OPTIONS)` instance in
439
+ a module-level `const` so its `innerPtr()` target stays alive.
440
+ - [ ] **Step 2:** In `ingress-cost.ts`, pre-generate the request id outside the
441
+ `nativeCmp` timed closure; merge the two `../../src/ingress/shared` imports.
442
+ - [ ] **Step 3:** Make `prewarmOriginBlocks` skip `*` (match the handler filter).
443
+ - [ ] **Step 4:** `bun run bench:ingress-cost` must run clean; commit.
444
+
445
+ ```bash
446
+ git commit -am "fix(bench): retain native ingress instances; de-skew nativeCmp timing"
447
+ ```
448
+
449
+ ---
450
+
451
+ ## Phase 3 — P2: crypto hardening
452
+
453
+ ### Task 11: JWT time-claim robustness + require-`exp`
454
+
455
+ **Files:** `rust/crypto/jwt/token.rs:238-265`, `rust/crypto/jwt/api.rs`, tests.
456
+
457
+ - [ ] Reject (not skip) non-integer / out-of-range `exp`, `nbf`, `iat` — a
458
+ float `exp` currently verifies forever. Add a Rust test with `"exp": 1.75e9`.
459
+ - [ ] Add an option to require `exp` (default documented); add a TS test.
460
+ - [ ] Replace the hand-rolled `ct_eq` (`token.rs:116-127`) with
461
+ `aws_lc_rs::hmac::verify` or `subtle::ConstantTimeEq`.
462
+ - [ ] `cargo test && bun test` + commit.
463
+
464
+ ### Task 12: AEAD batch nonce length validation
465
+
466
+ **Files:** `rust/crypto/aead.rs:29-37,192-230`, `src/rust-ffi/batch/types.ts`.
467
+
468
+ - [ ] Validate `nonce.len() == 12` in `batch_nonce`/entry points and return a
469
+ JS error instead of `copy_from_slice` panicking (`rust.aead.encryptBatch`).
470
+ - [ ] Document the "fresh base nonce per batch call" contract on the public type.
471
+ - [ ] Add a Rust test with a 16-byte nonce → error, not abort. Commit.
472
+
473
+ ### Task 13: Crypto config validation
474
+
475
+ **Files:** `rust/crypto/{hmac_sha256,cookie_sign,csrf,jwt/api,session}.rs`,
476
+ `rust/crypto/bcrypt.rs:17-27`, `rust/crypto/pbkdf2.rs:22-40`,
477
+ `rust/crypto/argon2.rs:33-43`.
478
+
479
+ - [ ] Reject empty HMAC/session/JWT keys (session already does; extend to the rest).
480
+ - [ ] bcrypt `cost == 0` → error (not clamp to 4); pbkdf2 `rounds == 0` → error.
481
+ - [ ] Cap argon2 `m_cost`/`t_cost`/`out_len` at sane maxima.
482
+ - [ ] `cargo test` + commit (split into per-area commits if easier).
483
+
484
+ ---
485
+
486
+ ## Phase 4 — P2: ingress security defaults / parser hardening
487
+
488
+ ### Task 14: Echo route reflected Content-Type XSS
489
+
490
+ **Files:** `src/ingress/routes/echo.ts:53-88`.
491
+
492
+ - [ ] Do not reflect `Content-Type` verbatim; default to `application/octet-stream`
493
+ unless it is a safe allowlist, or always force `X-Content-Type-Options: nosniff`.
494
+ - [ ] Test: `POST /echo` with `Content-Type: text/html` must not return `text/html`.
495
+ - [ ] Commit.
496
+
497
+ ### Task 15: Rate limiting with no `getIp` shares one global bucket
498
+
499
+ **Files:** `src/ingress/routes/common.ts:42-49`, `src/ingress/server.ts`,
500
+ `src/ingress/options.ts`.
501
+
502
+ - [ ] When rate limiting is enabled and no `getIp` is configured, log a one-time
503
+ warning and/or document the single-bucket behavior. Prefer a fail-loud default.
504
+ - [ ] Test that the warning fires (or that the chosen behavior is explicit).
505
+ - [ ] Commit.
506
+
507
+ ### Task 16: Duplicate `x-forwarded-for` handling
508
+
509
+ **Files:** `rust/http/headers.rs:117-126`, `rust/ingress/proxy.rs`.
510
+
511
+ - [ ] Combine duplicate XFF lines in arrival order instead of keeping only the
512
+ last; pin the behavior and re-run proxy tests. `[verify]` against proxy semantics.
513
+ - [ ] Commit.
514
+
515
+ ### Task 17: `cors: {}` resolves to wildcard allow-all
516
+
517
+ **Files:** `rust/ingress/cors.rs:54-80`, `src/ingress/headers/cors.ts`.
518
+
519
+ - [ ] Decide and document: an empty `cors` object should not silently allow all
520
+ origins. Prefer default-deny unless `allowOrigin` is set.
521
+ - [ ] Update tests + `docs/INGRESS.md`. Commit.
522
+ - [ ] **Do not change this without maintainer sign-off** — it is a behavioral default.
523
+
524
+ ### Task 18: Node WS handshake protocol injection
525
+
526
+ **Files:** `src/ingress/server-node.ts:276-279`.
527
+
528
+ - [ ] Validate the negotiated subprotocol token (`^[A-Za-z0-9._-]+$`) or build the
529
+ 101 response via `Headers` instead of string interpolation.
530
+ - [ ] Test with a CRLF-bearing protocol. Commit.
531
+
532
+ ---
533
+
534
+ ## Phase 5 — P2: maintainability
535
+
536
+ ### Task 19: FFI/general doc drift
537
+
538
+ **Files:** `docs/CASE_STUDY.md:61`, `docs/FFI_BUN_GUIDE.md:495`,
539
+ `docs/REPO_MAP.md:40,44,134,297`, `docs/ENVIRONMENT.md:13`,
540
+ `.agents/skills/castrum-rust-ffi/SKILL.md:109`,
541
+ `.agents/skills/castrum-codebase-map/SKILL.md:34`,
542
+ `.agents/skills/castrum-ingress-pipeline/SKILL.md:26,51-54,74`,
543
+ `scripts/check-clean.ts:19-25`.
544
+
545
+ - [ ] Make the counts match `check:clean` (121 total / 109 direct) and fix the
546
+ `nm -D | grep -c castrum_` example. Update the test counts and the zero-copy text.
547
+ - [ ] Extend `check:clean` to scan `.agents/skills/**` for the FFI count and to
548
+ catch "C-ABI symbols" as well as "castrum".
549
+ - [ ] Run `bun run check:clean`. Commit.
550
+
551
+ ### Task 20: Dead exports, missing JSDoc, accidental duplication
552
+
553
+ **Files:** `src/ingress/packing/gather-raw-headers.ts:17-21`,
554
+ `src/ingress/server.ts:346`, `src/loader/ops.ts:29`, `src/ingress/shared.ts:39`,
555
+ `src/native/ffi/build/parse.ts:37`, `src/ingress/server-node.ts:76-77/159-163`,
556
+ `src/shared/packed/parsers.ts:56-80`.
557
+
558
+ - [ ] Remove/narrow confirmed-unused exports (grep first).
559
+ - [ ] Add the missing JSDoc on `buildParse`.
560
+ - [ ] De-duplicate `hasBody`, the retry-after helper, and the four lazy-addon
561
+ parser wrappers.
562
+ - [ ] `bun run check:clean && bun run check:jsdoc && bun test test/unit` + commit.
563
+
564
+ ### Task 21: Extract the duplicated FFI-fallback block in `handlers.ts`
565
+
566
+ **Files:** `src/ingress/handlers.ts:506-540`.
567
+
568
+ - [ ] Extract one `handleFfiOrNapi(...)` helper used by both branches; keep the
569
+ failure counter + `ffiDisabled` behavior identical. `bun run bench:http:smoke` + commit.
570
+
571
+ ### Task 22: Document the class exceptions to RULES.md §4
572
+
573
+ **Files:** `RULES.md`, `src/ingress/routes/responder.ts:48,66,115` (typo).
574
+
575
+ - [ ] State that `AdaptiveEstimate`, `BakedIngressResult`, `FastIngressResult` are
576
+ sanctioned result/utility classes; fix `IGNGEX_SECURITY_HEADERS` → `IGNEX_SECURITY_HEADERS`.
577
+ - [ ] Commit.
578
+
579
+ ---
580
+
581
+ ## Phase 6 — Final verification
582
+
583
+ ### Task 23: Full gate + benchmark before/after
584
+
585
+ - [ ] `cargo test`
586
+ - [ ] `bun run build:all`
587
+ - [ ] `bun run typecheck && bun run typecheck:test`
588
+ - [ ] `bun run lint`
589
+ - [ ] `bun test test/unit test/property test/compat`
590
+ - [ ] `bun run test:node`
591
+ - [ ] `bun run check:clean && bun run check:jsdoc && bun run check:version`
592
+ - [ ] `bun run bench:http:smoke`
593
+ - [ ] `bun run bench:startup` (loader untouched, but cheap)
594
+ - [ ] Compare `bun run bench:ingress-cost` / `bench:ffi` before vs after; record
595
+ `bun --version` at start and end.
596
+ - [ ] Update `CHANGELOG.md`; commit.
597
+
598
+ ---
599
+
600
+ ## Self-Review
601
+
602
+ **Coverage vs the audit:** FFI lifecycle (1–3), unguarded exports (4), NUL
603
+ truncation (5), native-route fail-open (6), WIP correctness (7–10), crypto
604
+ (11–13), ingress security (14–18), docs/maintainability (19–22), verification
605
+ (23). Findings not yet scheduled and deferred deliberately: argon2 batch-salt
606
+ helper (API design), AEAD AAD support (API design), request-id predictability
607
+ (documented, not a secret), `isValidResponseStatus(101)` (defense-in-depth),
608
+ existing `MAX_OUTPUT_BUFFER_SIZE` export (covered by 20), oversized Rust test
609
+ modules (cosmetic). None are P0/P1.
610
+
611
+ **Open decisions requiring maintainer input before implementation:** Task 17
612
+ (`cors: {}` default), Task 15 (fail-loud on missing `getIp`), Task 11 (require
613
+ `exp` default). Do not silently change public defaults in those tasks.
614
+
615
+ **Placeholder scan:** P2/Phase 3–5 tasks specify files and exact behavior but not
616
+ full code blocks; each still names the symbol, the current bug, and the
617
+ verification command. Expand to full code before executing those tasks.
package/index.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  // test/unit/features/import-contract.test.ts).
13
13
 
14
14
  export * from './src/ingress'
15
+ export { getIngressBinding, type IngressBinding } from './src/native/ingress-binding'
15
16
  // Framework-agnostic integration helpers: run the ingress pipeline as a
16
17
  // request stage (createPipeline), RFC 6455 upgrade (createWebSocketUpgrade),
17
18
  // and SSE framing (sseResponse). See docs/INGRESS.md.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castrum",
3
- "version": "0.9.6",
3
+ "version": "0.9.8",
4
4
  "private": false,
5
5
  "description": "Enterprise-grade Bun + Rust runtime: Rust-accelerated FFI primitives, a production-grade HTTP ingress pipeline, validation, hashing, and JSON processing with benchmark-grade performance.",
6
6
  "type": "module",
@@ -66,7 +66,7 @@
66
66
  "build:all": "bun run build && bun run build:v3",
67
67
  "build:js": "bun run build:js:bundle && bun run build:js:types",
68
68
  "build:js:bundle": "bun build ./index.ts --target=node --format=esm --outdir=dist",
69
- "build:js:types": "dts-bundle-generator -o dist/index.d.ts index.ts",
69
+ "build:js:types": "node scripts/ensure-dts-typescript.mjs && dts-bundle-generator -o dist/index.d.ts index.ts",
70
70
  "bench": "bun run build && bun bench.ts",
71
71
  "check": "bun bench.ts",
72
72
  "test": "bun test test/unit test/property test/compat",
@@ -146,6 +146,7 @@
146
146
  "fast-json-patch": "^3.1.1",
147
147
  "mime-types": "^3.0.2",
148
148
  "typescript": "7.0.2",
149
+ "typescript-dts": "npm:typescript@5.9.3",
149
150
  "validator": "^13.15.35"
150
151
  },
151
152
  "overrides": {