okengine 0.23.1 → 0.23.2

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 (83) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/ai/mcp.mdx +3 -3
  3. package/site/content/docs/elements/signal/broadcast.mdx +4 -2
  4. package/site/content/docs/reference/configuration.mdx +16 -0
  5. package/site/content/docs/reference/errors.mdx +31 -19
  6. package/src/compiler/aot.test.ts +3 -2
  7. package/src/compiler/aot.ts +45 -1
  8. package/src/compiler/dynamic.ts +1 -1
  9. package/src/compiler/http-parse.test.ts +101 -0
  10. package/src/compiler/http-parse.ts +188 -9
  11. package/src/compiler/interpret.ts +25 -2
  12. package/src/console/ui-next/dist/assets/{access-page-C_N4qvai.js → access-page-zBMhZnFm.js} +1 -1
  13. package/src/console/ui-next/dist/assets/{agent-disclosure-Cq1TxniW.js → agent-disclosure-CBj9f7Ju.js} +1 -1
  14. package/src/console/ui-next/dist/assets/{cache-glyph-BaI4uxZz.js → cache-glyph-D3MY49h3.js} +1 -1
  15. package/src/console/ui-next/dist/assets/{call-pii-button-DNyK7IIY.js → call-pii-button-L9JxdBhp.js} +1 -1
  16. package/src/console/ui-next/dist/assets/{collapsible-CRhBJMZR.js → collapsible-BvFaX-Zt.js} +1 -1
  17. package/src/console/ui-next/dist/assets/{decisions-page-D9twj8Xy.js → decisions-page-DgB76m-X.js} +1 -1
  18. package/src/console/ui-next/dist/assets/{duration-tone-CsHWlVPU.js → duration-tone-l1DSJ2Vz.js} +1 -1
  19. package/src/console/ui-next/dist/assets/{flows-page-ZaTJAhR9.js → flows-page-Bb9t1lu6.js} +1 -1
  20. package/src/console/ui-next/dist/assets/{highlighted-json-DQ-EMAfz.js → highlighted-json-DMvsi3Ti.js} +1 -1
  21. package/src/console/ui-next/dist/assets/{http-method-Cn_Fl79s.js → http-method-BwhJBTcQ.js} +1 -1
  22. package/src/console/ui-next/dist/assets/{index-CmRFeWdT.js → index-RhV2jT_7.js} +3 -3
  23. package/src/console/ui-next/dist/assets/{observability-page-Cn6R_dXz.js → observability-page-BRfUILBq.js} +1 -1
  24. package/src/console/ui-next/dist/assets/{replica-lag-Bx6EZhSe.js → replica-lag-BsceAzIG.js} +1 -1
  25. package/src/console/ui-next/dist/assets/{request-meta-DmdiUrfh.js → request-meta-C6lTyCXp.js} +1 -1
  26. package/src/console/ui-next/dist/assets/{store-page-8P6vGXnn.js → store-page-YmE806bJ.js} +1 -1
  27. package/src/console/ui-next/dist/assets/{trace-detail-sheet-BfGvpFSv.js → trace-detail-sheet-Di4irS49.js} +1 -1
  28. package/src/console/ui-next/dist/assets/{tree-expand-toggle-CWdUHbc0.js → tree-expand-toggle-DYeL6Rye.js} +1 -1
  29. package/src/console/ui-next/dist/assets/{units-page-CZOFZzay.js → units-page-07agasRA.js} +1 -1
  30. package/src/console/ui-next/dist/assets/{vault-page-ki_ishmX.js → vault-page-CHBck4n_.js} +1 -1
  31. package/src/console/ui-next/dist/index.html +1 -1
  32. package/src/console/ui-next/seed-parked-approval.ts +5 -1
  33. package/src/drivers/journal-postgres.test.ts +84 -1
  34. package/src/drivers/journal-postgres.ts +282 -32
  35. package/src/drivers/postgres.test.ts +60 -0
  36. package/src/drivers/postgres.ts +79 -5
  37. package/src/drivers/signal-compete.test.ts +6 -2
  38. package/src/drivers/signal-postgres.test.ts +170 -0
  39. package/src/drivers/signal-postgres.ts +449 -196
  40. package/src/drivers/signal-redis.test.ts +224 -0
  41. package/src/drivers/signal-redis.ts +835 -150
  42. package/src/elements/ai/approval.test.ts +15 -3
  43. package/src/elements/ai/approval.ts +15 -9
  44. package/src/elements/channel/runtime.ts +31 -4
  45. package/src/elements/channel/sql-ledger.test.ts +404 -0
  46. package/src/elements/channel/sql-ledger.ts +177 -33
  47. package/src/elements/clock/chaos-child.ts +12 -2
  48. package/src/elements/clock/durable.ts +11 -0
  49. package/src/elements/store/cache-bus.test.ts +121 -0
  50. package/src/elements/store/cache-bus.ts +278 -0
  51. package/src/elements/store/cache.test.ts +59 -0
  52. package/src/elements/store/cache.ts +157 -12
  53. package/src/elements/store/runtime.ts +44 -2
  54. package/src/elements/store/sql-errors.test.ts +131 -0
  55. package/src/elements/store/sql-errors.ts +39 -16
  56. package/src/elements/store/sql-nested-tx.test.ts +344 -0
  57. package/src/elements/store/sql-session.ts +183 -3
  58. package/src/kernel/app.ts +176 -28
  59. package/src/kernel/auto-registry.test.ts +5 -1
  60. package/src/kernel/boot-bind/signal.ts +13 -2
  61. package/src/kernel/boot-bind/store.ts +16 -1
  62. package/src/kernel/boot.test.ts +2 -2
  63. package/src/kernel/boot.ts +41 -11
  64. package/src/kernel/boundary-contract.ts +7 -0
  65. package/src/kernel/builtin-errors.ts +2 -0
  66. package/src/kernel/call.test.ts +1 -0
  67. package/src/kernel/fx-sql-handle.ts +7 -1
  68. package/src/kernel/horizontal-child.ts +54 -1
  69. package/src/kernel/horizontal.integration.test.ts +131 -7
  70. package/src/kernel/idempotency.test.ts +5 -6
  71. package/src/kernel/journal-boot.test.ts +83 -6
  72. package/src/kernel/journal.test.ts +44 -0
  73. package/src/kernel/journal.ts +171 -26
  74. package/src/mcp/authorization.ts +5 -11
  75. package/src/mcp/confirmation.ts +203 -38
  76. package/src/mcp/docs-server.ts +7 -2
  77. package/src/mcp/index.ts +4 -0
  78. package/src/mcp/mcp.test.ts +321 -33
  79. package/src/mcp/server.ts +8 -3
  80. package/src/mcp/session.ts +7 -0
  81. package/src/mcp/tools.ts +68 -19
  82. package/src/runtime/bun.ts +5 -0
  83. package/src/runtime/types.ts +5 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okengine",
