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.
- package/package.json +1 -1
- package/site/content/docs/ai/mcp.mdx +3 -3
- package/site/content/docs/elements/signal/broadcast.mdx +4 -2
- package/site/content/docs/reference/configuration.mdx +16 -0
- package/site/content/docs/reference/errors.mdx +31 -19
- package/src/compiler/aot.test.ts +3 -2
- package/src/compiler/aot.ts +45 -1
- package/src/compiler/dynamic.ts +1 -1
- package/src/compiler/http-parse.test.ts +101 -0
- package/src/compiler/http-parse.ts +188 -9
- package/src/compiler/interpret.ts +25 -2
- package/src/console/ui-next/dist/assets/{access-page-C_N4qvai.js → access-page-zBMhZnFm.js} +1 -1
- package/src/console/ui-next/dist/assets/{agent-disclosure-Cq1TxniW.js → agent-disclosure-CBj9f7Ju.js} +1 -1
- package/src/console/ui-next/dist/assets/{cache-glyph-BaI4uxZz.js → cache-glyph-D3MY49h3.js} +1 -1
- package/src/console/ui-next/dist/assets/{call-pii-button-DNyK7IIY.js → call-pii-button-L9JxdBhp.js} +1 -1
- package/src/console/ui-next/dist/assets/{collapsible-CRhBJMZR.js → collapsible-BvFaX-Zt.js} +1 -1
- package/src/console/ui-next/dist/assets/{decisions-page-D9twj8Xy.js → decisions-page-DgB76m-X.js} +1 -1
- package/src/console/ui-next/dist/assets/{duration-tone-CsHWlVPU.js → duration-tone-l1DSJ2Vz.js} +1 -1
- package/src/console/ui-next/dist/assets/{flows-page-ZaTJAhR9.js → flows-page-Bb9t1lu6.js} +1 -1
- package/src/console/ui-next/dist/assets/{highlighted-json-DQ-EMAfz.js → highlighted-json-DMvsi3Ti.js} +1 -1
- package/src/console/ui-next/dist/assets/{http-method-Cn_Fl79s.js → http-method-BwhJBTcQ.js} +1 -1
- package/src/console/ui-next/dist/assets/{index-CmRFeWdT.js → index-RhV2jT_7.js} +3 -3
- package/src/console/ui-next/dist/assets/{observability-page-Cn6R_dXz.js → observability-page-BRfUILBq.js} +1 -1
- package/src/console/ui-next/dist/assets/{replica-lag-Bx6EZhSe.js → replica-lag-BsceAzIG.js} +1 -1
- package/src/console/ui-next/dist/assets/{request-meta-DmdiUrfh.js → request-meta-C6lTyCXp.js} +1 -1
- package/src/console/ui-next/dist/assets/{store-page-8P6vGXnn.js → store-page-YmE806bJ.js} +1 -1
- package/src/console/ui-next/dist/assets/{trace-detail-sheet-BfGvpFSv.js → trace-detail-sheet-Di4irS49.js} +1 -1
- package/src/console/ui-next/dist/assets/{tree-expand-toggle-CWdUHbc0.js → tree-expand-toggle-DYeL6Rye.js} +1 -1
- package/src/console/ui-next/dist/assets/{units-page-CZOFZzay.js → units-page-07agasRA.js} +1 -1
- package/src/console/ui-next/dist/assets/{vault-page-ki_ishmX.js → vault-page-CHBck4n_.js} +1 -1
- package/src/console/ui-next/dist/index.html +1 -1
- package/src/console/ui-next/seed-parked-approval.ts +5 -1
- package/src/drivers/journal-postgres.test.ts +84 -1
- package/src/drivers/journal-postgres.ts +282 -32
- package/src/drivers/postgres.test.ts +60 -0
- package/src/drivers/postgres.ts +79 -5
- package/src/drivers/signal-compete.test.ts +6 -2
- package/src/drivers/signal-postgres.test.ts +170 -0
- package/src/drivers/signal-postgres.ts +449 -196
- package/src/drivers/signal-redis.test.ts +224 -0
- package/src/drivers/signal-redis.ts +835 -150
- package/src/elements/ai/approval.test.ts +15 -3
- package/src/elements/ai/approval.ts +15 -9
- package/src/elements/channel/runtime.ts +31 -4
- package/src/elements/channel/sql-ledger.test.ts +404 -0
- package/src/elements/channel/sql-ledger.ts +177 -33
- package/src/elements/clock/chaos-child.ts +12 -2
- package/src/elements/clock/durable.ts +11 -0
- package/src/elements/store/cache-bus.test.ts +121 -0
- package/src/elements/store/cache-bus.ts +278 -0
- package/src/elements/store/cache.test.ts +59 -0
- package/src/elements/store/cache.ts +157 -12
- package/src/elements/store/runtime.ts +44 -2
- package/src/elements/store/sql-errors.test.ts +131 -0
- package/src/elements/store/sql-errors.ts +39 -16
- package/src/elements/store/sql-nested-tx.test.ts +344 -0
- package/src/elements/store/sql-session.ts +183 -3
- package/src/kernel/app.ts +176 -28
- package/src/kernel/auto-registry.test.ts +5 -1
- package/src/kernel/boot-bind/signal.ts +13 -2
- package/src/kernel/boot-bind/store.ts +16 -1
- package/src/kernel/boot.test.ts +2 -2
- package/src/kernel/boot.ts +41 -11
- package/src/kernel/boundary-contract.ts +7 -0
- package/src/kernel/builtin-errors.ts +2 -0
- package/src/kernel/call.test.ts +1 -0
- package/src/kernel/fx-sql-handle.ts +7 -1
- package/src/kernel/horizontal-child.ts +54 -1
- package/src/kernel/horizontal.integration.test.ts +131 -7
- package/src/kernel/idempotency.test.ts +5 -6
- package/src/kernel/journal-boot.test.ts +83 -6
- package/src/kernel/journal.test.ts +44 -0
- package/src/kernel/journal.ts +171 -26
- package/src/mcp/authorization.ts +5 -11
- package/src/mcp/confirmation.ts +203 -38
- package/src/mcp/docs-server.ts +7 -2
- package/src/mcp/index.ts +4 -0
- package/src/mcp/mcp.test.ts +321 -33
- package/src/mcp/server.ts +8 -3
- package/src/mcp/session.ts +7 -0
- package/src/mcp/tools.ts +68 -19
- package/src/runtime/bun.ts +5 -0
- 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.
|
|
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
|
-
###
|
|
55
|
+
### Ask for confirmation
|
|
56
56
|
|
|
57
|
-
`
|
|
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
|
-
|
|
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.
|
|
16
|
-
receive
|
|
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
|
|
132
|
-
| `
|
|
133
|
-
| `
|
|
134
|
-
| `
|
|
135
|
-
| `
|
|
136
|
-
| `
|
|
137
|
-
| `
|
|
138
|
-
|
|
|
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
|
|
package/src/compiler/aot.test.ts
CHANGED
|
@@ -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
|
|
239
|
+
// Best of trials — a single wall-clock ratio moves under load.
|
|
239
240
|
let best = 0;
|
|
240
|
-
for (let trial = 0; 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(), {});
|
package/src/compiler/aot.ts
CHANGED
|
@@ -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("
|
|
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);");
|
package/src/compiler/dynamic.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 };
|