stitchkit 0.46.0 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +4 -3
  2. package/dist/browser/cancellation.d.ts +14 -0
  3. package/dist/browser/cancellation.d.ts.map +1 -0
  4. package/dist/browser/client-multipart.d.ts +3 -1
  5. package/dist/browser/client-multipart.d.ts.map +1 -1
  6. package/dist/browser/client.d.ts +1 -0
  7. package/dist/browser/client.d.ts.map +1 -1
  8. package/dist/browser/http.d.ts +1 -0
  9. package/dist/browser/http.d.ts.map +1 -1
  10. package/dist/cli.js +2 -2
  11. package/dist/contract/define.d.ts +59 -15
  12. package/dist/contract/define.d.ts.map +1 -1
  13. package/dist/contract/index.d.ts +1 -1
  14. package/dist/contract/index.d.ts.map +1 -1
  15. package/dist/contract/index.js +1 -1
  16. package/dist/{index-zwqty9zf.js → index-44xysy8r.js} +465 -49
  17. package/dist/{index-pwyedf7b.js → index-45dz4m51.js} +46 -0
  18. package/dist/{index-c40tkxcd.js → index-8ekq6res.js} +1 -1
  19. package/dist/{index-5s8b7z6q.js → index-ee621cmy.js} +9 -9
  20. package/dist/{index-62pqb23z.js → index-kp8xamqp.js} +1 -1
  21. package/dist/{index-w1s873ng.js → index-nrytvb30.js} +4 -3
  22. package/dist/index.js +191 -88
  23. package/dist/node.js +2 -2
  24. package/dist/observability/audit.d.ts +22 -3
  25. package/dist/observability/audit.d.ts.map +1 -1
  26. package/dist/observability/index.d.ts +1 -1
  27. package/dist/observability/index.d.ts.map +1 -1
  28. package/dist/observability/index.js +82 -15
  29. package/dist/server/context.d.ts +8 -5
  30. package/dist/server/context.d.ts.map +1 -1
  31. package/dist/server/create.d.ts.map +1 -1
  32. package/dist/server/implement.d.ts +33 -2
  33. package/dist/server/implement.d.ts.map +1 -1
  34. package/dist/server/index.d.ts +3 -3
  35. package/dist/server/index.d.ts.map +1 -1
  36. package/dist/server/index.js +28 -6
  37. package/dist/server/middleware/auth.d.ts +7 -4
  38. package/dist/server/middleware/auth.d.ts.map +1 -1
  39. package/dist/server/multipart.d.ts +13 -18
  40. package/dist/server/multipart.d.ts.map +1 -1
  41. package/dist/server/openapi.d.ts.map +1 -1
  42. package/dist/server/types.d.ts +48 -15
  43. package/dist/server/types.d.ts.map +1 -1
  44. package/dist/tools.js +167 -68
  45. package/llms-full.txt +367 -67
  46. package/package.json +1 -1