3
- "version": "0.23.1",
3
+ "version": "0.23.2",
4
4
  "description": "One law. Eight elements. One contract. The backend model stays small; operational surfaces are derived from it instead of maintained separately.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -52,16 +52,16 @@ Two actions are write-class, and each call needs a **fresh, single-use** confirm
52
52
  <Steps>
53
53
 
54
54
  <Step>
55
- ### Request a confirmation token
55
+ ### Ask for confirmation
56
56
 
57
- `oke.action.confirm` with the target `tool`, the exact `args`, and a human `reason` — it returns a single-use token.
57
+ The write returns an opaque `confirmationId`. It does not return a token. Confirm from a **different auth session** (`claims.sid`) than the session that will invoke. The same principal is allowed. A confirmation issued on another process is unknown.
58
58
 
59
59
  </Step>
60
60
 
61
61
  <Step>
62
62
  ### Call the write tool
63
63
 
64
- Pass the token as `confirmToken` plus the phrase `CONFIRM` in `confirmation`. Token, phrase, args, and principal must all match what was confirmed.
64
+ `oke.action.confirm(confirmationId, reason)` returns a single-use token. Pass it as `confirmToken` plus the phrase `CONFIRM`. The invoking session must be the one that asked, and it must not be the session that confirmed.
65
65
 
66
66
  </Step>
67
67
 
@@ -12,8 +12,10 @@ For developers invalidating caches or syncing in-process state — declare the S
12
12
  more `on(signal, flow)` subscribers, emit with `fx.emit`.
13
13
 
14
14
  <Callout title="The one rule">
15
- Broadcast is ephemeral. If a subscriber is offline or restarts during the emit, it does not
16
- receive past events. Use [`live`](/docs/elements/signal/live) when clients need replay. Use
15
+ Broadcast is ephemeral. Each running instance handles a message once. If a subscriber is offline
16
+ during the emit, it does not receive that event. Postgres delivery is best-effort within `lagMs`
17
+ (default 30 seconds): a transaction that commits later than that can be missed. Use
18
+ [`live`](/docs/elements/signal/live) when clients need replay. Use
17
19
  [`once`](/docs/elements/signal/once) when exactly one worker must claim the job.
18
20
  </Callout>
19
21
 
@@ -237,6 +237,22 @@ Domain schema sync for `oke db push | generate | migrate` (Drizzle). Unrelated t
237
237
  | `console` | `6533` | Console |
