cursedbelt-server 4.1.0 → 4.3.0

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 (35) hide show
  1. package/dist/server/bench/assert.d.ts +16 -3
  2. package/dist/server/bench/assert.js +54 -0
  3. package/dist/server/bench/budget.d.ts +35 -1
  4. package/dist/server/bench/budget.js +35 -1
  5. package/dist/server/bench/cpuClock.js +21 -1
  6. package/dist/server/bench/index.d.ts +1 -1
  7. package/dist/server/bench/index.js +1 -1
  8. package/dist/server/d1/fakeD1.d.ts +5 -0
  9. package/dist/server/d1/fakeD1.js +51 -18
  10. package/dist/server/d1/index.d.ts +1 -0
  11. package/dist/server/d1/index.js +1 -0
  12. package/dist/server/d1/invocation.d.ts +72 -0
  13. package/dist/server/d1/invocation.js +166 -0
  14. package/dist/server/d1/local.js +48 -3
  15. package/dist/server/d1/values.d.ts +19 -2
  16. package/dist/server/d1/values.js +21 -2
  17. package/dist/server/middleware/bodyLimit.d.ts +152 -0
  18. package/dist/server/middleware/bodyLimit.js +161 -0
  19. package/package.json +8 -2
  20. package/src/leafSubpathsImportNothing.spec.ts +13 -0
  21. package/src/server/bench/assert.ts +78 -3
  22. package/src/server/bench/budget.spec.ts +27 -0
  23. package/src/server/bench/budget.ts +36 -1
  24. package/src/server/bench/cpuBudget.spec.ts +101 -1
  25. package/src/server/bench/cpuClock.ts +22 -1
  26. package/src/server/bench/index.ts +1 -0
  27. package/src/server/d1/fakeD1.ts +56 -18
  28. package/src/server/d1/index.ts +1 -0
  29. package/src/server/d1/invocation.spec.ts +174 -0
  30. package/src/server/d1/invocation.ts +207 -0
  31. package/src/server/d1/local.ts +56 -3
  32. package/src/server/d1/sameShape.spec.ts +73 -0
  33. package/src/server/d1/values.ts +23 -2
  34. package/src/server/middleware/bodyLimit.spec.ts +238 -0
  35. package/src/server/middleware/bodyLimit.ts +210 -0
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Reading a request body without first agreeing how big it may be.
3
+ *
4
+ * ── Why this is one function rather than one per app (2026-07-27) ────────────
5
+ * The two apps behind the shared owner-auth were inconsistently hardened, in a
6
+ * way an adversarial review found and neither test suite could:
7
+ *
8
+ * - the companion app (since folded into `apps/orch`) read `c.req.text()`,
9
+ * checked the length, and returned 413 BEFORE parsing — correct.
10
+ * - `apps/roms` called `await c.req.json()` first and checked the size deep
11
+ * inside the save-state store — i.e. it buffered and parsed an arbitrary body
12
+ * into a process whose unit caps at 300 MB of memory before forming an
13
+ * opinion about its size.
14
+ *
15
+ * Both went through {@link readBoundedJson} after that. The guard is one
16
+ * function so the posture cannot diverge again, and it is deliberately the
17
+ * *last* line of defense: a proxy's `client_max_body_size` refuses an oversized
18
+ * body at the edge, and this refuses it if the proxy is absent, misconfigured,
19
+ * or bypassed — which is exactly the case in dev, in `preview:prod`, and for any
20
+ * request that reaches the loopback port directly.
21
+ *
22
+ * ── Where it came from (2026-09-17) ──────────────────────────────────────────
23
+ * Five apps — `roms`, `family`, `music`, `vault`, `collections` — carried this
24
+ * file byte-for-byte identical (`b36d0eedee7a`), which is the posture diverging
25
+ * again, only more slowly and with nothing watching: a repo's gate proves that
26
+ * repo, so five green gates is what five copies of a security guard look like
27
+ * from the outside. Confirmed identical with `shasum -a256` before the move, so
28
+ * there was no fork to reconcile and nothing was dropped.
29
+ *
30
+ * 🔴 It is published as the LEAF subpath `cursedbelt-server/body-limit`, never
31
+ * through the `./middleware` barrel — that barrel drags `hono` and the error
32
+ * envelope, and this module's whole promise is that it imports NOTHING. The
33
+ * source is a `{ req: { text() } }` / `{ req: { arrayBuffer() } }` /
34
+ * `{ req: { header() } }` structural type rather than a Hono context, so a
35
+ * caller needs no framework to use it and a test needs none to exercise it.
36
+ * `src/leafSubpathsImportNothing.spec.ts` is what holds that promise.
37
+ */
38
+
39
+ /** What {@link readBoundedBytes} gives back. */
40
+ export type BoundedBytes =
41
+ | { ok: true; value: Uint8Array }
42
+ | { ok: false; status: 413 | 400; error: string };
43
+
44
+ /** The minimum a route needs to hand over a raw body. */
45
+ export interface BodyBytesSource {
46
+ req: { arrayBuffer(): Promise<ArrayBuffer> };
47
+ }
48
+
49
+ /** What {@link readBoundedJson} gives back — a discriminated result, never a throw. */
50
+ export type BoundedJson<T> =
51
+ | { ok: true; value: T }
52
+ | { ok: false; status: 413; error: string }
53
+ | { ok: false; status: 400; error: string };
54
+
55
+ export interface BoundedJsonOptions {
56
+ /** Hard ceiling on the raw body, in bytes. */
57
+ maxBytes: number;
58
+ /** What to call the payload in the error message (e.g. "save state"). */
59
+ label?: string;
60
+ }
61
+
62
+ /** The minimum a route needs from Hono's context — kept structural so tests need no Hono. */
63
+ export interface BodyTextSource {
64
+ req: { text(): Promise<string> };
65
+ }
66
+
67
+ /** What {@link readBoundedText} gives back. Same shape, minus the parse failure. */
68
+ export type BoundedText =
69
+ | { ok: true; value: string }
70
+ | { ok: false; status: 413 | 400; error: string };
71
+
72
+ /**
73
+ * Read a body as TEXT, refusing anything over `maxBytes`.
74
+ *
75
+ * The sibling of {@link readBoundedJson}, for a payload that IS the text rather
76
+ * than a document containing it — `apps/patterns`' chunked sends put their
77
+ * metadata in the query string and the chunk in the raw body, which spares a
78
+ * megabyte-scale caller from JSON-escaping every piece and spares this process
79
+ * from parsing what it is only going to concatenate.
80
+ *
81
+ * Same byte-not-character measurement, and for the same reason: it is the number
82
+ * the proxy in front of this is enforcing.
83
+ */
84
+ export async function readBoundedText(
85
+ c: BodyTextSource,
86
+ options: BoundedJsonOptions,
87
+ ): Promise<BoundedText> {
88
+ const { maxBytes, label = 'body' } = options;
89
+ let raw: string;
90
+ try {
91
+ raw = await c.req.text();
92
+ } catch {
93
+ return { ok: false, status: 400, error: `${label} could not be read` };
94
+ }
95
+ const bytes = new TextEncoder().encode(raw).length;
96
+ if (bytes > maxBytes) {
97
+ return {
98
+ ok: false,
99
+ status: 413,
100
+ error: `${label} too large (${bytes} bytes; the limit is ${maxBytes})`,
101
+ };
102
+ }
103
+ return { ok: true, value: raw };
104
+ }
105
+
106
+ /**
107
+ * Read a body as RAW BYTES, refusing anything over `maxBytes`.
108
+ *
109
+ * The third member of the family, for a payload that is neither JSON nor text:
110
+ * roms' ROM-upload chunks are cartridge bytes, and routing them through
111
+ * {@link readBoundedText} would decode arbitrary binary as UTF-8 — which is
112
+ * lossy (every invalid sequence becomes U+FFFD) and measures a size that is not
113
+ * the size the proxy in front is enforcing.
114
+ *
115
+ * A `Content-Length` claim is deliberately NOT trusted here: it is a header, and
116
+ * the guard has to hold against a body that disagrees with it. The measurement is
117
+ * the buffer that actually arrived.
118
+ */
119
+ export async function readBoundedBytes(
120
+ c: BodyBytesSource,
121
+ options: BoundedJsonOptions,
122
+ ): Promise<BoundedBytes> {
123
+ const { maxBytes, label = 'body' } = options;
124
+ let buffer: ArrayBuffer;
125
+ try {
126
+ buffer = await c.req.arrayBuffer();
127
+ } catch {
128
+ return { ok: false, status: 400, error: `${label} could not be read` };
129
+ }
130
+ if (buffer.byteLength > maxBytes) {
131
+ return {
132
+ ok: false,
133
+ status: 413,
134
+ error: `${label} too large (${buffer.byteLength} bytes; the limit is ${maxBytes})`,
135
+ };
136
+ }
137
+ return { ok: true, value: new Uint8Array(buffer) };
138
+ }
139
+
140
+ /** The minimum {@link refuseOversizedBody} needs — a header lookup, nothing else. */
141
+ export interface BodyHeaderSource {
142
+ req: { header(name: string): string | undefined };
143
+ }
144
+
145
+ /**
146
+ * Refuse a body by its DECLARED `Content-Length`, without reading it.
147
+ *
148
+ * The fourth member of the family, and the only one for a body that cannot be
149
+ * buffered first: a `multipart/form-data` upload. The other three measure the
150
+ * bytes that actually arrived, which is strictly better — but they get to,
151
+ * because they buffer. `c.req.formData()` gives no such opportunity: by the time
152
+ * it resolves, the whole upload is already in a process whose unit caps at
153
+ * 300 MB of memory, which is the thing the limit exists to prevent.
154
+ *
155
+ * So this trusts the header, and the docblock says so plainly. A liar gets
156
+ * through to the buffering call — but a liar was always going to, and this still
157
+ * turns the honest 3 GB folder-drop (the actual failure shape) into a refusal
158
+ * the app can explain instead of an OOM or an nginx HTML 413 the app never sees.
159
+ * It is a companion to the proxy's `client_max_body_size`, never a replacement.
160
+ *
161
+ * A missing or unparseable header is NOT a refusal: chunked requests omit it
162
+ * legitimately, and refusing them would break a correct client to catch a
163
+ * dishonest one.
164
+ */
165
+ export function refuseOversizedBody(
166
+ c: BodyHeaderSource,
167
+ options: BoundedJsonOptions,
168
+ ): { status: 413; error: string } | null {
169
+ const { maxBytes, label = 'body' } = options;
170
+ const declared = Number(c.req.header('content-length'));
171
+ if (!Number.isFinite(declared) || declared <= maxBytes) return null;
172
+ return {
173
+ status: 413,
174
+ error: `${label} too large (${declared} bytes; the limit is ${maxBytes})`,
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Read a JSON body, refusing anything over `maxBytes` before parsing it.
180
+ *
181
+ * The size is measured in BYTES, not characters. `String.length` counts UTF-16
182
+ * code units, so a body of 4-byte emoji measures half its true size that way —
183
+ * which would let a caller past a byte limit the proxy is enforcing in real
184
+ * bytes, and produce the mismatch this module exists to close.
185
+ */
186
+ export async function readBoundedJson<T = unknown>(
187
+ c: BodyTextSource,
188
+ options: BoundedJsonOptions,
189
+ ): Promise<BoundedJson<T>> {
190
+ const { maxBytes, label = 'body' } = options;
191
+ let raw: string;
192
+ try {
193
+ raw = await c.req.text();
194
+ } catch {
195
+ return { ok: false, status: 400, error: `${label} could not be read` };
196
+ }
197
+ const bytes = new TextEncoder().encode(raw).length;
198
+ if (bytes > maxBytes) {
199
+ return {
200
+ ok: false,
201
+ status: 413,
202
+ error: `${label} too large (${bytes} bytes; the limit is ${maxBytes})`,
203
+ };
204
+ }
205
+ try {
206
+ return { ok: true, value: JSON.parse(raw) as T };
207
+ } catch {
208
+ return { ok: false, status: 400, error: `${label} must be JSON` };
209
+ }
210
+ }