castrum 0.9.7 → 0.9.9

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 (108) hide show
  1. package/Cargo.lock +1 -1
  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 +664 -8
  12. package/dist/index.js +2014 -1327
  13. package/docs/BENCHMARKS.md +13 -0
  14. package/docs/CASE_STUDY.md +1 -1
  15. package/docs/ENVIRONMENT.md +8 -1
  16. package/docs/FFI_BUN_GUIDE.md +1 -1
  17. package/docs/INGRESS.md +4 -4
  18. package/docs/REPO_MAP.md +7 -5
  19. package/docs/RND-CONCURRENCY.md +20 -4
  20. package/docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md +617 -0
  21. package/docs/superpowers/plans/2026-09-18-elysia-learnings-adoption.md +634 -0
  22. package/index.ts +6 -0
  23. package/package.json +4 -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 +6 -0
  29. package/rust/ffi/crypto.rs +63 -32
  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 +55 -35
  35. package/rust/ffi/tests.rs +24 -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/api.rs +21 -5
  40. package/rust/ingress/mod.rs +7 -0
  41. package/rust/ingress/native_route.rs +25 -5
  42. package/rust/ingress/schema_cache.rs +266 -0
  43. package/rust/task/completion.rs +7 -0
  44. package/rust/task/mod.rs +2 -1
  45. package/rust/task/runtime.rs +55 -7
  46. package/rust/task/tests.rs +128 -1
  47. package/src/ingress/abort.ts +48 -0
  48. package/src/ingress/body.ts +104 -8
  49. package/src/ingress/decode/baked-result.ts +13 -5
  50. package/src/ingress/decode/fast-result.ts +1 -1
  51. package/src/ingress/decode/result-base.ts +16 -2
  52. package/src/ingress/handlers.ts +104 -31
  53. package/src/ingress/headers/memoized-headers.ts +40 -0
  54. package/src/ingress/health.ts +22 -0
  55. package/src/ingress/index.ts +3 -1
  56. package/src/ingress/native-route.ts +25 -6
  57. package/src/ingress/options.ts +26 -2
  58. package/src/ingress/packing/gather-raw-headers.ts +24 -8
  59. package/src/ingress/response/baked-response.ts +118 -28
  60. package/src/ingress/router.ts +50 -3
  61. package/src/ingress/routes/common.ts +35 -11
  62. package/src/ingress/routes/echo.ts +60 -7
  63. package/src/ingress/routes/json-write.ts +32 -8
  64. package/src/ingress/routes/native.ts +24 -5
  65. package/src/ingress/routes/options.ts +6 -2
  66. package/src/ingress/routes/responder.ts +29 -9
  67. package/src/ingress/routes/safe-record.ts +57 -0
  68. package/src/ingress/server-error.ts +111 -0
  69. package/src/ingress/server-node.ts +135 -37
  70. package/src/ingress/server.ts +143 -36
  71. package/src/ingress/shared.ts +1 -1
  72. package/src/ingress/types.ts +15 -0
  73. package/src/integration/pipeline.ts +6 -0
  74. package/src/loader/ops.ts +1 -1
  75. package/src/native/ffi/build/codecs-selftest.ts +305 -0
  76. package/src/native/ffi/build/codecs.ts +0 -296
  77. package/src/native/ffi/build/instances-selftest.ts +264 -0
  78. package/src/native/ffi/build/instances.ts +10 -259
  79. package/src/native/ffi/build/parse-selftest.ts +238 -0
  80. package/src/native/ffi/build/parse.ts +11 -234
  81. package/src/native/ffi/build/task.ts +2 -1
  82. package/src/native/ffi/build.ts +5 -1
  83. package/src/native/ffi/selftest.ts +3 -3
  84. package/src/native/ffi/symbols.ts +460 -0
  85. package/src/native/ffi/types.ts +6 -1
  86. package/src/native/ffi.ts +2 -434
  87. package/src/native/ingress-binding.ts +39 -0
  88. package/src/native/types.ts +6 -0
  89. package/src/runtime/codec.ts +24 -0
  90. package/src/runtime/detect.ts +14 -0
  91. package/src/runtime/types.ts +11 -0
  92. package/src/rust-ffi/batch/build.ts +18 -8
  93. package/src/rust-ffi/batch/types.ts +15 -0
  94. package/src/rust-ffi/context.ts +46 -3
  95. package/src/rust-ffi/options.ts +19 -0
  96. package/src/rust-ffi/scalar/crypto.ts +8 -0
  97. package/src/rust-ffi/scalar/factories.ts +53 -20
  98. package/src/rust-ffi/scalar/http.ts +60 -10
  99. package/src/rust-ffi/scalar/interface.ts +18 -1
  100. package/src/rust-ffi/scalar/payload.ts +27 -1
  101. package/src/rust-ffi/text.ts +5 -4
  102. package/src/shared/buffer-pool.ts +54 -9
  103. package/src/shared/codec.ts +26 -0
  104. package/src/shared/memory.ts +68 -0
  105. package/src/shared/metrics.ts +86 -3
  106. package/src/shared/packed/parsers.ts +64 -7
  107. package/src/shared/runtime.ts +7 -0
  108. package/src/task/runtime.ts +81 -37