package/llms-full.txt CHANGED
@@ -253,7 +253,7 @@ export const users = defineContract({ prefix: 'users' }, {
253
253
  | `scope` | no | access scope for this endpoint — see [Auth & errors](./auth-and-errors.md) |
254
254
  | `expose` | no | which transports carry this endpoint — see [below](#transports) |
255
255
  | `toolName` | no | explicit MCP / agent tool name (default: a verb-aware derivation, see below — not a literal `prefix_key`) |
256
- | `multipart` | no | field name of a file upload — see [below](#file-uploads) |
256
+ | `multipart` | no | typed file fields, cardinality, delivery and upload policy — see [below](#file-uploads) |
257
257
  | `maxJsonBodyBytes` | no | per-route JSON body ceiling; overrides the server default |
258
258
  | `timeout` | no | per-endpoint client timeout in ms, for slow endpoints |
259
259
  | `idempotent` | no | safe to call twice with the same input (like `PUT`/`DELETE`); a retrying transport reads it — see [Realtime](./realtime.md#bring-your-own-transport) |
@@ -474,21 +474,61 @@ it to curate a public spec (e.g. `meta: { public: true }`), without ever emittin
474
474
 
475
475
  ## File uploads
476
476
 
477
- `multipart` names the form field carrying a file. The handler receives it as
478
- `ctx.file`:
477
+ `multipart` is the single source of truth for file fields, cardinality and
478
+ transport-level upload policy. Buffered delivery (the default) gives the
479
+ handler a typed `ctx.files` map:
479
480
 
480
481
  ```ts
481
- upload: {
482
+ uploadAttachments: {
482
483
  method: 'POST',
483
- path: '/avatar',
484
- desc: 'Upload an avatar',
485
- multipart: 'file',
486
- output: z.object({ url: z.string() }),
484
+ path: '/:answerId/attachments',
485
+ desc: 'Upload attachments',
486
+ params: AnswerIdParamsSchema,
487
+ input: UploadMetadataSchema,
488
+ output: UploadedAttachmentsSchema,
489
+ multipart: {
490
+ maxRequestBytes: 120 * 1024 * 1024,
491
+ maxFieldBytes: 64 * 1024,
492
+ files: {
493
+ cover: {
494
+ required: false,
495
+ maxBytes: 10 * 1024 * 1024,
496
+ contentTypes: ['image/*'],
497
+ },
498
+ attachments: {
499
+ multiple: true,
500
+ maxFiles: 8,
501
+ maxBytes: 20 * 1024 * 1024,
502
+ contentTypes: ['image/*', 'application/pdf'],
503
+ },
504
+ },
505
+ },
506
+ }
507
+ ```
508
+
509
+ ```ts
510
+ uploadAttachments: ({ params, input, files }) => {
511
+ files.cover // File | undefined
512
+ files.attachments // File[] in multipart order
487
513
  }
488
514
  ```
489
515
 
490
- The client sends a `multipart/form-data` request; the field value must be a
491
- `Blob`. See [HTTP server multipart](./server.md#multipart).
516
+ `required` defaults to `true`; `multiple` defaults to `false`. `maxFiles` is
517
+ valid only for a multiple field. `contentTypes` accepts exact media types and
518
+ validated type wildcards such as `image/*`; it checks the declared multipart
519
+ header, not file contents. Content sniffing, antivirus and storage policy stay
520
+ in the application.
521
+
522
+ The typed client accepts a web `Blob`/`File` or React Native
523
+ `{ uri, name, type }` descriptor for each single value, and arrays for multiple
524
+ fields. It appends repeated multipart field names in stable order. Undeclared
525
+ file fields, duplicate single fields, missing required fields and wrong part
526
+ kinds fail before the handler. Multipart endpoints are HTTP-only.
527
+
528
+ Set `delivery: 'stream'` when files must go directly to consumer-owned storage
529
+ without becoming `File` objects in framework memory. The contract descriptor
530
+ stays the same; the implementation supplies receivers with Web streams. See
531
+ [HTTP server → multipart](./server.md#multipart).
492
532
 
493
533
  ### Multipart text fields
494
534
 
@@ -622,7 +662,7 @@ Every handler receives one `ctx` argument:
622
662
  |-------------|------|--------|
623
663
  | `params` | inferred from `params` schema | parsed path params |
624
664
  | `input` | inferred from `input` schema | parsed body / query |
625
- | `file` | `File` | the `multipart` upload, if any |
665
+ | `files` | inferred `File` map or receiver values | the endpoint's typed multipart fields |
626
666
  | `source` | `'http' \| 'mcp' \| 'agent'` | the transport that invoked the handler |
627
667
  | `traceId` | `string` | per-request trace id |
628
668
  | `ipAddress` | `string` | caller IP |
@@ -674,7 +714,6 @@ server. See [Testing & deployment](./testing-and-deployment.md).
674
714
  | `groups` | route groups — a shared path prefix and hooks (see below) |
675
715
  | `scopePrefixes` | `scope → path prefix` map — mount `services` by `service.scope` (see below) |
676
716
  | `rawRoutes` | non-contract routes (see below) |
677
- | `maxUploadBytes` | default multipart upload cap (bytes); per-route `EndpointDef.maxUploadBytes` overrides |
678
717
  | `maxJsonBodyBytes` | optional JSON body cap (bytes); per-route value overrides; unset preserves existing behaviour |
679
718
  | `port` / `hostname` | listen address — port defaults to `3000` |
680
719
  | `cors` | CORS policy — `{ origin, credentials, methods, headers, exposeHeaders }`. `origin` is **required** when `cors` is present: pass an explicit origin (or list), or `'*'` to deliberately allow every origin — an origin-less config is a construction error, never a silent wildcard. Omit `cors` entirely to emit no CORS headers. |
@@ -794,7 +833,7 @@ A group gives a set of services a shared path prefix and its own hooks:
794
833
  createServer({
795
834
  groups: [
796
835
  { pathPrefix: '/api', services: [usersService, postsService] },
797
- { pathPrefix: '/api/admin', services: [adminService], hooks: { beforeHandle: adminAuth } },
836
+ { pathPrefix: '/api/admin', services: [adminService], hooks: { authorize: adminAuth } },
798
837
  ],
799
838
  })
800
839
  ```
@@ -810,7 +849,7 @@ or resource-scoped API:
810
849
  ```ts
811
850
  createServer({
812
851
  groups: [
813
- { pathPrefix: '/tenants/:tenantId', services: [widgetsService], hooks: { beforeHandle: auth } },
852
+ { pathPrefix: '/tenants/:tenantId', services: [widgetsService], hooks: { authorize: auth } },
814
853
  ],
815
854
  })
816
855
  // widgetsService (prefix 'widgets') → /tenants/:tenantId/widgets/...
@@ -819,8 +858,8 @@ createServer({
819
858
  **Where the prefix param lands.** The router matches the *full* path (group
820
859
  prefix + service prefix + endpoint path) and collects every `:param` — from the
821
860
  prefix and from the endpoint alike — into one set. Each is spread onto the
822
- context root, so it is available as **`ctx.tenantId`** (a raw `string`) in both
823
- the handler and `beforeHandle`/`afterHandle`/`onError`:
861
+ context root, so it is available as **`ctx.tenantId`** (a raw `string`) in the
862
+ `authorize` hook, handler and later lifecycle hooks:
824
863
 
825
864
  ```ts
826
865
  beforeHandle: (ctx) => {
@@ -884,14 +923,17 @@ Scope stays a free string; the core attaches no meaning beyond this lookup
884
923
 
885
924
  ## Lifecycle hooks
886
925
 
887
- Four hooks wrap every contract request, in order:
926
+ Five hooks wrap every contract request. Route matching and path-param
927
+ validation happen before authorization; body/query parsing happens only after
928
+ authorization succeeds:
888
929
 
889
930
  ```ts
890
931
  createServer({
891
932
  services,
892
933
  hooks: {
893
934
  onRequest(req) { /* logging, global rate limit — may return a Response to short-circuit */ },
894
- beforeHandle(ctx, endpoint) { /* auth, scope checks — throw to reject */ },
935
+ authorize(ctx, endpoint) { /* identity + scope, before body reads — throw to reject */ },
936
+ beforeHandle(ctx, endpoint) { /* validated-input preconditions */ },
895
937
  afterHandle(ctx, result, ep) { /* transform the result data */ },
896
938
  onError(ctx, error, ep) { /* custom error response — return a Response */ },
897
939
  },
@@ -900,9 +942,13 @@ createServer({
900
942
 
901
943
  - **`onRequest`** — runs first, with the raw `Request`. Return a `Response` to
902
944
  short-circuit (a rate-limit 429, a redirect); return nothing to continue.
903
- - **`beforeHandle`** — runs after the context is built, before the handler.
904
- Throw an `AppError` to reject. This is where auth lives —
905
- [`createAuthHook`](./auth-and-errors.md#createauthhook) is a `beforeHandle`.
945
+ - **`authorize`** — runs after route matching and validated path params, but
946
+ before query, JSON or multipart parsing. It receives request metadata and
947
+ params, with `input: undefined` and no files. This is the HTTP home of
948
+ [`createAuthHook`](./auth-and-errors.md#createauthhook).
949
+ - **`beforeHandle`** — runs after the complete context has been parsed and
950
+ validated, immediately before the handler. Put input-dependent application
951
+ preconditions here, not authentication.
906
952
  - **`afterHandle`** — receives the handler result; return a replacement to
907
953
  transform it.
908
954
  - **`onError`** — receives any thrown error; return a `Response` to customise
@@ -1205,7 +1251,7 @@ focused helper — not a sub-framework.
1205
1251
  |--------|------|
1206
1252
  | `serveFile()` | serve a file with `Range` / `304` / `HEAD` (media seeking) |
1207
1253
  | `streamSSE()` | turn an `AsyncGenerator` into a Server-Sent-Events `Response` |
1208
- | `parseMultipart()` | parse a `multipart/form-data` request with a size cap |
1254
+ | `parseMultipart()` | parse a typed buffered/streaming multipart descriptor |
1209
1255
  | `createRateLimiter()` | per-key token-bucket rate limiting |
1210
1256
  | `createCache()` + `cacheHeaders()` | in-memory TTL cache; `Cache-Control` builder |
1211
1257
  | `createEventBus<EventMap>()` | typed in-process pub/sub |
@@ -1232,29 +1278,85 @@ The client side is [`parseSSE`](./client.md#sse).
1232
1278
 
1233
1279
  ### Multipart
1234
1280
 
1235
- ```ts
1236
- import { parseMultipart } from 'stitchkit/server'
1281
+ The contract owns one descriptor for buffered and streaming delivery:
1237
1282
 
1238
- const { file, fields } = await parseMultipart(req, 'file', undefined, 10_000_000)
1283
+ ```ts
1284
+ const uploads = defineContract({ prefix: 'uploads' }, {
1285
+ create: {
1286
+ method: 'POST', path: '/', desc: 'Upload media',
1287
+ input: UploadMetadataSchema,
1288
+ output: UploadedMediaSchema,
1289
+ multipart: {
1290
+ maxRequestBytes: 220 * 1024 * 1024,
1291
+ maxFieldBytes: 64 * 1024,
1292
+ files: {
1293
+ cover: { required: false, maxBytes: 10 * 1024 * 1024, contentTypes: ['image/*'] },
1294
+ media: { multiple: true, maxFiles: 4, maxBytes: 50 * 1024 * 1024 },
1295
+ },
1296
+ },
1297
+ },
1298
+ })
1239
1299
  ```
1240
1300
 
1241
- When an endpoint declares `multipart`, the framework parses the upload for you
1242
- and the file arrives as `ctx.file` call `parseMultipart` directly only from a
1243
- raw route.
1301
+ Buffered delivery is the default. Each file becomes a Web `File`; single,
1302
+ optional and multiple cardinality is inferred on `ctx.files`. The request cap
1303
+ defaults to **25 MB** when omitted. The parser measures actual bytes including
1304
+ boundaries and headers instead of trusting `Content-Length`; per-file bytes,
1305
+ file count, text-field bytes and declared MIME policy are enforced while
1306
+ reading. Text fields remain strings until the endpoint's Zod `input` parses
1307
+ them.
1244
1308
 
1245
- The upload cap defaults to **25 MB**. Raise it per route with
1246
- `EndpointDef.maxUploadBytes`, or set a server-wide default with
1247
- `createServer({ maxUploadBytes })` a per-route value wins over the global:
1309
+ For large files, set `delivery: 'stream'` and define receivers. A receiver gets
1310
+ a Web `ReadableStream<Uint8Array>` and writes directly to consumer-owned
1311
+ storage; Stitchkit holds only bounded parser state:
1248
1312
 
1249
1313
  ```ts
1250
- // contract
1251
- upload: { method: 'POST', path: '/', desc: 'Upload a video',
1252
- multipart: 'file', maxUploadBytes: 200 * 1024 * 1024 }
1314
+ const streamingUploads = defineContract({ prefix: 'uploads' }, {
1315
+ create: {
1316
+ method: 'POST', path: '/', desc: 'Upload media',
1317
+ input: UploadMetadataSchema,
1318
+ output: UploadedMediaSchema,
1319
+ multipart: {
1320
+ delivery: 'stream',
1321
+ maxRequestBytes: 260 * 1024 * 1024,
1322
+ files: { media: { maxBytes: 250 * 1024 * 1024, contentTypes: ['video/*'] } },
1323
+ },
1324
+ },
1325
+ })
1253
1326
 
1254
- // server default for every multipart route that declares no own cap
1255
- createServer({ services, maxUploadBytes: 50 * 1024 * 1024 })
1327
+ const service = implement(streamingUploads, {
1328
+ create: defineMultipartStream(streamingUploads.endpoints.create, {
1329
+ files: {
1330
+ media: async ({ metadata, stream, signal }) => {
1331
+ const stored = await storage.write({ metadata, stream, signal })
1332
+ return {
1333
+ value: stored,
1334
+ cleanup: () => storage.remove(stored.key),
1335
+ }
1336
+ },
1337
+ },
1338
+ handler: ({ input, files }) => mediaService.attach(input, files.media),
1339
+ }),
1340
+ })
1256
1341
  ```
1257
1342
 
1343
+ Receivers run sequentially in multipart order. A multiple receiver runs once
1344
+ per part and the handler gets an ordered value array. `cleanup` is registered
1345
+ as soon as a receiver materialises external state. Disconnect, size/policy
1346
+ failure, a later receiver/text validation failure or handler failure rolls
1347
+ accepted handles back exactly once in reverse order. After handler success,
1348
+ ownership transfers to the application.
1349
+
1350
+ Authorization always completes before the multipart parser or any receiver is
1351
+ started. The receiver signal is tied to the request abort. The core does not
1352
+ provide filesystem/S3 adapters, retries, antivirus or a distributed
1353
+ storage/database transaction; those policies belong to the application.
1354
+
1355
+ `parseMultipart(req, descriptor, fieldsSchema?, receivers?)` is also exported
1356
+ for a custom raw transport. It uses the same descriptor and returns
1357
+ `{ files, fields, rollback }`; contract endpoints should prefer the automatic
1358
+ dispatcher path.
1359
+
1258
1360
  ### Rate limiting
1259
1361
 
1260
1362
  ```ts
@@ -1450,10 +1552,43 @@ contract:
1450
1552
  - for `GET` / `DELETE`, the remaining fields become the **query string**
1451
1553
  (arrays become repeated keys),
1452
1554
  - for `POST` / `PUT` / `PATCH`, they become the **JSON body**,
1453
- - a `multipart` field is a `Blob` (web / Bun) or a platform `FileDescriptor`
1454
- (`{ uri, name, type }`, for React Native / Expo) and is sent as `form-data`.
1455
- The exported `MultipartFile` / `FileDescriptor` types let you annotate your own
1456
- upload helpers.
1555
+ - a single `multipart.files` field is a `Blob` (web / Bun) or platform
1556
+ `FileDescriptor` (`{ uri, name, type }`, React Native / Expo); a multiple
1557
+ field is an array and is appended under the same form field name in order.
1558
+ The exported `MultipartFile` / `FileDescriptor` types let you annotate your
1559
+ own upload helpers.
1560
+
1561
+ ### Per-call cancellation
1562
+
1563
+ Every endpoint accepts optional `ClientRequestOptions` containing an
1564
+ `AbortSignal`. Endpoints with arguments use `(args, options?)`; endpoints with
1565
+ no arguments use `(options?)`:
1566
+
1567
+ ```ts
1568
+ const controller = new AbortController()
1569
+
1570
+ const pending = api.upload(
1571
+ { file: selectedFile, title: 'Draft' },
1572
+ { signal: controller.signal },
1573
+ )
1574
+
1575
+ controller.abort()
1576
+ await pending
1577
+ ```
1578
+
1579
+ Caller cancellation and the endpoint/client timeout are composed; whichever
1580
+ fires first owns the result. Both the bare-fetch and Ky-backed clients expose
1581
+ the same client-only errors:
1582
+
1583
+ | Failure | `ApiError.code` | `status` |
1584
+ |---------|-----------------|----------|
1585
+ | caller `AbortSignal` | `REQUEST_ABORTED` | `0` |
1586
+ | endpoint/client timeout | `REQUEST_TIMEOUT` | `0` |
1587
+ | other transport failure | `UNKNOWN_ERROR` | `0` |
1588
+
1589
+ Abort and timeout do not emit `network_error` and are not retried. The same
1590
+ options work for query, JSON, multipart and raw-response calls. Stitchkit does
1591
+ not expose upload progress: Fetch has no portable upload-progress primitive.
1457
1592
 
1458
1593
  ### Many contracts at once
1459
1594
 
@@ -2030,15 +2165,15 @@ handlers must still be idempotent where retries matter.
2030
2165
 
2031
2166
  A tool call runs the same handler an HTTP request would. `lifecycle` makes it
2032
2167
  run the same gate: a `beforeHandle` (throw to reject) and an `afterHandle`
2033
- (transform the result) — the tool-side twin of `createServer`'s hooks. Pass the
2034
- **same** [`createAuthHook`](./auth-and-errors.md#createauthhook) result you give
2035
- the HTTP server and tool calls are scope-checked by the identical rules:
2168
+ (transform the result). Pass the **same**
2169
+ [`createAuthHook`](./auth-and-errors.md#createauthhook) result used as the HTTP
2170
+ server's `authorize` hook and tool calls are scope-checked by identical rules:
2036
2171
 
2037
2172
  ```ts
2038
2173
  createMcpHandler({ serverInfo, auth, services, lifecycle: { beforeHandle: authHook } })
2039
2174
  ```
2040
2175
 
2041
- Without it, a tool call bypasses the HTTP `beforeHandle` — the contract's
2176
+ Without it, a tool call bypasses the HTTP `authorize` gate — the contract's
2042
2177
  `scope` is not enforced on the MCP / agent surface. `mountMcp`, `mountAgent` and
2043
2178
  `buildMcpServer` take `lifecycle` too.
2044
2179
 
@@ -2400,7 +2535,7 @@ the merged `params` + `input`. `context` is merged into every tool handler's
2400
2535
  | `runtimeTools` | framework-managed pathless operations from `defineRuntimeTool` |
2401
2536
 
2402
2537
  `lifecycle` works the same as on the MCP server — without it an agent tool call
2403
- bypasses the HTTP `beforeHandle` auth gate. Pass your `createAuthHook` result.
2538
+ bypasses the HTTP `authorize` gate. Pass your `createAuthHook` result.
2404
2539
 
2405
2540
  ### Adding tool-only args — `extend`
2406
2541
 
@@ -2795,7 +2930,7 @@ await createCli({
2795
2930
  version: '1.0.0',
2796
2931
  auth: await resolveIdentityFromToken(process.env.MYAPP_TOKEN),
2797
2932
  context: (identity) => ({ user: identity }), // resolveFromContext reads this
2798
- lifecycle: { beforeHandle: authHook }, // same gate as HTTP
2933
+ lifecycle: { beforeHandle: authHook }, // same policy; HTTP wires it as authorize
2799
2934
  services,
2800
2935
  })
2801
2936
  ```
@@ -3348,10 +3483,12 @@ The framework attaches no meaning to the strings — `'public'`, `'user'`,
3348
3483
 
3349
3484
  ## `createAuthHook`
3350
3485
 
3351
- `createAuthHook` builds a `beforeHandle` hook that enforces `endpoint.scope`.
3352
- Every request runs the same three steps resolve the identity, read the scope,
3353
- allow / 401 / 403 so the flow lives in the framework and you supply only
3354
- `resolve` and a `rules` map.
3486
+ `createAuthHook` builds one scope gate that can run in both HTTP and tool
3487
+ lifecycles. On HTTP it belongs in `hooks.authorize`: Stitchkit validates path
3488
+ params, resolves identity and scope, and rejects `401`/`403` **before reading a
3489
+ JSON or multipart body**. On MCP, agent and CLI surfaces it belongs in
3490
+ `lifecycle.beforeHandle`, because those transports have already received their
3491
+ arguments before the common tool runner starts.
3355
3492
 
3356
3493
  ```ts
3357
3494
  import { createAuthHook, createServer } from 'stitchkit/server'
@@ -3366,7 +3503,7 @@ const authHook = createAuthHook<User>({
3366
3503
  inject: (ctx, user) => { ctx.user = user },
3367
3504
  })
3368
3505
 
3369
- createServer({ services, hooks: { beforeHandle: authHook } })
3506
+ createServer({ services, hooks: { authorize: authHook } })
3370
3507
  ```
3371
3508
 
3372
3509
  ### `AuthRule`
@@ -3376,8 +3513,9 @@ The value of each `rules` entry, keyed by scope:
3376
3513
  - **`'public'`** — always passes; the identity is attached if present.
3377
3514
  - **`'authenticated'`** — any resolved identity passes; no identity ⇒ 401.
3378
3515
  - **a function** `(identity, ctx) => boolean | Promise<boolean>` — a custom
3379
- check. It receives the full context, so a resource-scoped rule can read the
3380
- request's path params and do a DB lookup. May be async.
3516
+ check. It receives request metadata and validated path params, so a
3517
+ resource-scoped rule can do a DB lookup. It cannot read `input` or files: the
3518
+ body has deliberately not been consumed yet. May be async.
3381
3519
 
3382
3520
  #### Resource-scoped rule — reading a path/prefix param
3383
3521
 
@@ -3424,14 +3562,15 @@ catches a scope you forgot to cover.
3424
3562
 
3425
3563
  ### Auth on the tool surface — `resolveFromContext`
3426
3564
 
3427
- The hook runs in `beforeHandle`, so it guards **every transport** — HTTP, MCP
3428
- and agent calls all pass through it. But identity is resolved differently per
3429
- surface:
3565
+ The same hook guards every transport, but the lifecycle slot and identity
3566
+ source differ:
3430
3567
 
3431
- - **HTTP** — `resolve(ctx)` reads `ctx.req` (a cookie or bearer token).
3568
+ - **HTTP** — `hooks.authorize` calls `resolve(ctx)` from `ctx.req` (a cookie or
3569
+ bearer token) before body parsing.
3432
3570
  - **Tool calls (MCP / agent)** — there is no `req`. The transport authenticated
3433
3571
  the caller (an MCP API key) and `buildMcpServer`'s `context` injected the
3434
- identity into `ctx`. `resolveFromContext(ctx)` locates it.
3572
+ identity into `ctx`. `lifecycle.beforeHandle` calls
3573
+ `resolveFromContext(ctx)` to locate it.
3435
3574
 
3436
3575
  ```ts
3437
3576
  const authHook = createAuthHook<User>({
@@ -3446,6 +3585,11 @@ differs. If you omit `resolveFromContext`, a scoped tool call has no identity
3446
3585
  and **fails closed** (rejected by `onAnonymous`) — the hook never silently
3447
3586
  passes a tool call it cannot authenticate. → [ADR 0014](../decisions/0014-tool-http-parity.md)
3448
3587
 
3588
+ ```ts
3589
+ createServer({ services, hooks: { authorize: authHook } })
3590
+ createMcpHandler({ services, lifecycle: { beforeHandle: authHook } })
3591
+ ```
3592
+
3449
3593
  ## `createBearerResolver`
3450
3594
 
3451
3595
  For API-key or bearer-token auth (the usual MCP case), `createBearerResolver`
@@ -3806,6 +3950,66 @@ Each sink runs fire-and-forget and fails independently: a slow or broken request
3806
3950
  sink cannot block the response, suppress operational logging or break the tool
3807
3951
  sink.
3808
3952
 
3953
+ The fire-and-forget work has an explicit bounded lifecycle:
3954
+
3955
+ ```ts
3956
+ export const observability = createObservability({
3957
+ request: {
3958
+ maxPending: 1_000,
3959
+ write: persistRequestEvent,
3960
+ onSinkError: ({ error, event }) => reportAuditFailure(error, event),
3961
+ onDrop: ({ reason, event, pending }) => {
3962
+ reportAuditDrop({ reason, traceId: event.traceId, pending })
3963
+ },
3964
+ },
3965
+ tools: {
3966
+ maxPending: 500,
3967
+ write: persistToolEvent,
3968
+ onSinkError: ({ error, event }) => reportToolAuditFailure(error, event),
3969
+ onDrop: reportToolAuditDrop,
3970
+ },
3971
+ })
3972
+
3973
+ await observability.flush()
3974
+ await observability.close()
3975
+ ```
3976
+
3977
+ `maxPending` defaults to `1000` per sink and must be a positive safe integer.
3978
+ Filtering and sanitisation happen before a write occupies a pending slot. At
3979
+ capacity, a new event is rejected with `onDrop({ reason: 'capacity', ... })`;
3980
+ after close, admission reports `reason: 'closed'`. Existing writes are never
3981
+ cancelled. Sink, filter and diagnostic-callback failures remain isolated from
3982
+ the observed request or tool call and are reported through `onSinkError` when
3983
+ configured; `onSinkError`/`onDrop` cannot create unhandled rejections.
3984
+
3985
+ `flush()` snapshots the current generation and waits only for events admitted
3986
+ up to that call. `close()` atomically stops admission, drains every accepted
3987
+ generation and is idempotent. Graceful shutdown order is therefore:
3988
+
3989
+ 1. stop HTTP/MCP admission;
3990
+ 2. wait for active requests and tool calls;
3991
+ 3. `await observability.close()`;
3992
+ 4. close the database/storage connection used by the sinks.
3993
+
3994
+ Stitchkit manages only in-process delivery. If process-crash durability matters,
3995
+ make `write` enqueue into a consumer-owned durable outbox and let that adapter
3996
+ own retry, replay and storage policy:
3997
+
3998
+ ```ts
3999
+ interface AuditOutbox {
4000
+ enqueue(event: RequestEvent): Promise<void>
4001
+ }
4002
+
4003
+ const observability = createObservability({
4004
+ request: { write: (event) => outbox.enqueue(event) },
4005
+ tools: { write: (event) => outbox.enqueue(event) },
4006
+ })
4007
+ ```
4008
+
4009
+ The core intentionally contains no retry scheduler, database dependency or
4010
+ disk queue. A durable adapter can use `(traceId, spanId, source, toolPhase)` as
4011
+ its idempotency identity.
4012
+
3809
4013
  ### RequestEvent
3810
4014
 
3811
4015
  Every surface produces the same shape — so a single audit table stays
@@ -4337,8 +4541,9 @@ Notes for a Node host:
4337
4541
  to join the two logs.
4338
4542
  - **Rate limiting** — `createRateLimiter` in `onRequest` for a global limit;
4339
4543
  per-route limits belong in `beforeHandle`.
4340
- - **Auth** — a `createAuthHook` `beforeHandle` guards every transport at once;
4341
- do not re-check auth per handler.
4544
+ - **Auth** — wire one `createAuthHook` as HTTP `hooks.authorize` and tool
4545
+ `lifecycle.beforeHandle`; do not re-check auth per handler. HTTP authorization
4546
+ runs before JSON or multipart body reads.
4342
4547
  - **Errors** — handlers throw `AppError`; let the standard envelope render them.
4343
4548
  Add an `onError` hook only to integrate an error tracker.
4344
4549
  - **Secrets** — read them from the environment; never commit them.
@@ -4397,7 +4602,7 @@ matched value is on the context root as `ctx.tenantId`
4397
4602
  ```ts
4398
4603
  createServer({
4399
4604
  groups: [
4400
- { pathPrefix: '/tenants/:tenantId', services: [widgetsService], hooks: { beforeHandle: authHook } },
4605
+ { pathPrefix: '/tenants/:tenantId', services: [widgetsService], hooks: { authorize: authHook } },
4401
4606
  ],
4402
4607
  })
4403
4608
  // → /tenants/:tenantId/widgets
@@ -4411,7 +4616,7 @@ the flat `services` list — no hand-partitioning, the mapping lives in one plac
4411
4616
  createServer({
4412
4617
  services, // mixed scopes, listed once
4413
4618
  scopePrefixes: { tenant: 'tenants/:tenantId', project: 'projects/:projectId' },
4414
- hooks: { beforeHandle: authHook },
4619
+ hooks: { authorize: authHook },
4415
4620
  })
4416
4621
  // `tenant`-scoped → /tenants/:tenantId/..., `project` → /projects/:projectId/..., the rest flat
4417
4622
  ```
@@ -4612,6 +4817,86 @@ current one *up to* your target, and apply each snippet.
4612
4817
  runtime): bootstrap the server, one HTTP request, and any feature you rely on
4613
4818
  (Socket.IO connect, an MCP tool call, a multipart upload, …).
4614
4819
 
4820
+ ## Upcoming migration: `[Unreleased]`
4821
+
4822
+ ### HTTP auth moves to the pre-body `authorize` phase
4823
+
4824
+ Move the HTTP wiring of `createAuthHook` from `beforeHandle` to `authorize`.
4825
+ This lets Stitchkit reject an unauthorized JSON or multipart request after path
4826
+ parameter validation but before reading a body chunk. Keep application
4827
+ preconditions that depend on validated input in `beforeHandle`.
4828
+
4829
+ ```ts
4830
+ const auth = createAuthHook({ authenticate, authorize })
4831
+
4832
+ // before
4833
+ createServer({ services, hooks: { beforeHandle: auth } })
4834
+
4835
+ // after
4836
+ createServer({ services, hooks: { authorize: auth } })
4837
+ ```
4838
+
4839
+ Tool transports already receive parsed input, so their wiring does not move:
4840
+
4841
+ ```ts
4842
+ createMcpHandler({ services, lifecycle: { beforeHandle: auth } })
4843
+ ```
4844
+
4845
+ If a custom HTTP authorization hook read `ctx.input`, `ctx.files` or raw body
4846
+ state, split it: identity/scope checks belong in `authorize`; validated payload
4847
+ preconditions belong in `beforeHandle` or the domain service.
4848
+
4849
+ ### Multipart uses a typed descriptor and `ctx.files`
4850
+
4851
+ Replace every string multipart declaration, top-level `maxUploadBytes` and
4852
+ `ctx.file`. The descriptor is now the only source of request, per-file,
4853
+ cardinality and declared media-type policy.
4854
+
4855
+ ```ts
4856
+ // before
4857
+ upload: {
4858
+ method: 'POST',
4859
+ path: '/',
4860
+ multipart: 'file',
4861
+ maxUploadBytes: 25 * 1024 * 1024,
4862
+ }
4863
+ upload: ({ file, input }) => store(file, input)
4864
+
4865
+ // after
4866
+ upload: {
4867
+ method: 'POST',
4868
+ path: '/',
4869
+ multipart: {
4870
+ maxRequestBytes: 25 * 1024 * 1024,
4871
+ files: {
4872
+ file: {
4873
+ maxBytes: 20 * 1024 * 1024,
4874
+ contentTypes: ['image/*', 'application/pdf'],
4875
+ },
4876
+ },
4877
+ },
4878
+ }
4879
+ upload: ({ files, input }) => store(files.file, input)
4880
+ ```
4881
+
4882
+ Multiple files are repeated under one multipart field name and arrive in the
4883
+ same order:
4884
+
4885
+ ```ts
4886
+ files: {
4887
+ attachments: { multiple: true, maxFiles: 8 },
4888
+ }
4889
+
4890
+ await api.upload({ attachments: [firstFile, secondFile] })
4891
+ // handler: files.attachments is File[]
4892
+ ```
4893
+
4894
+ For direct-to-storage delivery, set `delivery: 'stream'` and implement the
4895
+ endpoint with `defineMultipartStream`. A receiver must consume its Web Stream
4896
+ and return `{ value, cleanup }`; the final handler sees only receiver values.
4897
+ There is no deprecated overload or buffered compatibility path under the old
4898
+ contract shape.
4899
+
4615
4900
  ## Worked example — frozen on 0.3, jumping to 0.7
4616
4901
 
4617
4902
  1. `bun.lock` → consumer resolves `stitchkit@0.3.x`.
@@ -5299,6 +5584,7 @@ The browser-and-server entrypoint. Re-exports everything from
5299
5584
  | `createUrlBuilders` | function | build one exact URL builder per contract in a registry |
5300
5585
  | `UrlBuilderConfig` | _type_ | explicit `{ baseUrl }` source for a URL builder |
5301
5586
  | `ClientConfig` | _type_ | config for `createClient`'s bare-fetch mode (2nd arg, no `HttpClient`) |
5587
+ | `ClientRequestOptions` | _type_ | per-call `{ signal?: AbortSignal }`; caller abort is distinct from timeout — [guide](../guide/client.md#per-call-cancellation) |
5302
5588
  | `ContractClientConfig` | _type_ | per-tenant / resource-scoped client config — dynamic `pathPrefix` + `stripPrefixKeys` ([guide](../guide/client.md#contractclientconfig--per-tenant--resource-scoped-clients)) |
5303
5589
  | `contractEndpointMatchers` | function | compile exact pathname matchers for selected HTTP contract operations and expected-401 policy |
5304
5590
  | `PathPrefixArgs` | _type_ | required string-valued keys exposed to a typed dynamic `pathPrefix` callback |
@@ -5388,6 +5674,7 @@ from the root `stitchkit`.
5388
5674
  | `TransportSource` | _type_ | `http \| mcp \| agent \| cli` — the value of `ctx.source` |
5389
5675
  | `RuntimeContext` | _type_ | the loose context seen by transport and hooks |
5390
5676
  | `HandlerContext` | _type_ | the typed context seen by a handler |
5677
+ | `EndpointHandlerContext` | _type_ | one endpoint handler's fully inferred params, input, files and runtime context |
5391
5678
  | `EndpointFn` | _type_ | the call signature of one client method |
5392
5679
  | `TypedClient` | _type_ | the full typed client for a contract |
5393
5680
  | `TypedHttpClient` | _type_ | the typed client, HTTP endpoints only (`= ScopedHttpClient<C, unknown>`) |
@@ -5398,6 +5685,9 @@ from the root `stitchkit`.
5398
5685
  | `ScopedUrlFn` | _type_ | one URL method's signature with scoped-prefix keys folded in |
5399
5686
  | `MultipartFile` | _type_ | a `multipart` file field — `Blob \| FileDescriptor` |
5400
5687
  | `FileDescriptor` | _type_ | a React Native / Expo file — `{ uri, name, type }` |
5688
+ | `MultipartDescriptor` | _type_ | file fields, cardinality, delivery and request/text limits |
5689
+ | `MultipartFilePolicy` | _type_ | required/multiple, per-file bytes/count and declared MIME policy |
5690
+ | `MultipartBufferedFiles` | _type_ | `File` map inferred from a multipart descriptor |
5401
5691
  | `EndpointToolAnnotations` | _type_ | MCP behavioural hints on an endpoint (`readOnlyHint` / `destructiveHint` / `title`) |
5402
5692
  | `EndpointUiMeta` | _type_ | MCP Apps widget metadata on an endpoint |
5403
5693
  | `EndpointMcpInputRequired` | _type_ | typed MCP multi-round input request (`key`, message and Zod object schema) |
@@ -5476,7 +5766,8 @@ Also re-exports the error helpers from `stitchkit/contract`.
5476
5766
  | `MethodDef` | _type_ | one resolved endpoint inside a service |
5477
5767
  | `OperationIdentity` | _type_ | path-free service/action/scope/method identity shared by contract and native tool operations |
5478
5768
  | `Handlers` | _type_ | the typed handler map `implement` expects |
5479
- | `LifecycleHooks` | _type_ | `onRequest` / `beforeHandle` / `afterHandle` / `onError` |
5769
+ | `LifecycleHooks` | _type_ | `onRequest` / pre-body `authorize` / `beforeHandle` / `afterHandle` / `onError` |
5770
+ | `AuthorizationContext` | _type_ | HTTP pre-body context with validated params, `input: undefined` and no files |
5480
5771
  | `RouteGroup` | _type_ | a prefixed group of services with its own hooks |
5481
5772
  | `RawRoute` | _type_ | a non-contract `Request → Response` route with a concrete `BunServer` context |
5482
5773
  | `RawRouteContext` | _type_ | the Bun-bound routing context a raw handler receives |
@@ -5493,7 +5784,7 @@ Also re-exports the error helpers from `stitchkit/contract`.
5493
5784
 
5494
5785
  | Export | Kind | Summary |
5495
5786
  |--------|------|---------|
5496
- | `createAuthHook` | function | a scope-enforcing `beforeHandle` hook — [guide](../guide/auth-and-errors.md#createauthhook) |
5787
+ | `createAuthHook` | function | one scope gate for HTTP `authorize` and tool `beforeHandle` — [guide](../guide/auth-and-errors.md#createauthhook) |
5497
5788
  | `createErrorHook` | function | an async-capable, endpoint-aware `onError` hook from a code map + envelope renderer — [guide](../guide/auth-and-errors.md#createerrorhook) |
5498
5789
  | `ErrorHookConfig` | _type_ | async observer/renderer config for `createErrorHook` |
5499
5790
  | `ResolvedError` | _type_ | the normalised error handed to `createErrorHook`'s `render` |
@@ -5551,8 +5842,14 @@ Also re-exports the error helpers from `stitchkit/contract`.
5551
5842
  |--------|------|---------|
5552
5843
  | `streamSSE` | function | an async generator → SSE `Response` — [guide](../guide/server.md#sse-streaming) |
5553
5844
  | `parseSSE` | function | parse an SSE `Response` (also on the root entrypoint) |
5845
+ | `MultipartLifecycle` | _type_ | request-scoped rollback ownership for accepted streamed handles |
5554
5846
  | `MultipartResult` | _type_ | what `parseMultipart` returns |
5555
- | `parseMultipart` | function | parse a `multipart/form-data` request — [guide](../guide/server.md#multipart) |
5847
+ | `parseMultipart` | function | parse a typed buffered/streaming multipart descriptor — [guide](../guide/server.md#multipart) |
5848
+ | `defineMultipartStream` | function | bind typed streaming file receivers and a final endpoint handler |
5849
+ | `MultipartFileMetadata` | _type_ | field, filename, declared media type and optional declared size |
5850
+ | `MultipartReceiver` | _type_ | consumer-owned Web-stream storage receiver |
5851
+ | `MultipartReceiverResult` | _type_ | receiver value plus rollback cleanup |
5852
+ | `StreamingMultipartImplementation` | _type_ | receiver registry and handler shape inferred by `defineMultipartStream` |
5556
5853
  | `createRateLimiter` | function | token-bucket rate limiting — [guide](../guide/server.md#rate-limiting) |
5557
5854
  | `createCache` | function | an in-memory TTL cache |
5558
5855
  | `CacheOptions` | _type_ | bounded-cache options, including the maximum retained entry count |
@@ -5597,9 +5894,12 @@ audit event. See the [Observability guide](../guide/observability.md).
5597
5894
  | `createObservability` | function | configure framework-owned request completion and canonical tool event sinks — [guide](../guide/observability.md#createobservability) |
5598
5895
  | `RequestEvent` | _type_ | the normalised audit event handed to the sink |
5599
5896
  | `ObservabilityConfig` | _type_ | independent request and tool sink configuration |
5600
- | `Observability` | _type_ | the `{ request?, toolCall }` wiring result |
5601
- | `RequestEventSinkConfig` | _type_ | `write`, `filter` and sanitisation for one event surface |
5897
+ | `Observability` | _type_ | `{ request?, toolCall, flush(), close() }` with bounded sink lifecycle |
5898
+ | `RequestEventSinkConfig` | _type_ | `write`, filter/sanitisation, `maxPending`, `onSinkError` and `onDrop` |
5602
5899
  | `RequestObservabilityConfig` | _type_ | request sink plus opt-in payload capture |
5900
+ | `SinkDropReason` | _type_ | `'capacity' \| 'closed'` |
5901
+ | `SinkError` | _type_ | isolated sink/projection failure and optional event |
5902
+ | `SinkDrop` | _type_ | rejected event, reason and current pending count |
5603
5903
  | `HttpRequestCompletion` | _type_ | the single framework-owned HTTP outcome projected to logging and request events |
5604
5904
  | `HttpRequestObserver` | _type_ | server-facing projection consumed by `HandlerConfig.observability` |
5605
5905