238
238
  | `mcp` | `6535` | MCP |
239
239
 
240
+ ## App options
241
+
242
+ These sit on `oke({ ... })`, not in `oke.config.ts`.
243
+
244
+ | Option | Default | Meaning |
245
+ | -------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
246
+ | `codeVersion` | unset | Opt-in durable stamp `app:<version>`. Unset checks nothing. A stored `0.23.1`-style stamp is compatible and restamped. A different `app:` stamp fails the run with OKE1076. |
247
+ | `maxRequestBodySize` | `1048576` | Body cap in bytes. A route `maxBodySize` overrides it. Bun's listen backstop uses the same number. |
248
+ | `cache.maxEntries` | `10000` | Auto-cache entry cap. |
249
+ | `cache.defaultTtlMs` | `60000` | Auto-cache TTL when the flow does not set `cache: "5m"`. |
250
+ | `cache.auto` | on | `false` turns automatic caching off. A flow can still opt in. |
251
+
252
+ Postgres broadcast and live poll with `lagMs`, default `30000`. That window is best-effort, not a gap-free cursor.
253
+
254
+ A write drops the auto-cache on every instance. With Redis configured, the notice is published on `oke:cache:invalidate`. Otherwise a Postgres app polls `oke_cache_invalidations`. One process does not broadcast.
255
+
240
256
  ## console
241
257
 
242
258
  | Option | Type | Default | Meaning |
@@ -117,25 +117,37 @@ The typed client always includes built-in codes. A declared key still wins.
117
117
  A bare `404` with body `Not Found` means **no route matched**. `fx.fail.notFound` is a JSON envelope
118
118
  at **404**. Clients distinguish by envelope, not status. Domain codes stay **400**.
119
119
 
120
- | Code | Status | Helper |
121
- | ------------------------------------------------ | ----------- | ------------------------------------------------------ |
122
- | `ValidationError` | `422` | none — failed `in`, `do` never runs |
123
- | `IdempotencyKeyMissing` | `400` | none — `idempotency: "required"` and no header |
124
- | `IdempotencyKeyInvalid` | `400` | none — header is not 16–255 printable ASCII |
125
- | `IdempotencyKeyReused` | `422` | none — same key, different payload |
126
- | `IdempotencyInProgress` | `409` | none — `Retry-After` until the in-flight call finishes |
127
- | `Unauthorized` | `401` | `unauthorized` · also a gate denial |
128
- | `Forbidden` | `403` | `forbidden` · also a gate denial |
129
- | `NotFound` | `404` | `notFound` |
130
- | `Conflict` / `ForeignKey` | `409` | `conflict` / `foreignKey` |
131
- | `UnsupportedMediaType` | `415` | none — QUERY `Content-Type` is not `application/json` |
132
- | `RateLimited` / `AuthRateLimited` | `429` | `rateLimited` · `AuthRateLimited` has none |
133
- | `InvalidQuery` | `400` | none — QUERY missing `Content-Type` or body isn't JSON |
134
- | `AuthFailed` | `400` | none — credentials / policy, not “no session” |
135
- | `DatabaseError` | by `reason` | `database` |
136
- | `ServiceUnavailable` | `503` | `serviceUnavailable` |
137
- | `InternalError` | `500` | `internal` — never copies a thrown `message` |
138
- | Domain (`OutOfStock`, `FlightFull`, `Duplicate`) | `400` | `fx.fail("OutOfStock", data)` |
120
+ | Code | Status | Helper |
121
+ | ------------------------------------------------ | ----------- | ---------------------------------------------------------------------------- |
122
+ | `ValidationError` | `422` | none — failed `in`, `do` never runs |
123
+ | `IdempotencyKeyMissing` | `400` | none — `idempotency: "required"` and no header |
124
+ | `IdempotencyKeyInvalid` | `400` | none — header is not 16–255 printable ASCII |
125
+ | `IdempotencyKeyReused` | `422` | none — same key, different payload |
126
+ | `IdempotencyInProgress` | `409` | none — `Retry-After` until the in-flight call finishes |
127
+ | `Unauthorized` | `401` | `unauthorized` · also a gate denial |
128
+ | `Forbidden` | `403` | `forbidden` · also a gate denial |
129
+ | `NotFound` | `404` | `notFound` |
130
+ | `Conflict` / `ForeignKey` | `409` | `conflict` / `foreignKey` |
131
+ | `UnsupportedMediaType` | `415` | none — QUERY, or a JSON-shaped body that is not `application/json` / `+json` |
132
+ | `PayloadTooLarge` | `413` | none — body exceeds `maxRequestBodySize` or the route `maxBodySize` |
133
+ | `RateLimited` / `AuthRateLimited` | `429` | `rateLimited` · `AuthRateLimited` has none |
134
+ | `InvalidQuery` | `400` | none — QUERY or JSON body that does not parse (`malformed_body`) |
135
+ | `AuthFailed` | `400` | none — credentials / policy, not “no session” |
136
+ | `DatabaseError` | by `reason` | `database` |
137
+ | `ServiceUnavailable` | `503` | `serviceUnavailable` |
138
+ | `InternalError` | `500` | `internal` — never copies a thrown `message` |
139
+ | Domain (`OutOfStock`, `FlightFull`, `Duplicate`) | `400` | `fx.fail("OutOfStock", data)` |
140
+
141
+ `UnsupportedMediaType` (415) is this rule, for a non-empty body. Let `mt` be the media type before `;`:
142
+
143
+ 1. `application/json` or `application/*+json` is parsed. A parse failure is 400 `InvalidQuery` with reason `malformed_body`.
144
+ 2. A route with `jsonContentType: "any"` tries JSON and otherwise keeps the raw string.
145
+ 3. `text/plain`, form-urlencoded, multipart, or a missing type, when the trimmed body starts with `{` or `[` and parses as JSON, is 415.
146
+ 4. Anything else is the raw string. `curl -d` needs `-H 'content-type: application/json'`.
147
+
148
+ `OKE1074` means the durable run lost its lease. The step result is not written and compensation does not run. The caller sees a busy lease, not a new failure of the work.
149
+
150
+ `OKE1076` means `codeVersion` was set and the stored stamp is a different `app:` value. Leave the old value pinned until sleeping runs drain, then change it. Unset `codeVersion` checks nothing. A stored stamp from 0.23.1 (no `app:` prefix) is restamped, not failed.
139
151
 