@@ -0,0 +1,634 @@
1
+ # Elysia → castrum adoption plan (performance, security, stability)
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:** Adopt the transferable ideas from Elysia v2 (`~/poc/elysia`) that
9
+ close real gaps in castrum — a native-first Rust/Bun HTTP engine — without
10
+ touching the two ingress wire formats or the hot-path FFI surface.
11
+
12
+ **Architecture:** castrum is already *ahead* of Elysia on the data plane
13
+ (native packed parsing, pooled/zero-copy output, Bun native `routes`). Elysia's
14
+ value is in three places castrum under-invests: (1) **fault containment and
15
+ information-leak control** around escaped user code, (2) **bounded, flushable
16
+ process-wide caches** and cancellation, and (3) **precomputed static responses**
17
+ that never enter JS. This plan ports those three, plus the testing methodology
18
+ that proves them.
19
+
20
+ **Tech Stack:** TypeScript (Bun-first, Node fallback), Rust cdylib via napi-rs +
21
+ `bun:ffi`, Biome, `bun test`, `cargo test`, Bun.serve.
22
+
23
+ **Spec:** this document. Design source: read-only audit of `~/poc/elysia`
24
+ (v2.0.0-beta.16) and `~/poc/castrum` (v0.9.8 working tree). Every claim about
25
+ either repo is cited `file:line`. Items I could not verify from source are
26
+ marked `[verify]`.
27
+
28
+ ---
29
+
30
+ ## Global Constraints
31
+
32
+ Copied from `AGENTS.md` / `RULES.md`; every task includes them implicitly.
33
+
34
+ - **Do NOT change the ingress wire formats** (`docs/adr/0001-two-wire-formats.md`):
35
+ success/error body shapes and `ratelimit-*` / `x-ratelimit-*` header names are
36
+ frozen. New error responses must reuse an existing shape.
37
+ - **Do NOT change or remove hot-path APIs** (AGENTS.md "Do NOT"); no signature
38
+ churn on `handleRequestPacked`, `handleRequestFullSync*`, scalar `rust.*`.
39
+ - **One numeric layout source**: `rust/ingress/output.rs` → `src/ingress/constants.ts`.
40
+ - **Runtime seam**: `typeof Bun` lives only in `src/runtime/detect.ts`; env reads
41
+ go through `src/shared/env.ts` (`resolveEnvVar`).
42
+ - **Bun-first**: never slow the Bun path for Node; Node falls back, never diverges
43
+ in observable HTTP behavior.
44
+ - **Purity boundary**: pure modules must not import `src/native/` or
45
+ `src/shared/buffer-pool.ts` (AGENTS.md "Purity & side-effect policy").
46
+ - **Every new `castrum_*` export** routes fallible cores through `panic_guard`,
47
+ is self-tested at bind time, is added to the dlopen map + `ffi/types.ts` + the
48
+ parity test, and uses `u64_fast` for byte counts
49
+ (`docs/FFI_BUN_GUIDE.md`, `docs/HOW_TO_ADD_AN_OP.md`).
50
+ - **New public exports** need JSDoc (`bun run check:jsdoc`), and `rust.*` ops need
51
+ a `PROVEN_SELECTION` + `selection.json` entry (`test/unit/contract/proven.test.ts`).
52
+ - **Green before start**: `bun run typecheck && bun run lint && bun run check:clean`
53
+ and `cargo test`. Record `bun --version` and the test counts.
54
+ - Baseline note: an existing defect-fix plan lives at
55
+ `docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md`. That plan is
56
+ **not** duplicated here; where an item overlaps it is referenced, not repeated.
57
+
58
+ ---
59
+
60
+ ## Context — what Elysia does that makes it powerful
61
+
62
+ Elysia is a JS-first framework whose speed comes from moving work **off the
63
+ request path** and its safety from **bounded state + conservative fallback**.
64
+ The table maps each mechanism to castrum's current state.
65
+
66
+ | Elysia mechanism (file:line) | What it buys | castrum today | Verdict |
67
+ |---|---|---|---|
68
+ | AOT build plugin emits per-route JS, stubs out JIT/TypeBox/sucrose (`src/plugin/aot/core.ts:473-627`, `src/compile/aot.ts`) | No runtime codegen; small bundle; fast cold start | Routes compile once at startup (`src/ingress/server.ts:237-339`); native engine, no runtime codegen | **Already have** (different form) |
69
+ | Static routes promoted to `Bun.serve` native table so Bun serves them without JS (`src/adapter/bun/index.ts:184-306`, `compile/handler/index.ts:354-428`) | Removes JS from the request path for static responses | Every route runs the ingress pipeline via JS handler (`src/ingress/routes/read.ts:19-34`) | **Adopt** (C1) |
70
+ | Response ownership / `skipClone` to avoid `Response.clone()` (`src/adapter/skip-clone.ts`, `response-ownership.ts:1-10`) | Avoids double-buffering and tee bookkeeping | castrum never clones; pools output buffers (`src/shared/response.ts`, `handlers.ts:501`) | **Already have** |
71
+ | `parse-query.ts` single-pass scanner with schema-derived channels (`src/parse-query.ts:19-183`) | Allocation-light URL parsing | Rust zero-alloc packed parsers (`rust/http/query_parser.rs`) | **Already have (stronger)** |
72
+ | Validator cache dedupes identical schemas, 1-slot fast path, GC timer (`src/type/validator/validator-cache.ts:263-489`) | N routes with 1 schema compile once | Compiles per instance/route, no cross-route cache (`rust/ingress/api.rs:81-95`, `router.ts:177-200`) | **Adopt** (C2) |
73
+ | Immutable default-headers sentinel; skip `Headers` materialization (`src/adapter/default-headers.ts:1`, `adapter/utils.ts:15-20`) | Fewer allocations per response | Memoized `Headers` + origin cache, no clone when no extras (`baked-response.ts:148-176`, `routes/common.ts:62-89`) | **Already have** |
74
+ | Bounded caches + generational eviction + `flushMemory()` + `Bun.gc()` (`src/memory.ts:7-14`, `src/utils.ts:42-53`) | Stops slow heap growth; explicit maintenance | Strong pool bounds, but **no global flush API**, unbounded TS metrics/MIME/task queue | **Adopt** (B2/B3) |
75
+ | Production error masking, owned-vs-foreign errors, double-fault containment (`src/error.ts:981-1006`, `handler/error.ts:144-153`, `handler/fetch.ts:84-117`) | 5xx never leaks internals; error hook can't crash server | Bun path has **no server-level trap**; escaped errors hit Bun default (`server.ts:347-401`); `onError` is Node-only (`server.ts:115-120`) | **Adopt** (A1) |
76
+ | Prototype-pollution defenses: `dangerousKeys`, null-prototype objects, `assignOwn` (`src/utils.ts:498-504`, `adapter/web-standard/utils.ts:133`, `parse-query.ts:25`) | Attacker-controlled keys stay inert | `nativeResponderRoute` does `JSON.parse` into plain objects (`routes/responder.ts:71-81`); audit needed | **Adopt** (A2) |
77
+ | Request `AbortSignal` checked between lifecycle stages (`handler/fetch.ts:263-287`) | Cancels work when client disconnects | No `Request.signal` use on the HTTP path | **Adopt** (B1) |
78
+ | WebSocket in-flight cap 256 / backpressure / per-message error isolation / drain (`src/ws/route.ts:1126,1213-1220`, `:212-285`) | Bounds memory, isolates errors, clean shutdown | No WS server runtime; handshake helper only (`src/integration/websocket.ts:49-93`) | **Adopt separately** (D1) |
79
+ | Differential/metamorphic lane harness byte-compares JIT/precompile/socket/AOT (`test/differential/lanes.ts:217-239`) | Catches lane divergence | proptest + pool-parity, no cross-transport lane harness | **Adopt** (B4) |
80
+ | D1 proof-baseline: noise floors + injected-regression self-test (`bench/d1/run.ts:1395-1569`, `README.md`) | Performance claims are statistically gated | ad-hoc benches (`bench/http/run-bench.ts`) | **Adopt later** (D3) |
81
+ | Cookie signing with rotation/name-binding/constant-time (`src/cookie/crypto.ts:145-227`) | Safe signed cookies | Primitives exist, not wired into ingress (`rust/crypto/cookie_sign.rs`) | **Adopt separately** (D2) |
82
+
83
+ **Explicitly NOT ported** (and why): AOT `new Function` codegen (castrum is
84
+ native — no runtime JS codegen to eliminate); Memoirist trie matching (Bun's
85
+ native router already matches; Node keeps `path-matcher.ts`); response clone
86
+ protocol (castrum never clones); `parse-query` JS scanner (native parser is
87
+ faster); TypeBox lazy-JIT threshold (native schema compiles once, eagerly — the
88
+ right choice for latency); sucrose source analysis (no JS handler analysis
89
+ needed).
90
+
91
+ ---
92
+
93
+ ## Phase A — Security / fault containment
94
+
95
+ ### Task 1 (A1): Server-level error trap for escaped handlers (Bun + Node parity)
96
+
97
+ **Why:** `nativeResponderRoute` (`src/ingress/routes/responder.ts:157`) and
98
+ `nativeRouteHandler` (`src/ingress/routes/native.ts:129`) call the user
99
+ `responder(snapshot)` with **no try/catch**, and a raw `spec.read` function is
100
+ wired straight into Bun (`src/ingress/server.ts:261`) and Node
101
+ (`server-node.ts:207-211`). A sync throw or async rejection therefore escapes:
102
+ on Node it hits the adapter catch-all (`server-node.ts:220-243`, generic 500);
103
+ on Bun it hits **Bun's default handler**, which is not castrum-controlled and
104
+ may surface stack/internal detail — exactly the class of leak Elysia prevents
105
+ with `src/handler/error.ts:144-153`.
106
+
107
+ **Files:**
108
+ - Create: `src/ingress/server-error.ts`
109
+ - Modify: `src/ingress/server.ts` (`BuildRouteHandlersOptions` + the three escape
110
+ sites at `:257-261`, `:290-301`, `:303-319`; forward `onError`)
111
+ - Modify: `src/ingress/server-node.ts` (reuse the shared error handler)
112
+ - Test: `test/unit/ingress/server-error.test.ts`
113
+
114
+ **Interfaces:**
115
+ - Produces:
116
+ - `createServerErrorHandler(opts: ServerErrorOptions): (error: unknown, req?: Request) => Response`
117
+ - `ServerErrorOptions = { onError?: (info: { error: Error; request?: Request }) => void; logger?: (line: string) => void }`
118
+ - `guardRouteHandler(handler: RouteHandler, onServerError: (e: unknown, req?: Request) => Response): RouteHandler`
119
+ - `internalErrorBody` = the existing `ERROR_BODIES.internal` bytes (reuse; do
120
+ not define a new wire shape).
121
+ - Consumes: `ERROR_BODIES.internal` (`src/ingress/response/error-bodies.ts:46`),
122
+ `memoizedHeaders` (`src/ingress/headers/memoized-headers.ts`).
123
+
124
+ - [ ] **Step 1: Write the failing tests**
125
+
126
+ ```ts
127
+ // test/unit/ingress/server-error.test.ts
128
+ import { expect, test } from 'bun:test'
129
+ import { createServerErrorHandler, guardRouteHandler } from '../../../src/ingress/server-error'
130
+
131
+ test('sync throw becomes a generic 500 with no thrown text', async () => {
132
+ const handler = guardRouteHandler(
133
+ () => { throw new Error('secret-db-password') },
134
+ createServerErrorHandler({}),
135
+ )
136
+ const res = await handler(new Request('http://x/'))
137
+ expect(res.status).toBe(500)
138
+ const body = await res.text()
139
+ expect(body).toBe('{"ok":false,"error":{"code":"internal_error","message":"Internal server error"}}')
140
+ expect(body).not.toContain('secret-db-password')
141
+ })
142
+
143
+ test('async rejection becomes a generic 500', async () => {
144
+ const handler = guardRouteHandler(
145
+ async () => { throw new Error('secret-2') },
146
+ createServerErrorHandler({}),
147
+ )
148
+ const res = await handler(new Request('http://x/'))
149
+ expect(res.status).toBe(500)
150
+ expect(await res.text()).not.toContain('secret-2')
151
+ })
152
+
153
+ test('onError sees the real error, runs once, and cannot break the response', async () => {
154
+ let calls = 0
155
+ const onServerError = createServerErrorHandler({
156
+ onError: (info) => { calls++; expect(info.error.message).toBe('boom'); throw new Error('hook-broke') },
157
+ })
158
+ const handler = guardRouteHandler(() => { throw new Error('boom') }, onServerError)
159
+ const res = await handler(new Request('http://x/'))
160
+ expect(res.status).toBe(500)
161
+ expect(calls).toBe(1)
162
+ })
163
+
164
+ test('success path is unaffected (no wrapping overhead semantics change)', async () => {
165
+ const handler = guardRouteHandler(() => new Response('ok'), createServerErrorHandler({}))
166
+ const res = await handler(new Request('http://x/'))
167
+ expect(res.status).toBe(200)
168
+ expect(await res.text()).toBe('ok')
169
+ })
170
+ ```
171
+
172
+ - [ ] **Step 2: Run to confirm failure**
173
+
174
+ Run: `bun test test/unit/ingress/server-error.test.ts`
175
+ Expected: FAIL — module `src/ingress/server-error.ts` does not exist.
176
+
177
+ - [ ] **Step 3: Implement `src/ingress/server-error.ts`**
178
+
179
+ Reuse the pre-encoded internal body; **never** put `error.message`/stack in the
180
+ response (wire format + info-leak rule). The guard must handle both sync and
181
+ async returns:
182
+
183
+ ```ts
184
+ // src/ingress/server-error.ts — request-handler fault containment.
185
+ import { ERROR_BODIES } from './response/error-bodies'
186
+ import { memoizedHeaders } from './headers/memoized-headers'
187
+ import type { RouteHandler } from './server'
188
+
189
+ export interface ServerErrorOptions {
190
+ onError?: (info: { error: Error; request?: Request }) => void
191
+ logger?: (line: string) => void
192
+ }
193
+
194
+ function toError(value: unknown): Error {
195
+ return value instanceof Error ? value : new Error(String(value))
196
+ }
197
+
198
+ /** Build a masked-500 responder. Never throws; the onError hook is contained. */
199
+ export function createServerErrorHandler(
200
+ opts: ServerErrorOptions,
201
+ ): (error: unknown, req?: Request) => Response {
202
+ const headers = memoizedHeaders([['content-type', 'application/json; charset=utf-8']])
203
+ return (error, req) => {
204
+ const err = toError(error)
205
+ if (opts.onError) {
206
+ try { opts.onError({ error: err, request: req }) } catch { /* swallowed by design */ }
207
+ }
208
+ if (opts.logger) {
209
+ try { opts.logger(JSON.stringify({ level: 'error', msg: 'unhandled_handler_error', error: err.message })) } catch {}
210
+ }
211
+ return new Response(ERROR_BODIES.internal, { status: 500, headers })
212
+ }
213
+ }
214
+
215
+ /** Wrap a route handler so sync throws and async rejections are contained. */
216
+ export function guardRouteHandler(
217
+ handler: RouteHandler,
218
+ onServerError: (error: unknown, req?: Request) => Response,
219
+ ): RouteHandler {
220
+ return (req, srv, params) => {
221
+ try {
222
+ const out = handler(req, srv, params)
223
+ return out instanceof Promise ? out.catch((e) => onServerError(e, req)) : out
224
+ } catch (e) {
225
+ return onServerError(e, req)
226
+ }
227
+ }
228
+ }
229
+ ```
230
+
231
+ - [ ] **Step 4: Wire it into `server.ts`**
232
+
233
+ Add `onError` and `logger` to `BuildRouteHandlersOptions` (`server.ts:226-229`),
234
+ build `const onServerError = createServerErrorHandler({ onError, logger })`, and
235
+ apply `guardRouteHandler` **only to the escape-capable handlers**: raw
236
+ `spec.read` (line 261), the `responder` route (line 292-300) and the `native`
237
+ route (line 307-318). Built-in factories (`readHandler`, `jsonWriteHandler`,
238
+ `echoHandler`, `deleteHandler`, `optionsHandler`, `fallbackHandler`) already
239
+ contain their own error handling — do **not** wrap them (keeps the hot path
240
+ unchanged). Then pass `onError`/`logger` from `createIngressServer` into
241
+ `buildRouteHandlers` (currently omitted, `server.ts:369`).
242
+
243
+ - [ ] **Step 5: Route Node through the same handler**
244
+
245
+ In `server-node.ts`, replace the inline catch-all body construction
246
+ (`:220-243`) with `createServerErrorHandler({ onError })` so both runtimes emit
247
+ the same generic 500 and invoke the same hook. (The adapter still must
248
+ `res.destroy()` when headers were already sent; keep that branch.)
249
+
250
+ - [ ] **Step 6: Verify + commit**
251
+
252
+ Run:
253
+ ```bash
254
+ bun test test/unit/ingress/server-error.test.ts
255
+ bun run typecheck && bun run lint && bun run check:jsdoc
256
+ bun run bench:http:smoke
257
+ git add src/ingress/server-error.ts src/ingress/server.ts src/ingress/server-node.ts test/unit/ingress/server-error.test.ts
258
+ git commit -m "feat(ingress): contain escaped handler errors with a masked 500 on Bun and Node"
259
+ ```
260
+
261
+ ---
262
+
263
+ ### Task 2 (A2): Prototype-pollution audit + guards on JS object materialization
264
+
265
+ **Why:** Elysia treats `__proto__`/`constructor`/`prototype` as inert data and
266
+ builds null-prototype containers (`src/parse-query.ts:25`,
267
+ `adapter/web-standard/utils.ts:133`, `utils.ts:498-504`). castrum's native
268
+ parsers return packed pairs, but JS turns them into plain objects in the
269
+ responder bridge (`routes/responder.ts:71-81` `JSON.parse` → plain object;
270
+ `:134-142` casts to `Record`). A query/cookie/body key of `__proto__` must not
271
+ mutate an object prototype or reach a responder that then merges it into config.
272
+
273
+ **Files:**
274
+ - Modify: `src/ingress/routes/responder.ts` (`parseSection`, snapshot assembly)
275
+ - Modify: `src/ingress/routes/native.ts` (`pairsToRecord`) and audit every
276
+ `Object.fromEntries`/`JSON.parse` on request-derived data
277
+ - Modify: any `pairsToRecord` helper it calls
278
+ - Test: `test/unit/ingress/prototype-pollution.test.ts`
279
+
280
+ **Interfaces:**
281
+ - Produces: `safeRecord(): Record<string, unknown>` (null-prototype) and
282
+ `assignOwn(target, key, value)` used by all request-derived object builders.
283
+ `__proto__`/`constructor`/`prototype` are assigned as **own** data (or skipped)
284
+ but never as accessors on the prototype chain.
285
+
286
+ - [ ] **Step 1: Write failing tests**
287
+
288
+ ```ts
289
+ // test/unit/ingress/prototype-pollution.test.ts
290
+ import { expect, test } from 'bun:test'
291
+ import { parseQueryPairsSafe } from '../../../src/ingress/routes/responder' // export for test
292
+
293
+ test('__proto__ query key is inert own data', () => {
294
+ const rec = parseQueryPairsSafe('__proto__=polluted&a=1')
295
+ expect(({} as any).polluted).toBeUndefined()
296
+ expect(rec.a).toBe('1')
297
+ })
298
+
299
+ test('constructor key does not overwrite the constructor', () => {
300
+ const rec = parseQueryPairsSafe('constructor=evil')
301
+ expect(typeof rec.constructor).not.toBe('string' === 'x' ? 'x' : 'function')
302
+ })
303
+ ```
304
+
305
+ - [ ] **Step 2: Run — FAIL (helper absent).** `bun test test/unit/ingress/prototype-pollution.test.ts`
306
+
307
+ - [ ] **Step 3: Implement + audit**
308
+
309
+ Use `Object.create(null)` for the record root and assign via
310
+ `Object.defineProperty(rec, key, { value, enumerable: true, configurable: true, writable: true })`
311
+ (or skip `dangerousKeys`), so a `__proto__` key becomes own data. Grep
312
+ `Object.fromEntries`, `JSON.parse`, and object literals fed by
313
+ `req.headers`/`parseSection`/`pairsToRecord` and route them through the helper.
314
+
315
+ - [ ] **Step 4: Verify + commit**
316
+
317
+ ```bash
318
+ bun test test/unit/ingress/prototype-pollution.test.ts
319
+ bun run typecheck && bun run lint
320
+ git add -A src/ingress/routes test/unit/ingress/prototype-pollution.test.ts
321
+ git commit -m "fix(ingress): make request-derived object keys prototype-safe"
322
+ ```
323
+
324
+ ---
325
+
326
+ ### Task 3 (A3): ReDoS / resource-limit regression test for schema `pattern`
327
+
328
+ **Why:** Elysia pins a linear-time `${t.Numeric}` check against a 200 KB attack
329
+ because a naive numeric regex can backtrack catastrophically
330
+ (`test/regression/security.test.ts:6-27`). castrum's `fast_schema` evaluates
331
+ **schema-author-supplied** `pattern`/`patternProperties` via `fancy-regex`
332
+ (`AGENTS.md` "Ingress schema (fast path)") against **attacker-controlled**
333
+ input. A pathological pattern could burn CPU. This is a resource-exhaustion
334
+ class defect; the test encodes intent even if the fix is "document the trust
335
+ boundary + cap input".
336
+
337
+ **Files:**
338
+ - Test: `test/unit/ingress/regex-dos.test.ts`
339
+ - Docs: `SECURITY.md` ("Schema `pattern` is trusted author input") if no code
340
+ change is warranted.
341
+
342
+ - [ ] **Step 1: Write the test** — build an `IngressSchema` with a
343
+ backtracking-prone `pattern` (e.g. `^(a+)+$`) and validate a 50 KB
344
+ non-matching string under a wall-clock bound; assert it completes under a
345
+ budget (e.g. 50 ms) **or** that the schema was rejected/marked unsupported.
346
+ - [ ] **Step 2: Run.**
347
+ - [ ] **Step 3: Decide the fix from evidence**: if it backtracks, add a schema
348
+ compile-time pattern-size/`fancy-regex` backtrack limit (or route the pattern
349
+ to the `jsonschema` fallback which has its own limits) and add a compile-time
350
+ guard. If it is already linear, document the trust boundary in `SECURITY.md`
351
+ and keep the test as a regression pin.
352
+ - [ ] **Step 4: Verify + commit.**
353
+
354
+ ---
355
+
356
+ ## Phase B — Stability / reliability
357
+
358
+ ### Task 4 (B1): Plumb `Request.signal` cancellation through the request path
359
+
360
+ **Why:** Elysia checks `context['~sig'].aborted` between lifecycle stages and
361
+ returns early (`handler/fetch.ts:263-287`), so a disconnected client stops work.
362
+ castrum never reads `Request.signal`; a slow body read or a long pipeline keeps
363
+ running after the client is gone, and pooled buffers stay held.
364
+
365
+ **Files:**
366
+ - Modify: `src/ingress/body.ts` (`readBodyWithLimit`, `:139-268`) — race the read
367
+ against `req.signal` and reject with a cancellation error; remove the abort
368
+ listener on settle.
369
+ - Modify: `src/ingress/handlers.ts` (`run()`, `:456-650`) — check
370
+ `req.signal?.aborted` before the native call and after it, releasing the pooled
371
+ handle on the abort path exactly like the existing early-throw path
372
+ (`:622-630`).
373
+ - Modify: `src/ingress/routes/responder.ts` / `native.ts` — treat cancellation
374
+ as no response (or a 499-style no-op) rather than a 500.
375
+ - Test: `test/unit/ingress/abort.test.ts`
376
+
377
+ **Interfaces:**
378
+ - Produces: `isAbortError(err): boolean`; body read rejects with
379
+ `{ code: 'REQUEST_ABORTED' }`; `run()` returns/throws for an aborted signal.
380
+
381
+ - [ ] **Step 1: Write failing tests** — an `AbortController` aborted before and
382
+ during a streamed body read; assert the read rejects promptly and the pooled
383
+ buffer is released (reuse `test/property/pool-parity.test.ts` assertions).
384
+ - [ ] **Step 2: Run — FAIL.**
385
+ - [ ] **Step 3: Implement** the listener race in `body.ts` + the two
386
+ `req.signal.aborted` checks in `handlers.ts`. Keep the shared watchdog
387
+ (`body.ts:76-137`) untouched — add an abort listener that removes itself.
388
+ - [ ] **Step 4: Verify** `bun test test/unit/ingress test/property` + `bun run bench:http:smoke`.
389
+ - [ ] **Step 5: Commit.**
390
+
391
+ ---
392
+
393
+ ### Task 5 (B2): Bounded caches + public `flushMemory()`
394
+
395
+ **Why:** Elysia ships `flushMemory()` (clear caches + `Bun.gc()`) and bounds
396
+ every cache with generational eviction (`src/memory.ts:7-14`,
397
+ `src/utils.ts:42-53`). castrum has **no memory-flush API** and three unbounded
398
+ structures:
399
+ - TS metrics registry: `values`/`counts`/`sums` Maps grow per distinct label set
400
+ (`src/shared/metrics.ts:73-93`, no cap).
401
+ - `mimeStrCache` (`src/rust-ffi/text.ts:71`) and `mimeByText`
402
+ (`src/rust-ffi/context.ts:113`) keyed by caller extension.
403
+ - Task runtime work queue is explicitly unbounded
404
+ (`rust/task/runtime.rs:193-206`) — handled in B3.
405
+
406
+ **Files:**
407
+ - Create: `src/shared/memory.ts` (public `flushMemory(opts?)`)
408
+ - Modify: `src/shared/metrics.ts` — add `MAX_SERIES_PER_METRIC` (default 10_000)
409
+ with an eviction policy + a `castrum_metrics_series_dropped_total` counter so
410
+ drops are observable rather than silent.
411
+ - Modify: `src/rust-ffi/text.ts`, `src/rust-ffi/context.ts` — bound the MIME maps
412
+ (LRU 1024) and export `clearMimeCaches()`.
413
+ - Modify: `src/shared/buffer-pool.ts` — add `shrink()` (drop free buffers).
414
+ - Modify: `src/loader/index.ts` — expose the existing `clear()` for the flush.
415
+ - Modify: `index.ts` (public export) + `test/unit/contract/proven.test.ts` if a
416
+ `rust.*` surface changes.
417
+ - Test: `test/unit/shared/memory.test.ts`, `test/unit/shared/metrics.test.ts`
418
+
419
+ **Interfaces:**
420
+ - Produces:
421
+ - `flushMemory(options?: { gc?: boolean }): void` — clears loader LRU, MIME
422
+ caches, buffer-pool free lists, and (after C2) the native schema cache;
423
+ optionally `Bun.gc()` / `globalThis.gc?.()`. **Never clears metrics** (an
424
+ observability registry is not a cache).
425
+ - `clearMimeCaches(): void` (impure boundary module).
426
+ - Consumes: `loader.clear()` (`src/loader/index.ts:578`), `BufferPool.shrink()`.
427
+
428
+ - [ ] **Step 1: Write failing tests** — (a) metrics cardinality cap drops and
429
+ counts; (b) `flushMemory()` clears caches and is idempotent/safe with no addon.
430
+ - [ ] **Step 2: Run — FAIL.**
431
+ - [ ] **Step 3: Implement** the cap in `metrics.ts` (evict-oldest-half on the
432
+ per-entry `values` map, mirroring Elysia `evictOldestHalf`), the MIME LRUs, the
433
+ pool `shrink()`, and `flushMemory`.
434
+ - [ ] **Step 4: Verify** `bun test test/unit/shared test/unit/rust-ffi` + `bun run check:jsdoc` + `bun run check:clean`.
435
+ - [ ] **Step 5: Commit.**
436
+
437
+ ---
438
+
439
+ ### Task 6 (B3): Bound the task runtime work queue (admission control)
440
+
441
+ **Why:** `rust/task/runtime.rs:193-206` documents "the queue is unbounded;
442
+ admission/backpressure is a later milestone", and the JS `inflight` Map grows
443
+ with submissions (`src/task/runtime.ts:226`). Under load this is a memory-growth
444
+ path. Elysia bounds analogous in-flight work with a hard cap + close/error
445
+ (`src/ws/route.ts:1126,1213-1220`).
446
+
447
+ **Files:** `rust/task/runtime.rs`, `rust/task/mod.rs`, `rust/ffi/task.rs`,
448
+ `src/task/runtime.ts`; tests `rust/task/tests.rs`,
449
+ `test/unit/task/runtime.test.ts`.
450
+
451
+ **Interfaces:** `submit()` returns a distinct "rejected/overloaded" outcome when
452
+ the queue depth exceeds `CASTRUM_TASK_QUEUE_MAX` (default e.g. 4096); JS rejects
453
+ the promise with a typed `OVERLOADED` error. Existing `submit` return-type
454
+ change must be additive (keep the `bool`-returning C ABI shape or add a new
455
+ symbol — `[verify]` against `docs/FFI_BUN_GUIDE.md` §14 ABI rule).
456
+
457
+ - [ ] **Step 1: Failing test** — submit > cap without draining, assert the
458
+ excess reject with `OVERLOADED` and queue depth is bounded.
459
+ - [ ] **Step 2–5:** implement ring bound + atomic depth counter; verify
460
+ `cargo test task && bun test test/unit/task`; commit.
461
+
462
+ ---
463
+
464
+ ### Task 7 (B4): Differential transport-lane test harness
465
+
466
+ **Why:** Elysia's strongest assurance mechanism is running one corpus through
467
+ every lane (JIT / precompile / real socket / AOT-reconstruct) and byte-comparing
468
+ (`test/differential/lanes.ts:217-239`). castrum has proptest + pool-parity but
469
+ no single harness proving **the same request produces identical bytes** across
470
+ `bun:ffi` vs `napi`, path-1 (`fast.ts`) vs path-2 (`handlers.ts`), Bun vs Node,
471
+ and pooled vs zero-copy.
472
+
473
+ **Files:**
474
+ - Create: `test/differential/lanes.ts` — lane definitions:
475
+ `['ffi','napi']` (transport), `['fast','baked']` (wire format — compare
476
+ normalized shape, **not** raw bytes, since the formats intentionally differ),
477
+ `['copy','zero-copy']`, `['bun','node']`.
478
+ - Create: `test/differential/corpus.ts` — ~40 requests (valid/invalid/edge:
479
+ empty body, oversized, bad JSON, bad query encodings, duplicate headers,
480
+ `__proto__`, abort).
481
+ - Create: `test/differential/differential.test.ts`.
482
+ - Modify: `package.json` test script if a separate gate is desired (keep it out
483
+ of the default `test` path if it requires Node's `dist/` — mirror the
484
+ `test:node` split, `AGENTS.md` Testing).
485
+
486
+ **Interfaces:** `runLane(lane, corpus): Promise<LaneResult>` where each result
487
+ normalizes status + headers-of-interest + body; the harness asserts cross-lane
488
+ equality and pins known-divergent fields explicitly.
489
+
490
+ - [ ] **Step 1: Write the harness + a deliberate divergence test** (inject a
491
+ wrong status in one lane; assert the harness fails).
492
+ - [ ] **Step 2: Run — expect the deliberate failure, then fix the injection.**
493
+ - [ ] **Step 3: Add the corpus; make all lanes agree.**
494
+ - [ ] **Step 4: Commit.**
495
+
496
+ ---
497
+
498
+ ## Phase C — Performance
499
+
500
+ ### Task 8 (C1): Static route promotion (serve prebuilt `Response`s from Bun's native table)
501
+
502
+ **Why:** Elysia pre-maps static `Response`/`Error`/HTML routes and hands them to
503
+ `Bun.serve.routes`, so Bun serves them **without entering JS**
504
+ (`src/adapter/bun/index.ts:184-306`, `compile/handler/index.ts:354-428`).
505
+ castrum already uses `Bun.serve.routes` but every value is a JS handler that
506
+ runs the ingress pipeline (`src/ingress/server.ts:261-333`). Health probes and
507
+ other constant responses (`src/ingress/health.ts:35-67`) can skip JS entirely.
508
+
509
+ **Files:**
510
+ - Modify: `src/ingress/server.ts` — extend `BakedRoute` with
511
+ `static?: Response | (() => Response)` and wire it directly into
512
+ `methods[GET]` when present, bypassing `readHandler`. `[verify]` that Bun
513
+ accepts a static `Response` (not only a handler) as a route value for the
514
+ current Bun floor; if not, keep the handler but return a cached instance.
515
+ - Modify: `src/ingress/health.ts` — expose `staticLiveness()` returning a
516
+ prebuilt `Response` so `/livez` needs no JS.
517
+ - Modify: `src/ingress/router.ts` if it should honor `static`.
518
+ - Test: `test/unit/ingress/static-route.test.ts`, plus
519
+ `bench/http/load.ts` shape guard (`bun run bench:http:smoke`).
520
+
521
+ **Interfaces:** `BakedRoute.static?: Response | (() => Response)`; when present,
522
+ `read`/`write`/`echo` etc. for that path/method are ignored for GET and the
523
+ value is placed in the route table verbatim. Fallback: if Bun rejects a `Response`
524
+ value, wrap in `() => staticResponse`.
525
+
526
+ - [ ] **Step 1: Failing test** — a server with `{ '/livez': { static: prebuilt } }`
527
+ responds 200 with the prebuilt bytes and does **not** run a probe handler
528
+ (assert via a side-effect counter / a Response identity check).
529
+ - [ ] **Step 2–4:** implement; verify `bun run bench:http:smoke` shows no shape
530
+ failures; commit.
531
+
532
+ ---
533
+
534
+ ### Task 9 (C2): Process-wide schema compile cache (dedupe identical schemas)
535
+
536
+ **Why:** Elysia's `TypeBoxValidatorCache` compiles a schema once and shares it
537
+ across routes/apps (`src/type/validator/validator-cache.ts:263-489`), including
538
+ a GC timer. castrum compiles an `IngressSchema` per `Ingress`/`NativeRoute`
539
+ (`rust/ingress/api.rs:81-95`, `router.ts:177-200`), so N routes with one schema
540
+ pay N compiles at startup and N copies at runtime. This is the one performance
541
+ mechanism with a clear, bounded win.
542
+
543
+ **Files (FFI task — follow `docs/HOW_TO_ADD_AN_OP.md` + `castrum-rust-ffi` skill):**
544
+ - Create: `rust/ingress/schema_cache.rs` (bounded `LruCache<SchemaKey, Arc<IngressSchema>>`,
545
+ cap e.g. 128; `SchemaKey = (canonical-json hash, flags)`), re-export from
546
+ `rust/ingress/mod.rs`.
547
+ - Modify: `rust/ingress/api.rs` (`Ingress::new`) and
548
+ `rust/ingress/native_route.rs` (`NativeRoute::compile`) to call
549
+ `get_or_compile`.
550
+ - Modify: `rust/ffi/` — add `castrum_schema_cache_clear` (must be `panic_guard`ed,
551
+ self-tested, added to the dlopen map + `ffi/types.ts` + parity test).
552
+ - Modify: `src/rust-ffi/scalar/factories.ts` (+ `src/native/ffi.ts` bindings) to
553
+ expose `rust.clearSchemaCache()`.
554
+ - Modify: `src/shared/memory.ts` (`flushMemory` calls it).
555
+ - Tests: `rust/ingress/schema_cache.rs` unit tests, `test/unit/native/ffi-symbol-parity.test.ts`,
556
+ `test/unit/ingress/schema-cache.test.ts`.
557
+
558
+ **Interfaces:** `schema_cache::get_or_compile(bytes, flags) -> Result<Arc<IngressSchema>, SchemaError>`;
559
+ `rust.clearSchemaCache(): void`.
560
+
561
+ **Safety gate (must hold before sharing):** `IngressSchema` must be immutable
562
+ after compile (`Arc` shared across routes/threads). `[verify]` there is no
563
+ interior mutability in `IngressSchema`, `fast_schema::FastNode`, or the
564
+ `jsonschema::Validator` it holds; if any exists, cache the compiled pieces only
565
+ and recompile the mutable shell, or abandon C2.
566
+
567
+ - [ ] **Step 1: Rust unit test** — compiling the same bytes twice returns the
568
+ same `Arc` pointer (`Arc::ptr_eq`), and `clear` empties the cache.
569
+ - [ ] **Step 2:** implement Rust cache + clear.
570
+ - [ ] **Step 3: FFI wiring test** — parity test sees the new symbol; bind-time
571
+ self-test passes (`CASTRUM_FFI_MODE=ffi bun test test/unit/native`).
572
+ - [ ] **Step 4: JS parity test** — two `createIngressHandler` instances with the
573
+ same schema produce identical behavior to distinct-schema handlers.
574
+ - [ ] **Step 5:** `cargo test`, `bun run build`, `bun run bench:startup`,
575
+ `bun run bench:http:smoke`; commit.
576
+
577
+ ---
578
+
579
+ ## Phase D — Larger efforts (separate plans)
580
+
581
+ These are real Elysia strengths but each is its own subsystem; write a dedicated
582
+ plan per item using `writing-plans` before implementation.
583
+
584
+ - **D1 — WebSocket server lifecycle.** Wire a `Bun.serve({ websocket })` runtime
585
+ into `createIngressServer` with: max message size, an in-flight message cap
586
+ (Elysia uses 256, `src/ws/route.ts:1126`), backpressure-aware send
587
+ (`:212-285`), per-message error isolation, upgrade-time validation parity with
588
+ HTTP, strictest-wins option merging (`:1084-1124`), and drain-on-shutdown.
589
+ castrum currently ships only the handshake helper
590
+ (`src/integration/websocket.ts:49-93`) and no Bun `websocket` config
591
+ (`src/ingress/server.ts:347-401`).
592
+ - **D2 — Ingress cookie signing/verification + CSRF gate.** Add an opt-in
593
+ `cookies: { secrets, verify: 'lazy'|'eager' }` to `IngressOptions` that reuses
594
+ the native cookie-signer (`rust/crypto/cookie_sign.rs`) and mirrors Elysia's
595
+ rotation/legacy/name-binding semantics (`src/cookie/crypto.ts:145-227`).
596
+ castrum has the primitives but no ingress integration
597
+ (`rust/ingress/pipeline.rs:454-476`).
598
+ - **D3 — Benchmark proof-baseline with noise floors + self-test injection.**
599
+ Port the *methodology* of Elysia D1 (`bench/d1/run.ts:1395-1569`,
600
+ `README.md`) to castrum's HTTP/startup benches: paired blocks, percentile
601
+ bootstrap, machine-pinned baselines, and injected regressions that prove each
602
+ gate can fail. This turns castrum's ad-hoc benches into statistically gated
603
+ claims.
604
+ - **D4 — `isProduction()` seam (only if/when verbose dev diagnostics are added).**
605
+ Elysia gates error detail on it (`src/universal/is-production.ts:3-4`). Not
606
+ needed for A1 because castrum's error bodies are static by wire-format rule;
607
+ add it when a dev-only diagnostic path is designed, not before.
608
+
609
+ ---
610
+
611
+ ## Self-Review
612
+
613
+ - **Spec coverage:** every Elysia mechanism in the Context table is either mapped
614
+ to a task (A1, A2, A3, B1, B2, B3, B4, C1, C2) or explicitly deferred (D1–D4)
615
+ or explicitly rejected with a reason. No silent gaps.
616
+ - **Placeholder scan:** no TBD/"handle errors"/"similar to Task N" — each task
617
+ names files, interfaces, and test intent. C2 is flagged with a hard safety gate
618
+ rather than a speculative implementation.
619
+ - **Type consistency:** `RouteHandler`, `BakedRoute`, `BuildRouteHandlersOptions`,
620
+ `OptimizedIngressHandler.run` signatures match the current source
621
+ (`server.ts:30-94`, `:226-229`); `createServerErrorHandler`/`guardRouteHandler`
622
+ names are used consistently from A1 onward; `flushMemory`/`clearMimeCaches`
623
+ names are consistent between B2 and C2.
624
+ - **Constraint check:** no task changes a wire format, a hot-path signature, or a
625
+ layout constant; A1 reuses the frozen internal error body; C2 is gated on
626
+ proving `IngressSchema` immutability before sharing.
627
+
628
+ ## Suggested order
629
+
630
+ A1 → B2 → A2 → B1 → C1 → A3 → B4 → B3 → C2 → D*.
631
+
632
+ Rationale: A1 and B2 are the highest value per unit risk (containment + bounded
633
+ memory). C1 is a contained perf win. C2 is the largest perf change and carries an
634
+ FFI/ABI cost, so it comes after the safety net (A1/B2/B4) exists.
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.
@@ -40,6 +41,11 @@ export { decoder, encoder } from './src/shared/bytes'
40
41
  export type { Counter, Gauge, Histogram, MetricsRegistry } from './src/shared/metrics'
41
42
  // Zero-dep metrics registry (counters/gauges/histograms + Prometheus render).
42
43
  export { createMetrics, DEFAULT_BUCKETS } from './src/shared/metrics'
44
+ export type { FlushMemoryOptions } from './src/shared/memory'
45
+ // Explicit memory-flush escape hatch (drops cached state + optional GC).
46
+ // Deliberately does NOT clear the metrics registry (an observability registry
47
+ // is not a cache).
48
+ export { flushMemory } from './src/shared/memory'
43
49
  export type { Pair, ParsedHttpRequestPacked } from './src/shared/packed'
44
50
  export {
45
51
  packBatch,