cursedbelt-server 4.1.0 → 4.2.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.
- package/dist/server/bench/assert.d.ts +16 -3
- package/dist/server/bench/assert.js +54 -0
- package/dist/server/bench/cpuClock.js +21 -1
- package/dist/server/d1/fakeD1.d.ts +5 -0
- package/dist/server/d1/fakeD1.js +51 -18
- package/dist/server/d1/local.js +48 -3
- package/dist/server/d1/values.d.ts +19 -2
- package/dist/server/d1/values.js +21 -2
- package/dist/server/middleware/bodyLimit.d.ts +152 -0
- package/dist/server/middleware/bodyLimit.js +161 -0
- package/package.json +7 -1
- package/src/leafSubpathsImportNothing.spec.ts +13 -0
- package/src/server/bench/assert.ts +78 -3
- package/src/server/bench/cpuBudget.spec.ts +101 -1
- package/src/server/bench/cpuClock.ts +22 -1
- package/src/server/d1/fakeD1.ts +56 -18
- package/src/server/d1/local.ts +56 -3
- package/src/server/d1/sameShape.spec.ts +73 -0
- package/src/server/d1/values.ts +23 -2
- package/src/server/middleware/bodyLimit.spec.ts +238 -0
- package/src/server/middleware/bodyLimit.ts +210 -0
|
@@ -276,4 +276,77 @@ describe('local and D1 drivers return identical shapes', () => {
|
|
|
276
276
|
expect(await attempt(p.local)).toBe(0);
|
|
277
277
|
expect(await attempt(p.remote)).toBe(0);
|
|
278
278
|
});
|
|
279
|
+
|
|
280
|
+
// ── batch META, which both sides got wrong the same way until 2026-09-17 ──────────
|
|
281
|
+
//
|
|
282
|
+
// 🔴 These four assert VALUES, not just agreement. The bug they pin was `changes: 0`
|
|
283
|
+
// hardcoded in `local.ts`'s `allSync` AND in `fakeD1`'s `batch`, so the two drivers
|
|
284
|
+
// agreed perfectly on a wrong answer and every `expect(local).toEqual(remote)` above
|
|
285
|
+
// stayed green. A shared bug is the one failure mode a two-implementation test cannot
|
|
286
|
+
// see, so the numbers have to be written down here. Found by porting
|
|
287
|
+
// `apps/patterns/src/server/tokens.ts` onto the seam, on its first atomic write.
|
|
288
|
+
|
|
289
|
+
test('batch() reports each member’s OWN changes — not zero, on both', async () => {
|
|
290
|
+
p.seed(`INSERT INTO t (id, text_col) VALUES (1, 'a'), (2, 'b'), (3, 'c')`);
|
|
291
|
+
const { local, remote } = await both(p, async (db) => {
|
|
292
|
+
const res = await db.batch([
|
|
293
|
+
db.prepare("UPDATE t SET text_col = 'z'"),
|
|
294
|
+
db.prepare('DELETE FROM t WHERE id = ?').bind(1),
|
|
295
|
+
]);
|
|
296
|
+
return res.map((r) => r.meta.changes);
|
|
297
|
+
});
|
|
298
|
+
expect(local).toEqual(remote);
|
|
299
|
+
// The whole point: three rows updated and one deleted, reported per statement.
|
|
300
|
+
expect(local).toEqual([3, 1]);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('batch() reports last_row_id for an insert, on both', async () => {
|
|
304
|
+
const { local, remote } = await both(p, async (db) => {
|
|
305
|
+
const res = await db.batch([
|
|
306
|
+
db.prepare('INSERT INTO t (id, text_col) VALUES (41, ?)').bind('a'),
|
|
307
|
+
db.prepare('INSERT INTO t (id, text_col) VALUES (42, ?)').bind('b'),
|
|
308
|
+
]);
|
|
309
|
+
return res.map((r) => ({ changes: r.meta.changes, last_row_id: r.meta.last_row_id }));
|
|
310
|
+
});
|
|
311
|
+
expect(local).toEqual(remote);
|
|
312
|
+
expect(local).toEqual([
|
|
313
|
+
{ changes: 1, last_row_id: 41 },
|
|
314
|
+
{ changes: 1, last_row_id: 42 },
|
|
315
|
+
]);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test('a READ inside a batch reports changes 0, even right after a write', async () => {
|
|
319
|
+
// 🔴 The trap in the obvious fix. SQLite's `changes()` is connection-wide and
|
|
320
|
+
// sticky, so a driver that simply reads it after every statement reports the
|
|
321
|
+
// UPDATE's 3 as the SELECT's own count. Measured 2026-09-17: a `SELECT` following
|
|
322
|
+
// a one-row insert still answers `changes() = 1`.
|
|
323
|
+
p.seed(`INSERT INTO t (id, text_col) VALUES (1, 'a'), (2, 'b'), (3, 'c')`);
|
|
324
|
+
const { local, remote } = await both(p, async (db) => {
|
|
325
|
+
const res = await db.batch([
|
|
326
|
+
db.prepare("UPDATE t SET text_col = 'z'"),
|
|
327
|
+
db.prepare('SELECT id FROM t ORDER BY id'),
|
|
328
|
+
]);
|
|
329
|
+
return {
|
|
330
|
+
changes: res.map((r) => r.meta.changes),
|
|
331
|
+
rows: res[1]?.results,
|
|
332
|
+
};
|
|
333
|
+
});
|
|
334
|
+
expect(local).toEqual(remote);
|
|
335
|
+
expect(local.changes).toEqual([3, 0]);
|
|
336
|
+
expect(local.rows).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test('INSERT … RETURNING in a batch reports its rows AND its changes, on both', async () => {
|
|
340
|
+
// Returns rows and writes, so neither the `.run()` path nor the pure-read path
|
|
341
|
+
// answers it — this is the case `total_changes()` differencing exists for.
|
|
342
|
+
const { local, remote } = await both(p, async (db) => {
|
|
343
|
+
const res = await db.batch([
|
|
344
|
+
db.prepare('INSERT INTO t (id, text_col) VALUES (7, ?), (8, ?) RETURNING id').bind('a', 'b'),
|
|
345
|
+
]);
|
|
346
|
+
return { rows: res[0]?.results, changes: res[0]?.meta.changes };
|
|
347
|
+
});
|
|
348
|
+
expect(local).toEqual(remote);
|
|
349
|
+
expect(local.changes).toBe(2);
|
|
350
|
+
expect(local.rows).toEqual([{ id: 7 }, { id: 8 }]);
|
|
351
|
+
});
|
|
279
352
|
});
|
package/src/server/d1/values.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The one place that decides what a bound parameter
|
|
3
|
-
* both drivers.
|
|
2
|
+
* The one place that decides what a bound parameter, a returned column and a statement's
|
|
3
|
+
* own `meta` MEAN, shared by both drivers.
|
|
4
4
|
*
|
|
5
5
|
* 🔴 **Both drivers call these, and that is why the identical-shapes test can pass.** If
|
|
6
6
|
* the local driver normalized its own way and the remote driver normalized its own way,
|
|
@@ -81,6 +81,27 @@ export function normalizeBind(value: unknown, index: number): D1LikeBindable {
|
|
|
81
81
|
export const normalizeBinds = (values: readonly unknown[]): D1LikeBindable[] =>
|
|
82
82
|
values.map((v, i) => normalizeBind(v, i));
|
|
83
83
|
|
|
84
|
+
/** Leading whitespace and SQL comments, which sit in front of the real first keyword. */
|
|
85
|
+
const LEADING_NOISE = /^(?:\s|--[^\n]*\n?|\/\*[\s\S]*?\*\/)+/;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Is this statement a PURE READ — one that cannot possibly have changed a row?
|
|
89
|
+
*
|
|
90
|
+
* 🔴 Measured 2026-09-17, and this is why the question has to be asked at all:
|
|
91
|
+
* SQLite's `changes()` and `last_insert_rowid()` are **connection-wide and sticky**.
|
|
92
|
+
* After a plain `SELECT` they still read the counters left by the last write — probed
|
|
93
|
+
* here, a `SELECT` following a 1-row insert reports `changes = 1`. So those counters may
|
|
94
|
+
* only ever be attributed to a statement that actually wrote something, and a driver
|
|
95
|
+
* that reads them after a read invents a `changes` out of the previous statement's.
|
|
96
|
+
*
|
|
97
|
+
* Only a leading `SELECT` is claimed, because that is the one keyword that is
|
|
98
|
+
* unambiguous: `WITH … INSERT … RETURNING` and `INSERT … RETURNING` both return rows AND
|
|
99
|
+
* write. Anything this returns `false` for is measured exactly instead (see
|
|
100
|
+
* `local.ts`), so a statement shape this does not recognise costs two extra queries —
|
|
101
|
+
* never a wrong number.
|
|
102
|
+
*/
|
|
103
|
+
export const isPureReadSql = (sql: string): boolean => /^select\b/i.test(sql.replace(LEADING_NOISE, ''));
|
|
104
|
+
|
|
84
105
|
/**
|
|
85
106
|
* Normalize one column value coming BACK from a driver.
|
|
86
107
|
*
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
type BodyBytesSource,
|
|
4
|
+
type BodyHeaderSource,
|
|
5
|
+
type BodyTextSource,
|
|
6
|
+
readBoundedBytes,
|
|
7
|
+
readBoundedJson,
|
|
8
|
+
readBoundedText,
|
|
9
|
+
refuseOversizedBody,
|
|
10
|
+
} from './bodyLimit';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 🔴 A guard that is the LAST line of defense ships with the test of its failure path,
|
|
14
|
+
* not only of its success path.
|
|
15
|
+
*
|
|
16
|
+
* `apps/roms/src/server/limits.test.ts` is where this came from (2026-09-17). Most of
|
|
17
|
+
* that file is about roms' own `routes.ts` — that every bounded-body call site takes its
|
|
18
|
+
* ceiling from `MAX_BODY_BYTES` — and stays there, because it asserts about an app's
|
|
19
|
+
* routes. What travels with the module is its two behavioural tests (an honest oversized
|
|
20
|
+
* declaration is refused with both numbers in the message; a body with no declared length
|
|
21
|
+
* is NOT refused on that ground alone), extended here to the three readers the app-side
|
|
22
|
+
* file never exercised.
|
|
23
|
+
*
|
|
24
|
+
* Every source below is a plain object literal. That is the module's design showing
|
|
25
|
+
* through rather than a shortcut: the four entry points take structural
|
|
26
|
+
* `{ req: { … } }` sources, so nothing here needs Hono, a server, or a socket — which is
|
|
27
|
+
* also why `cursedbelt-server/body-limit` can promise to import nothing at runtime.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const textSource = (raw: string): BodyTextSource => ({ req: { text: async () => raw } });
|
|
31
|
+
|
|
32
|
+
const bytesSource = (bytes: Uint8Array): BodyBytesSource => ({
|
|
33
|
+
req: {
|
|
34
|
+
async arrayBuffer() {
|
|
35
|
+
return bytes.buffer.slice(
|
|
36
|
+
bytes.byteOffset,
|
|
37
|
+
bytes.byteOffset + bytes.byteLength,
|
|
38
|
+
) as ArrayBuffer;
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** A body that dies mid-read — a client that hung up, a socket reset. */
|
|
44
|
+
const brokenText: BodyTextSource = {
|
|
45
|
+
req: {
|
|
46
|
+
text: async () => {
|
|
47
|
+
throw new Error('connection reset');
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const brokenBytes: BodyBytesSource = {
|
|
53
|
+
req: {
|
|
54
|
+
arrayBuffer: async () => {
|
|
55
|
+
throw new Error('connection reset');
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const headerSource = (contentLength: string | undefined): BodyHeaderSource => ({
|
|
61
|
+
req: { header: (name) => (name === 'content-length' ? contentLength : undefined) },
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('readBoundedJson', () => {
|
|
65
|
+
it('parses a body inside the ceiling', async () => {
|
|
66
|
+
const result = await readBoundedJson<{ hello: string }>(
|
|
67
|
+
textSource('{"hello":"world"}'),
|
|
68
|
+
{ maxBytes: 1024 },
|
|
69
|
+
);
|
|
70
|
+
expect(result.ok).toBe(true);
|
|
71
|
+
expect(result.ok === true ? result.value : null).toEqual({ hello: 'world' });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('refuses an oversized body with 413 and BOTH numbers in the message', async () => {
|
|
75
|
+
const raw = JSON.stringify({ blob: 'x'.repeat(200) });
|
|
76
|
+
const result = await readBoundedJson(textSource(raw), {
|
|
77
|
+
maxBytes: 64,
|
|
78
|
+
label: 'save state',
|
|
79
|
+
});
|
|
80
|
+
expect(result.ok).toBe(false);
|
|
81
|
+
expect(result.ok === false ? result.status : 0).toBe(413);
|
|
82
|
+
const error = result.ok === false ? result.error : '';
|
|
83
|
+
expect(error).toContain('save state');
|
|
84
|
+
expect(error).toContain(String(raw.length)); // what arrived
|
|
85
|
+
expect(error).toContain('64'); // what was allowed
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 🔴 The reason the module measures `TextEncoder().encode(raw).length` and not
|
|
90
|
+
* `raw.length`. A 4-byte emoji is 2 UTF-16 code units, so a `String.length` check
|
|
91
|
+
* measures this body at 8 against a real size of 14 — and lets it past a byte limit
|
|
92
|
+
* the proxy in front is enforcing in real bytes. That mismatch is what this module
|
|
93
|
+
* exists to close, so it gets an assertion rather than a sentence.
|
|
94
|
+
*/
|
|
95
|
+
it('measures BYTES, not UTF-16 code units', async () => {
|
|
96
|
+
const raw = JSON.stringify('😀😀😀');
|
|
97
|
+
expect(raw.length).toBe(8);
|
|
98
|
+
expect(new TextEncoder().encode(raw).length).toBe(14);
|
|
99
|
+
const result = await readBoundedJson(textSource(raw), { maxBytes: 10 });
|
|
100
|
+
expect(result.ok).toBe(false);
|
|
101
|
+
expect(result.ok === false ? result.status : 0).toBe(413);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('allows a body exactly ON the ceiling — the limit is inclusive', async () => {
|
|
105
|
+
const raw = '"abcd"'; // 6 bytes
|
|
106
|
+
const result = await readBoundedJson(textSource(raw), { maxBytes: 6 });
|
|
107
|
+
expect(result.ok).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/** A 400, never a throw: the caller gets a status to return, not an exception to catch. */
|
|
111
|
+
it('answers 400 for a body that is not JSON', async () => {
|
|
112
|
+
const result = await readBoundedJson(textSource('not json at all'), {
|
|
113
|
+
maxBytes: 1024,
|
|
114
|
+
label: 'preferences',
|
|
115
|
+
});
|
|
116
|
+
expect(result.ok).toBe(false);
|
|
117
|
+
expect(result.ok === false ? result.status : 0).toBe(400);
|
|
118
|
+
expect(result.ok === false ? result.error : '').toBe('preferences must be JSON');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('answers 400 when the body cannot be read at all', async () => {
|
|
122
|
+
const result = await readBoundedJson(brokenText, { maxBytes: 1024 });
|
|
123
|
+
expect(result.ok).toBe(false);
|
|
124
|
+
expect(result.ok === false ? result.status : 0).toBe(400);
|
|
125
|
+
expect(result.ok === false ? result.error : '').toBe('body could not be read');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('readBoundedText', () => {
|
|
130
|
+
it('hands back the raw text inside the ceiling', async () => {
|
|
131
|
+
const result = await readBoundedText(textSource('chunk-payload'), { maxBytes: 64 });
|
|
132
|
+
expect(result.ok).toBe(true);
|
|
133
|
+
expect(result.ok === true ? result.value : '').toBe('chunk-payload');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('refuses an oversized body with 413, measured in bytes', async () => {
|
|
137
|
+
const result = await readBoundedText(textSource('😀'.repeat(4)), {
|
|
138
|
+
maxBytes: 8,
|
|
139
|
+
label: 'chunk',
|
|
140
|
+
});
|
|
141
|
+
expect(result.ok).toBe(false);
|
|
142
|
+
expect(result.ok === false ? result.status : 0).toBe(413);
|
|
143
|
+
expect(result.ok === false ? result.error : '').toContain('chunk too large (16 bytes');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('answers 400 when the body cannot be read', async () => {
|
|
147
|
+
const result = await readBoundedText(brokenText, { maxBytes: 64 });
|
|
148
|
+
expect(result.ok === false ? result.status : 0).toBe(400);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('readBoundedBytes', () => {
|
|
153
|
+
it('hands back the exact bytes inside the ceiling', async () => {
|
|
154
|
+
const payload = new Uint8Array([0x00, 0xff, 0x80, 0x41]);
|
|
155
|
+
const result = await readBoundedBytes(bytesSource(payload), { maxBytes: 16 });
|
|
156
|
+
expect(result.ok).toBe(true);
|
|
157
|
+
expect([...(result.ok === true ? result.value : [])]).toEqual([0x00, 0xff, 0x80, 0x41]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The reason this exists beside {@link readBoundedText}: 0x80 and 0xff are not valid
|
|
162
|
+
* UTF-8, so decoding cartridge bytes as text would replace them with U+FFFD and measure
|
|
163
|
+
* a size that is not the size the proxy is enforcing.
|
|
164
|
+
*/
|
|
165
|
+
it('does not corrupt bytes that are invalid UTF-8', async () => {
|
|
166
|
+
const payload = new Uint8Array([0x80, 0xfe, 0xff]);
|
|
167
|
+
const result = await readBoundedBytes(bytesSource(payload), { maxBytes: 16 });
|
|
168
|
+
expect(result.ok === true ? result.value.length : -1).toBe(3);
|
|
169
|
+
expect(new TextDecoder().decode(result.ok === true ? result.value : new Uint8Array())).toBe(
|
|
170
|
+
'���',
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('refuses an oversized buffer with 413 and both numbers', async () => {
|
|
175
|
+
const result = await readBoundedBytes(bytesSource(new Uint8Array(100)), {
|
|
176
|
+
maxBytes: 32,
|
|
177
|
+
label: 'rom chunk',
|
|
178
|
+
});
|
|
179
|
+
expect(result.ok === false ? result.status : 0).toBe(413);
|
|
180
|
+
expect(result.ok === false ? result.error : '').toBe(
|
|
181
|
+
'rom chunk too large (100 bytes; the limit is 32)',
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('answers 400 when the body cannot be read', async () => {
|
|
186
|
+
const result = await readBoundedBytes(brokenBytes, { maxBytes: 32 });
|
|
187
|
+
expect(result.ok === false ? result.status : 0).toBe(400);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('refuseOversizedBody', () => {
|
|
192
|
+
/** The guard itself: an honest oversized declaration is refused, and the message names
|
|
193
|
+
* both numbers so a 413 is actionable rather than mysterious. */
|
|
194
|
+
it('refuses an oversized declared body with both numbers in the message', () => {
|
|
195
|
+
const refusal = refuseOversizedBody(headerSource('4194305'), {
|
|
196
|
+
maxBytes: 4 * 1024 * 1024,
|
|
197
|
+
label: 'save state',
|
|
198
|
+
});
|
|
199
|
+
expect(refusal?.status).toBe(413);
|
|
200
|
+
expect(refusal?.error).toContain('save state');
|
|
201
|
+
expect(refusal?.error).toContain('4194305');
|
|
202
|
+
expect(refusal?.error).toContain('4194304');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* A chunked request legitimately omits `Content-Length`. Refusing it would break a
|
|
207
|
+
* correct client to catch a dishonest one — and the dishonest one is caught by the
|
|
208
|
+
* buffering readers anyway, which measure what actually arrived.
|
|
209
|
+
*/
|
|
210
|
+
it('does not refuse a body with no declared length on that ground alone', () => {
|
|
211
|
+
expect(refuseOversizedBody(headerSource(undefined), { maxBytes: 1024 })).toBeNull();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('does not refuse an unparseable declared length', () => {
|
|
215
|
+
expect(refuseOversizedBody(headerSource('not-a-number'), { maxBytes: 1024 })).toBeNull();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('does not refuse a body exactly on the ceiling', () => {
|
|
219
|
+
expect(refuseOversizedBody(headerSource('1024'), { maxBytes: 1024 })).toBeNull();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 🔴 The compensating control, asserted rather than described. This function TRUSTS the
|
|
224
|
+
* header — it has to, because a `multipart/form-data` body cannot be measured without
|
|
225
|
+
* buffering it first, which is the thing the limit exists to prevent. What makes that
|
|
226
|
+
* safe is that a liar only gets as far as the next guard: a body declaring 1 byte and
|
|
227
|
+
* carrying 5 MB is still refused by the reader that measures what arrived.
|
|
228
|
+
*/
|
|
229
|
+
it('is bypassed by a lying header — and the buffering reader still refuses', async () => {
|
|
230
|
+
const liar = headerSource('1');
|
|
231
|
+
expect(refuseOversizedBody(liar, { maxBytes: 1024 })).toBeNull();
|
|
232
|
+
|
|
233
|
+
const actuallyHuge = await readBoundedBytes(bytesSource(new Uint8Array(5000)), {
|
|
234
|
+
maxBytes: 1024,
|
|
235
|
+
});
|
|
236
|
+
expect(actuallyHuge.ok === false ? actuallyHuge.status : 0).toBe(413);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
@@ -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
|
+
}
|