140
152
  ### Client chrome
141
153
 
@@ -97,6 +97,7 @@ describe("compileAot", () => {
97
97
  const bad = await compiled.parseValidate(
98
98
  new Request("http://localhost/bookings", {
99
99
  method: "POST",
100
+ headers: { "content-type": "application/json" },
100
101
  body: JSON.stringify({ flightId: "SK1", seats: 0 }),
101
102
  }),
102
103
  {},
@@ -235,9 +236,9 @@ describe("AoT throughput ≥ 1.5× dynamic", () => {
235
236
  }
236
237
 
237
238
  const iterations = 4_000;
238
- // Best of trials — single wall-clock ratio is noisy under full-suite load.
239
+ // Best of trials — a single wall-clock ratio moves under load.
239
240
  let best = 0;
240
- for (let trial = 0; trial < 3; trial++) {
241
+ for (let trial = 0; trial < 8; trial++) {
241
242
  const t0 = performance.now();
242
243
  for (let i = 0; i < iterations; i++) {
243
244
  await aot.parseValidate(makeReq(), {});
@@ -6,9 +6,11 @@
6
6
  * {@link compileDynamic} / `aot: false` on edge runtimes that ban `eval`.
7
7
  */
8
8
 
9
+ import { fail } from "../kernel/errors.ts";
9
10
  import { validate, type SchemaInput } from "../validation/standard-schema.ts";
10
11
  import {
11
12
  assembleInput,
13
+ HttpBodyRejected,
12
14
  parseBody,
13
15
  parseCookie,
14
16
  parseHeaders,
@@ -33,6 +35,8 @@ export interface CompileRouteOptions {
33
35
  readonly hooks?: ReadonlyArray<(...args: never[]) => unknown>;
34
36
  /** Input schema (Standard Schema when present). */
35
37
  readonly schema?: SchemaInput | undefined;
38
+ /** Body cap and JSON content-type policy. */
39
+ readonly body?: import("./http-parse.ts").ParseBodyOptions;
36
40
  }
37
41
 
38
42
  /** Bundle returned by the compilers. */
@@ -55,6 +59,16 @@ interface AotHelpers {
55
59
  parseCookie: typeof parseCookie;
56
60
  assembleInput: typeof assembleInput;
57
61
  validate: typeof validate;
62
+ readonly body?: import("./http-parse.ts").ParseBodyOptions;
63
+ readBody: (
64
+ request: Request,
65
+ ) => Promise<
66
+ { ok: true; value: unknown } | { ok: false; failure: import("../kernel/errors.ts").FlowFailure }
67
+ >;
68
+ bodyFailure: (
69
+ err: unknown,
70
+ request: Request,
71
+ ) => { ok: false; failure: import("../kernel/errors.ts").FlowFailure } | undefined;
58
72
  }
59
73
 
60
74
  /**
@@ -82,6 +96,34 @@ export function compileAot(options: CompileRouteOptions): CompiledRoute {
82
96
  parseCookie,
83
97
  assembleInput,
84
98
  validate,
99
+ body: options.body,
100
+ async readBody(request: Request) {
101
+ try {
102
+ return { ok: true as const, value: await parseBody(request, options.body) };
103
+ } catch (err) {
104
+ const rejected = this.bodyFailure(err, request);
105
+ if (rejected) return rejected;
106
+ throw err;
107
+ }
108
+ },
109
+ bodyFailure(err, request) {
110
+ if (!(err instanceof HttpBodyRejected)) return undefined;
111
+ if (err.code === "InvalidQuery") {
112
+ return {
113
+ ok: false,
114
+ failure: fail("InvalidQuery", { reason: err.reason ?? "malformed_body" }),
115
+ };
116
+ }
117
+ if (err.code === "UnsupportedMediaType") {
118
+ return {
119
+ ok: false,
120
+ failure: fail("UnsupportedMediaType", {
121
+ contentType: request.headers.get("content-type") ?? "",
122
+ }),
123
+ };
124
+ }
125
+ return { ok: false, failure: fail("PayloadTooLarge", {}) };
126
+ },
85
127
  };
86
128
 
87
129
  try {
@@ -122,7 +164,9 @@ function generateParseValidate(
122
164
  lines.push("parts.cookie = helpers.parseCookie(request);");
123
165
  }
124
166
  if (inference.body) {
125
- lines.push("parts.body = await helpers.parseBody(request);");
167
+ lines.push("const bodyResult = await helpers.readBody(request);");
168
+ lines.push("if (bodyResult.ok === false) return bodyResult;");
169
+ lines.push("parts.body = bodyResult.value;");
126
170
  }
127
171
 
128
172
  lines.push("const raw = helpers.assembleInput(parts);");
@@ -28,7 +28,7 @@ function loadAot(): typeof import("./aot.ts") {
28
28
  export function compileDynamic(options: CompileRouteOptions): CompiledRoute {
29
29
  return {
30
30
  inference: FULL_INFERENCE,
31
- parseValidate: createInterpretedParseValidate(FULL_INFERENCE, options.schema),
31
+ parseValidate: createInterpretedParseValidate(FULL_INFERENCE, options.schema, options.body),
32
32
  aot: false,
33
33
  };
34
34
  }
@@ -0,0 +1,101 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { assembleInput, HttpBodyRejected, parseBody } from "./http-parse.ts";
4
+
5
+ function request(body: string, contentType?: string, length?: string): Request {
6
+ const headers = new Headers();
7
+ if (contentType) headers.set("content-type", contentType);
8
+ if (length) headers.set("content-length", length);
9
+ return new Request("http://localhost/hooks", { method: "POST", body, headers });
10
+ }
11
+
12
+ describe("request bodies", () => {
13
+ test("rejects an overstated and an honest body over the cap", async () => {
14
+ await expect(
15
+ parseBody(request("{}", "application/json", "99"), { maxBytes: 4 }),
16
+ ).rejects.toBeInstanceOf(HttpBodyRejected);
17
+ await expect(
18
+ parseBody(request("{}", "application/json"), { maxBytes: 1 }),
19
+ ).rejects.toMatchObject({
20
+ code: "PayloadTooLarge",
21
+ });
22
+ });
23
+
24
+ test("sendBeacon text/plain JSON is 415 unless the route opts out", async () => {
25
+ const beacon = request('{"n":1}', "text/plain;charset=UTF-8");
26
+ await expect(parseBody(beacon)).rejects.toMatchObject({ code: "UnsupportedMediaType" });
27
+ await expect(
28
+ parseBody(request('{"n":1}', "text/plain"), { jsonContentType: "any" }),
29
+ ).resolves.toEqual({
30
+ n: 1,
31
+ });
32
+ });
33
+
34
+ test("a form body that parses as JSON is 415, and a normal form body stays a string", async () => {
35
+ await expect(
36
+ parseBody(request("{not json", "application/x-www-form-urlencoded")),
37
+ ).resolves.toBe("{not json");
38
+ await expect(
39
+ parseBody(request('{"a":1}', "application/x-www-form-urlencoded")),
40
+ ).rejects.toMatchObject({ code: "UnsupportedMediaType" });
41
+ await expect(parseBody(request("a=1&b=2", "application/x-www-form-urlencoded"))).resolves.toBe(
42
+ "a=1&b=2",
43
+ );
44
+ });
45
+
46
+ test("application/json must parse, and +json is accepted", async () => {
47
+ await expect(parseBody(request("{", "application/json"))).rejects.toMatchObject({
48
+ code: "InvalidQuery",
49
+ reason: "malformed_body",
50
+ });
51
+ await expect(
52
+ parseBody(request('{"n":1}', "application/vnd.x+json; charset=utf-8")),
53
+ ).resolves.toEqual({ n: 1 });
54
+ });
55
+
56
+ test("drops __proto__ and keeps a constructor field", async () => {
57
+ const parsed = await parseBody(
58
+ request('{"__proto__":{"isAdmin":true},"constructor":"ok"}', "application/json"),
59
+ );
60
+ expect((parsed as { isAdmin?: boolean; constructor?: string }).isAdmin).toBeUndefined();
61
+ expect((parsed as { constructor?: string }).constructor).toBe("ok");
62
+ expect(Object.prototype).not.toHaveProperty("isAdmin");
63
+ const input = assembleInput({ body: parsed });
64
+ expect((input as { isAdmin?: boolean }).isAdmin).toBeUndefined();
65
+
66
+ const escaped = await parseBody(
67
+ request('{"\\u005f\\u005fproto\\u005f\\u005f":{"isAdmin":true},"a":1}', "application/json"),
68
+ );
69
+ expect((escaped as { a?: number; isAdmin?: boolean }).a).toBe(1);
70
+ expect((escaped as { isAdmin?: boolean }).isAdmin).toBeUndefined();
71
+ });
72
+
73
+ test("a tighter cap cancels a chunked body instead of buffering it", async () => {
74
+ let pulled = 0;
75
+ const stream = new ReadableStream<Uint8Array>({
76
+ pull(controller) {
77
+ pulled += 1;
78
+ if (pulled > 4) {
79
+ controller.close();
80
+ return;
81
+ }
82
+ controller.enqueue(new Uint8Array(8));
83
+ },
84
+ });
85
+ const req = new Request("http://localhost/hooks", {
86
+ method: "POST",
87
+ body: stream,
88
+ headers: { "content-type": "application/octet-stream" },
89
+ });
90
+ await expect(parseBody(req, { maxBytes: 8 })).rejects.toMatchObject({
91
+ code: "PayloadTooLarge",
92
+ });
93
+ expect(pulled).toBeLessThan(4);
94
+ });
95
+
96
+ test("an empty body is undefined", async () => {
97
+ await expect(
98
+ parseBody(new Request("http://localhost/", { method: "DELETE" })),
99
+ ).resolves.toBeUndefined();
100
+ });
101
+ });
@@ -77,19 +77,193 @@ export function pathParamNames(path: string): string[] {
77
77
  return names;
78
78
  }
79
79
 
80
+ /** Default request body cap (1 MiB). */
81
+ export const DEFAULT_MAX_BODY_BYTES = 1_048_576;
82
+
83
+ /** A body the route refused before the flow ran. */
84
+ export class HttpBodyRejected extends Error {
85
+ /** Builtin failure code. */
86
+ readonly code: "PayloadTooLarge" | "UnsupportedMediaType" | "InvalidQuery";
87
+ /** `InvalidQuery` reason, when set. */
88
+ readonly reason?: string;
89
+
90
+ /**
91
+ * @param code - Builtin failure code
92
+ * @param reason - Optional InvalidQuery reason
93
+ */
94
+ constructor(code: "PayloadTooLarge" | "UnsupportedMediaType" | "InvalidQuery", reason?: string) {
95
+ super(code);
96
+ this.name = "HttpBodyRejected";
97
+ this.code = code;
98
+ if (reason !== undefined) this.reason = reason;
99
+ }
100
+ }
101
+
102
+ /** Per-route body policy. */
103
+ export interface ParseBodyOptions {
104
+ /** Byte cap. Default {@link DEFAULT_MAX_BODY_BYTES}. */
105
+ readonly maxBytes?: number;
106
+ /** Default `required`. `any` keeps today's parse-or-raw-string behavior. */
107
+ readonly jsonContentType?: "required" | "any";
108
+ }
109
+
110
+ function mediaType(request: Request): string {
111
+ const raw = request.headers.get("content-type");
112
+ if (!raw) return "";
113
+ return (raw.split(";")[0] ?? "").trim().toLowerCase();
114
+ }
115
+
116
+ function parseJson(text: string): unknown {
117
+ const value = JSON.parse(text) as unknown;
118
+ // Own `__proto__` keys are rare. Skip the clone unless the text can name one,
119
+ // including a `\u` escape of that key.
120
+ if (!text.includes("__proto__") && !text.includes("\\u")) return value;
121
+ return dropProto(value);
122
+ }
123
+
124
+ function dropProto(value: unknown): unknown {
125
+ if (Array.isArray(value)) return value.map((item) => dropProto(item));
126
+ if (value !== null && typeof value === "object") {
127
+ const out: Record<string, unknown> = {};
128
+ for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
129
+ if (key === "__proto__") continue;
130
+ out[key] = dropProto(item);
131
+ }
132
+ return out;
133
+ }
134
+ return value;
135
+ }
136
+
137
+ const textDecoder = new TextDecoder();
138
+
139
+ async function readDeclared(request: Request, max: number): Promise<string> {
140
+ const text = await request.text();
141
+ if (byteLength(text) > max) throw new HttpBodyRejected("PayloadTooLarge");
142
+ return text;
143
+ }
144
+
145
+ function byteLength(text: string): number {
146
+ for (let i = 0; i < text.length; i++) {
147
+ if (text.charCodeAt(i) > 0x7f) return new TextEncoder().encode(text).byteLength;
148
+ }
149
+ return text.length;
150
+ }
151
+
152
+ function decode(bytes: Uint8Array): string {
153
+ return textDecoder.decode(bytes);
154
+ }
155
+
80
156
  /**
81
- * Parse JSON / text body. Empty body → `undefined`.
157
+ * Read a body with no usable Content-Length.
158
+ *
159
+ * At the default cap, `Bun.serve` already stops the socket at the same size,
160
+ * so this uses the native buffered read and then checks the real length.
161
+ * A tighter per-route cap still counts chunks and cancels the stream.
162
+ */
163
+ async function readLimited(request: Request, max: number): Promise<string> {
164
+ if (max >= DEFAULT_MAX_BODY_BYTES) {
165
+ const text = await request.text();
166
+ if (byteLength(text) > max) throw new HttpBodyRejected("PayloadTooLarge");
167
+ return text;
168
+ }
169
+ const body = request.body;
170
+ if (!body) {
171
+ const text = await request.text();
172
+ if (byteLength(text) > max) throw new HttpBodyRejected("PayloadTooLarge");
173
+ return text;
174
+ }
175
+ const reader = body.getReader();
176
+ const first = await reader.read();
177
+ if (first.done) return "";
178
+ if (first.value.byteLength > max) {
179
+ await reader.cancel();
180
+ throw new HttpBodyRejected("PayloadTooLarge");
181
+ }
182
+ const second = await reader.read();
183
+ if (second.done) return first.value.byteLength === 0 ? "" : decode(first.value);
184
+
185
+ const chunks: Uint8Array[] = [first.value, second.value];
186
+ let total = first.value.byteLength + second.value.byteLength;
187
+ if (total > max) {
188
+ await reader.cancel();
189
+ throw new HttpBodyRejected("PayloadTooLarge");
190
+ }
191
+ for (;;) {
192
+ const step = await reader.read();
193
+ if (step.done) break;
194
+ total += step.value.byteLength;
195
+ if (total > max) {
196
+ await reader.cancel();
197
+ throw new HttpBodyRejected("PayloadTooLarge");
198
+ }
199
+ chunks.push(step.value);
200
+ }
201
+ const bytes = new Uint8Array(total);
202
+ let offset = 0;
203
+ for (const chunk of chunks) {
204
+ bytes.set(chunk, offset);
205
+ offset += chunk.byteLength;
206
+ }
207
+ return decode(bytes);
208
+ }
209
+
210
+ /**
211
+ * Parse a request body.
212
+ *
213
+ * Empty bodies are `undefined`. JSON content types must parse. A JSON-shaped
214
+ * body sent as text, form, or with no content type is 415 unless the route
215
+ * opts out. `__proto__` keys are dropped.
82
216
  *
83
217
  * @param request - Incoming request
218
+ * @param options - Cap and content-type policy
84
219
  */
85
- export async function parseBody(request: Request): Promise<unknown> {
86
- const text = await request.text();
220
+ export async function parseBody(request: Request, options?: ParseBodyOptions): Promise<unknown> {
221
+ const max = options?.maxBytes ?? DEFAULT_MAX_BODY_BYTES;
222
+ const declared = request.headers.get("content-length");
223
+ if (declared !== null && declared !== "" && Number(declared) > max) {
224
+ throw new HttpBodyRejected("PayloadTooLarge");
225
+ }
226
+ // A declared length inside the cap is buffered by the platform. Check the
227
+ // real byte length afterwards so an understated Content-Length is still 413.
228
+ // A missing length uses the native buffer at the default cap (the serve
229
+ // socket already stops there) and a cancelling read for a tighter cap.
230
+ const text =
231
+ declared !== null && declared !== "" && Number(declared) <= max
232
+ ? await readDeclared(request, max)
233
+ : await readLimited(request, max);
87
234
  if (text.length === 0) return undefined;
88
- try {
89
- return JSON.parse(text) as unknown;
90
- } catch {
91
- return text;
235
+ const mt = mediaType(request);
236
+ const jsonType =
237
+ mt === "application/json" || (mt.endsWith("+json") && mt.length > "+json".length);
238
+ if (jsonType) {
239
+ try {
240
+ return parseJson(text);
241
+ } catch {
242
+ throw new HttpBodyRejected("InvalidQuery", "malformed_body");
243
+ }
244
+ }
245
+ if (options?.jsonContentType === "any") {
246
+ try {
247
+ return parseJson(text);
248
+ } catch {
249
+ return text;
250
+ }
251
+ }
252
+ const loose =
253
+ mt === "" ||
254
+ mt === "text/plain" ||
255
+ mt === "application/x-www-form-urlencoded" ||
256
+ mt === "multipart/form-data";
257
+ const trimmed = text.trim();
258
+ if (loose && (trimmed.startsWith("{") || trimmed.startsWith("["))) {
259
+ try {
260
+ JSON.parse(trimmed);
261
+ throw new HttpBodyRejected("UnsupportedMediaType");
262
+ } catch (err) {
263
+ if (err instanceof HttpBodyRejected) throw err;
264
+ }
92
265
  }
266
+ return text;
93
267
  }
94
268
 
95
269
  /**
@@ -158,7 +332,11 @@ export function assembleInput(parts: InputParts): unknown {
158
332
 
159
333
  if (parts.body !== undefined) {
160
334
  if (typeof parts.body === "object" && parts.body !== null && !Array.isArray(parts.body)) {
161
- Object.assign(out, parts.body as Record<string, unknown>);
335
+ const body = parts.body as Record<string, unknown>;
336
+ for (const [key, value] of Object.entries(body)) {
337
+ if (key === "__proto__") continue;
338
+ out[key] = value;
339
+ }
162
340
  } else {
163
341
  out.body = parts.body;
164
342
  }
@@ -188,6 +366,7 @@ export async function extractParts(
188
366
  request: Request,
189
367
  params: Readonly<Record<string, string>>,
190
368
  inference: ContextInference,
369
+ options?: { readonly body?: ParseBodyOptions },
191
370
  ): Promise<InputParts> {
192
371
  const parts: {
193
372
  params?: Record<string, string>;
@@ -201,7 +380,7 @@ export async function extractParts(
201
380
  if (inference.query) parts.query = parseQuery(request);
202
381
  if (inference.headers) parts.headers = parseHeaders(request);
203
382
  if (inference.cookie) parts.cookie = parseCookie(request);
204
- if (inference.body) parts.body = await parseBody(request);
383
+ if (inference.body) parts.body = await parseBody(request, options?.body);
205
384
 
206
385
  return parts;
207
386
  }
@@ -5,13 +5,15 @@
5
5
  * requested, so eval-restricted runtimes do not carry the codegen.
6
6
  */
7
7
 
8
- import type { FlowFailure } from "../kernel/errors.ts";
8
+ import { fail, type FlowFailure } from "../kernel/errors.ts";
9
9
  import { validate, type SchemaInput } from "../validation/standard-schema.ts";
10
10
  import {
11
11
  assembleInput,
12
12
  extractParts,
13
+ HttpBodyRejected,
13
14
  type ContextInference,
14
15
  type InputParts,
16
+ type ParseBodyOptions,
15
17
  } from "./http-parse.ts";
16
18
 
17
19
  /** Result of parse + validate for one request. */
@@ -34,9 +36,30 @@ export type CompiledParseValidate = (
34
36
  export function createInterpretedParseValidate(
35
37
  inference: ContextInference,
36
38
  schema: SchemaInput | undefined,
39
+ body?: ParseBodyOptions,
37
40
  ): CompiledParseValidate {
38
41
  return async (request, params) => {
39
- const parts: InputParts = await extractParts(request, params, inference);
42
+ let parts: InputParts;
43
+ try {
44
+ parts = await extractParts(request, params, inference, { body });
45
+ } catch (err) {
46
+ if (!(err instanceof HttpBodyRejected)) throw err;
47
+ if (err.code === "InvalidQuery") {
48
+ return {
49
+ ok: false,
50
+ failure: fail("InvalidQuery", { reason: err.reason ?? "malformed_body" }),
51
+ };
52
+ }
53
+ if (err.code === "UnsupportedMediaType") {
54
+ return {
55
+ ok: false,
56
+ failure: fail("UnsupportedMediaType", {
57
+ contentType: request.headers.get("content-type") ?? "",
58
+ }),
59
+ };
60
+ }
61
+ return { ok: false, failure: fail("PayloadTooLarge", {}) };
62
+ }
40
63
  const raw = assembleInput(parts);
41
64
  const result = await validate(schema, raw);
42
65
  if (!result.ok) return { ok: false, failure: result.failure };