stitchkit 0.46.0 → 0.48.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 +13 -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 +67 -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 +195 -94
  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 +171 -74
  45. package/llms-full.txt +411 -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
+ },
487
506
  }
488
507
  ```
489
508
 
490
- The client sends a `multipart/form-data` request; the field value must be a
491
- `Blob`. See [HTTP server multipart](./server.md#multipart).
509
+ ```ts
510
+ uploadAttachments: ({ params, input, files }) => {
511
+ files.cover // File | undefined
512
+ files.attachments // File[] in multipart order
513
+ }
514
+ ```
515
+
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,50 @@ 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 callable exposes a `withOptions` method accepting required
1564
+ `ClientRequestOptions`. The ordinary callable contains only contract arguments,
1565
+ so it can be passed directly to callback APIs such as `react-query-kit` without
1566
+ mistaking their callback context for Stitchkit transport options:
1567
+
1568
+ ```ts
1569
+ const controller = new AbortController()
1570
+
1571
+ const pending = api.upload.withOptions(
1572
+ { file: selectedFile, title: 'Draft' },
1573
+ { signal: controller.signal },
1574
+ )
1575
+
1576
+ controller.abort()
1577
+ await pending
1578
+ ```
1579
+
1580
+ Caller cancellation and the endpoint/client timeout are composed; whichever
1581
+ fires first owns the result. Both the bare-fetch and Ky-backed clients expose
1582
+ the same client-only errors:
1583
+
1584
+ | Failure | `ApiError.code` | `status` |
1585
+ |---------|-----------------|----------|
1586
+ | caller `AbortSignal` | `REQUEST_ABORTED` | `0` |
1587
+ | endpoint/client timeout | `REQUEST_TIMEOUT` | `0` |
1588
+ | other transport failure | `UNKNOWN_ERROR` | `0` |
1589
+
1590
+ Abort and timeout do not emit `network_error` and are not retried. The same
1591
+ options work for query, JSON, multipart and raw-response calls. Stitchkit does
1592
+ not expose upload progress: Fetch has no portable upload-progress primitive.
1593
+
1594
+ For an endpoint without contract arguments, pass only the options object:
1595
+
1596
+ ```ts
1597
+ await api.health.withOptions({ signal: controller.signal })
1598
+ ```
1457
1599
 
1458
1600
  ### Many contracts at once
1459
1601
 
@@ -1688,6 +1830,10 @@ export const useUsers = createQuery({ queryKey: ['users'], fetcher: () => a
1688
1830
  export const useCreateUser = createMutation({ mutationFn: api.create })
1689
1831
  ```
1690
1832
 
1833
+ Generated methods intentionally keep their ordinary call signature limited to
1834
+ contract variables. Use `api.create.withOptions(variables, { signal })` only for
1835
+ an imperative call that needs per-request cancellation.
1836
+
1691
1837
  ### Cursor pagination
1692
1838
 
1693
1839
  For a cursor-paginated list, `createCursorQuery` is the canonical helper:
@@ -2030,15 +2176,15 @@ handlers must still be idempotent where retries matter.
2030
2176
 
2031
2177
  A tool call runs the same handler an HTTP request would. `lifecycle` makes it
2032
2178
  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:
2179
+ (transform the result). Pass the **same**
2180
+ [`createAuthHook`](./auth-and-errors.md#createauthhook) result used as the HTTP
2181
+ server's `authorize` hook and tool calls are scope-checked by identical rules:
2036
2182
 
2037
2183
  ```ts
2038
2184
  createMcpHandler({ serverInfo, auth, services, lifecycle: { beforeHandle: authHook } })
2039
2185
  ```
2040
2186
 
2041
- Without it, a tool call bypasses the HTTP `beforeHandle` — the contract's
2187
+ Without it, a tool call bypasses the HTTP `authorize` gate — the contract's
2042
2188
  `scope` is not enforced on the MCP / agent surface. `mountMcp`, `mountAgent` and
2043
2189
  `buildMcpServer` take `lifecycle` too.
2044
2190
 
@@ -2400,7 +2546,7 @@ the merged `params` + `input`. `context` is merged into every tool handler's
2400
2546
  | `runtimeTools` | framework-managed pathless operations from `defineRuntimeTool` |
2401
2547
 
2402
2548
  `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.
2549
+ bypasses the HTTP `authorize` gate. Pass your `createAuthHook` result.
2404
2550
 
2405
2551
  ### Adding tool-only args — `extend`
2406
2552
 
@@ -2795,7 +2941,7 @@ await createCli({
2795
2941
  version: '1.0.0',
2796
2942
  auth: await resolveIdentityFromToken(process.env.MYAPP_TOKEN),
2797
2943
  context: (identity) => ({ user: identity }), // resolveFromContext reads this
2798
- lifecycle: { beforeHandle: authHook }, // same gate as HTTP
2944
+ lifecycle: { beforeHandle: authHook }, // same policy; HTTP wires it as authorize
2799
2945
  services,
2800
2946
  })
2801
2947
  ```
@@ -3348,10 +3494,12 @@ The framework attaches no meaning to the strings — `'public'`, `'user'`,
3348
3494
 
3349
3495
  ## `createAuthHook`
3350
3496
 
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.
3497
+ `createAuthHook` builds one scope gate that can run in both HTTP and tool
3498
+ lifecycles. On HTTP it belongs in `hooks.authorize`: Stitchkit validates path
3499
+ params, resolves identity and scope, and rejects `401`/`403` **before reading a
3500
+ JSON or multipart body**. On MCP, agent and CLI surfaces it belongs in
3501
+ `lifecycle.beforeHandle`, because those transports have already received their
3502
+ arguments before the common tool runner starts.
3355
3503
 
3356
3504
  ```ts
3357
3505
  import { createAuthHook, createServer } from 'stitchkit/server'
@@ -3366,7 +3514,7 @@ const authHook = createAuthHook<User>({
3366
3514
  inject: (ctx, user) => { ctx.user = user },
3367
3515
  })
3368
3516
 
3369
- createServer({ services, hooks: { beforeHandle: authHook } })
3517
+ createServer({ services, hooks: { authorize: authHook } })
3370
3518
  ```
3371
3519
 
3372
3520
  ### `AuthRule`
@@ -3376,8 +3524,9 @@ The value of each `rules` entry, keyed by scope:
3376
3524
  - **`'public'`** — always passes; the identity is attached if present.
3377
3525
  - **`'authenticated'`** — any resolved identity passes; no identity ⇒ 401.
3378
3526
  - **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.
3527
+ check. It receives request metadata and validated path params, so a
3528
+ resource-scoped rule can do a DB lookup. It cannot read `input` or files: the
3529
+ body has deliberately not been consumed yet. May be async.
3381
3530
 
3382
3531
  #### Resource-scoped rule — reading a path/prefix param
3383
3532
 
@@ -3424,14 +3573,15 @@ catches a scope you forgot to cover.
3424
3573
 
3425
3574
  ### Auth on the tool surface — `resolveFromContext`
3426
3575
 
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:
3576
+ The same hook guards every transport, but the lifecycle slot and identity
3577
+ source differ:
3430
3578
 
3431
- - **HTTP** — `resolve(ctx)` reads `ctx.req` (a cookie or bearer token).
3579
+ - **HTTP** — `hooks.authorize` calls `resolve(ctx)` from `ctx.req` (a cookie or
3580
+ bearer token) before body parsing.
3432
3581
  - **Tool calls (MCP / agent)** — there is no `req`. The transport authenticated
3433
3582
  the caller (an MCP API key) and `buildMcpServer`'s `context` injected the
3434
- identity into `ctx`. `resolveFromContext(ctx)` locates it.
3583
+ identity into `ctx`. `lifecycle.beforeHandle` calls
3584
+ `resolveFromContext(ctx)` to locate it.
3435
3585
 
3436
3586
  ```ts
3437
3587
  const authHook = createAuthHook<User>({
@@ -3446,6 +3596,11 @@ differs. If you omit `resolveFromContext`, a scoped tool call has no identity
3446
3596
  and **fails closed** (rejected by `onAnonymous`) — the hook never silently
3447
3597
  passes a tool call it cannot authenticate. → [ADR 0014](../decisions/0014-tool-http-parity.md)
3448
3598
 
3599
+ ```ts
3600
+ createServer({ services, hooks: { authorize: authHook } })
3601
+ createMcpHandler({ services, lifecycle: { beforeHandle: authHook } })
3602
+ ```
3603
+
3449
3604
  ## `createBearerResolver`
3450
3605
 
3451
3606
  For API-key or bearer-token auth (the usual MCP case), `createBearerResolver`
@@ -3806,6 +3961,66 @@ Each sink runs fire-and-forget and fails independently: a slow or broken request
3806
3961
  sink cannot block the response, suppress operational logging or break the tool
3807
3962
  sink.
3808
3963
 
3964
+ The fire-and-forget work has an explicit bounded lifecycle:
3965
+
3966
+ ```ts
3967
+ export const observability = createObservability({
3968
+ request: {
3969
+ maxPending: 1_000,
3970
+ write: persistRequestEvent,
3971
+ onSinkError: ({ error, event }) => reportAuditFailure(error, event),
3972
+ onDrop: ({ reason, event, pending }) => {
3973
+ reportAuditDrop({ reason, traceId: event.traceId, pending })
3974
+ },
3975
+ },
3976
+ tools: {
3977
+ maxPending: 500,
3978
+ write: persistToolEvent,
3979
+ onSinkError: ({ error, event }) => reportToolAuditFailure(error, event),
3980
+ onDrop: reportToolAuditDrop,
3981
+ },
3982
+ })
3983
+
3984
+ await observability.flush()
3985
+ await observability.close()
3986
+ ```
3987
+
3988
+ `maxPending` defaults to `1000` per sink and must be a positive safe integer.
3989
+ Filtering and sanitisation happen before a write occupies a pending slot. At
3990
+ capacity, a new event is rejected with `onDrop({ reason: 'capacity', ... })`;
3991
+ after close, admission reports `reason: 'closed'`. Existing writes are never
3992
+ cancelled. Sink, filter and diagnostic-callback failures remain isolated from
3993
+ the observed request or tool call and are reported through `onSinkError` when
3994
+ configured; `onSinkError`/`onDrop` cannot create unhandled rejections.
3995
+
3996
+ `flush()` snapshots the current generation and waits only for events admitted
3997
+ up to that call. `close()` atomically stops admission, drains every accepted
3998
+ generation and is idempotent. Graceful shutdown order is therefore:
3999
+
4000
+ 1. stop HTTP/MCP admission;
4001
+ 2. wait for active requests and tool calls;
4002
+ 3. `await observability.close()`;
4003
+ 4. close the database/storage connection used by the sinks.
4004
+
4005
+ Stitchkit manages only in-process delivery. If process-crash durability matters,
4006
+ make `write` enqueue into a consumer-owned durable outbox and let that adapter
4007
+ own retry, replay and storage policy:
4008
+
4009
+ ```ts
4010
+ interface AuditOutbox {
4011
+ enqueue(event: RequestEvent): Promise<void>
4012
+ }
4013
+
4014
+ const observability = createObservability({
4015
+ request: { write: (event) => outbox.enqueue(event) },
4016
+ tools: { write: (event) => outbox.enqueue(event) },
4017
+ })
4018
+ ```
4019
+
4020
+ The core intentionally contains no retry scheduler, database dependency or
4021
+ disk queue. A durable adapter can use `(traceId, spanId, source, toolPhase)` as
4022
+ its idempotency identity.
4023
+
3809
4024
  ### RequestEvent
3810
4025
 
3811
4026
  Every surface produces the same shape — so a single audit table stays
@@ -4337,8 +4552,9 @@ Notes for a Node host:
4337
4552
  to join the two logs.
4338
4553
  - **Rate limiting** — `createRateLimiter` in `onRequest` for a global limit;
4339
4554
  per-route limits belong in `beforeHandle`.
4340
- - **Auth** — a `createAuthHook` `beforeHandle` guards every transport at once;
4341
- do not re-check auth per handler.
4555
+ - **Auth** — wire one `createAuthHook` as HTTP `hooks.authorize` and tool
4556
+ `lifecycle.beforeHandle`; do not re-check auth per handler. HTTP authorization
4557
+ runs before JSON or multipart body reads.
4342
4558
  - **Errors** — handlers throw `AppError`; let the standard envelope render them.
4343
4559
  Add an `onError` hook only to integrate an error tracker.
4344
4560
  - **Secrets** — read them from the environment; never commit them.
@@ -4397,7 +4613,7 @@ matched value is on the context root as `ctx.tenantId`
4397
4613
  ```ts
4398
4614
  createServer({
4399
4615
  groups: [
4400
- { pathPrefix: '/tenants/:tenantId', services: [widgetsService], hooks: { beforeHandle: authHook } },
4616
+ { pathPrefix: '/tenants/:tenantId', services: [widgetsService], hooks: { authorize: authHook } },
4401
4617
  ],
4402
4618
  })
4403
4619
  // → /tenants/:tenantId/widgets
@@ -4411,7 +4627,7 @@ the flat `services` list — no hand-partitioning, the mapping lives in one plac
4411
4627
  createServer({
4412
4628
  services, // mixed scopes, listed once
4413
4629
  scopePrefixes: { tenant: 'tenants/:tenantId', project: 'projects/:projectId' },
4414
- hooks: { beforeHandle: authHook },
4630
+ hooks: { authorize: authHook },
4415
4631
  })
4416
4632
  // `tenant`-scoped → /tenants/:tenantId/..., `project` → /projects/:projectId/..., the rest flat
4417
4633
  ```
@@ -4612,6 +4828,119 @@ current one *up to* your target, and apply each snippet.
4612
4828
  runtime): bootstrap the server, one HTTP request, and any feature you rely on
4613
4829
  (Socket.IO connect, an MCP tool call, a multipart upload, …).
4614
4830
 
4831
+ ## Released migration: 0.48.0
4832
+
4833
+ ### Typed-client request options move to `.withOptions`
4834
+
4835
+ Generated endpoint methods reserve their ordinary call signature for contract
4836
+ variables. This keeps them directly assignable to callback APIs whose runtime
4837
+ supplies its own second context argument, including `react-query-kit` and
4838
+ TanStack Query. Move imperative cancellation to the callable's explicit method:
4839
+
4840
+ ```ts
4841
+ // before — endpoint with arguments
4842
+ await api.create({ name: 'Max' }, { signal })
4843
+
4844
+ // after
4845
+ await api.create.withOptions({ name: 'Max' }, { signal })
4846
+
4847
+ // before — endpoint without arguments
4848
+ await api.health({ signal })
4849
+
4850
+ // after
4851
+ await api.health.withOptions({ signal })
4852
+ ```
4853
+
4854
+ Direct query and mutation composition remains unchanged:
4855
+
4856
+ ```ts
4857
+ createMutation({ mutationFn: api.create })
4858
+ createQuery({ queryKey: ['search'], fetcher: api.search })
4859
+ ```
4860
+
4861
+ There is no positional-options alias. Ordinary generated methods ignore extra
4862
+ runtime callback arguments; only `.withOptions` reads `ClientRequestOptions`.
4863
+
4864
+ ## Released migration: 0.47.0
4865
+
4866
+ ### HTTP auth moves to the pre-body `authorize` phase
4867
+
4868
+ Move the HTTP wiring of `createAuthHook` from `beforeHandle` to `authorize`.
4869
+ This lets Stitchkit reject an unauthorized JSON or multipart request after path
4870
+ parameter validation but before reading a body chunk. Keep application
4871
+ preconditions that depend on validated input in `beforeHandle`.
4872
+
4873
+ ```ts
4874
+ const auth = createAuthHook({ authenticate, authorize })
4875
+
4876
+ // before
4877
+ createServer({ services, hooks: { beforeHandle: auth } })
4878
+
4879
+ // after
4880
+ createServer({ services, hooks: { authorize: auth } })
4881
+ ```
4882
+
4883
+ Tool transports already receive parsed input, so their wiring does not move:
4884
+
4885
+ ```ts
4886
+ createMcpHandler({ services, lifecycle: { beforeHandle: auth } })
4887
+ ```
4888
+
4889
+ If a custom HTTP authorization hook read `ctx.input`, `ctx.files` or raw body
4890
+ state, split it: identity/scope checks belong in `authorize`; validated payload
4891
+ preconditions belong in `beforeHandle` or the domain service.
4892
+
4893
+ ### Multipart uses a typed descriptor and `ctx.files`
4894
+
4895
+ Replace every string multipart declaration, top-level `maxUploadBytes` and
4896
+ `ctx.file`. The descriptor is now the only source of request, per-file,
4897
+ cardinality and declared media-type policy.
4898
+
4899
+ ```ts
4900
+ // before
4901
+ upload: {
4902
+ method: 'POST',
4903
+ path: '/',
4904
+ multipart: 'file',
4905
+ maxUploadBytes: 25 * 1024 * 1024,
4906
+ }
4907
+ upload: ({ file, input }) => store(file, input)
4908
+
4909
+ // after
4910
+ upload: {
4911
+ method: 'POST',
4912
+ path: '/',
4913
+ multipart: {
4914
+ maxRequestBytes: 25 * 1024 * 1024,
4915
+ files: {
4916
+ file: {
4917
+ maxBytes: 20 * 1024 * 1024,
4918
+ contentTypes: ['image/*', 'application/pdf'],
4919
+ },
4920
+ },
4921
+ },
4922
+ }
4923
+ upload: ({ files, input }) => store(files.file, input)
4924
+ ```
4925
+
4926
+ Multiple files are repeated under one multipart field name and arrive in the
4927
+ same order:
4928
+
4929
+ ```ts
4930
+ files: {
4931
+ attachments: { multiple: true, maxFiles: 8 },
4932
+ }
4933
+
4934
+ await api.upload({ attachments: [firstFile, secondFile] })
4935
+ // handler: files.attachments is File[]
4936
+ ```
4937
+
4938
+ For direct-to-storage delivery, set `delivery: 'stream'` and implement the
4939
+ endpoint with `defineMultipartStream`. A receiver must consume its Web Stream
4940
+ and return `{ value, cleanup }`; the final handler sees only receiver values.
4941
+ There is no deprecated overload or buffered compatibility path under the old
4942
+ contract shape.
4943
+
4615
4944
  ## Worked example — frozen on 0.3, jumping to 0.7
4616
4945
 
4617
4946
  1. `bun.lock` → consumer resolves `stitchkit@0.3.x`.
@@ -5299,6 +5628,7 @@ The browser-and-server entrypoint. Re-exports everything from
5299
5628
  | `createUrlBuilders` | function | build one exact URL builder per contract in a registry |
5300
5629
  | `UrlBuilderConfig` | _type_ | explicit `{ baseUrl }` source for a URL builder |
5301
5630
  | `ClientConfig` | _type_ | config for `createClient`'s bare-fetch mode (2nd arg, no `HttpClient`) |
5631
+ | `ClientRequestOptions` | _type_ | per-call `{ signal?: AbortSignal }` passed through an endpoint callable's `.withOptions(...)`; caller abort is distinct from timeout — [guide](../guide/client.md#per-call-cancellation) |
5302
5632
  | `ContractClientConfig` | _type_ | per-tenant / resource-scoped client config — dynamic `pathPrefix` + `stripPrefixKeys` ([guide](../guide/client.md#contractclientconfig--per-tenant--resource-scoped-clients)) |
5303
5633
  | `contractEndpointMatchers` | function | compile exact pathname matchers for selected HTTP contract operations and expected-401 policy |
5304
5634
  | `PathPrefixArgs` | _type_ | required string-valued keys exposed to a typed dynamic `pathPrefix` callback |
@@ -5388,6 +5718,7 @@ from the root `stitchkit`.
5388
5718
  | `TransportSource` | _type_ | `http \| mcp \| agent \| cli` — the value of `ctx.source` |
5389
5719
  | `RuntimeContext` | _type_ | the loose context seen by transport and hooks |
5390
5720
  | `HandlerContext` | _type_ | the typed context seen by a handler |
5721
+ | `EndpointHandlerContext` | _type_ | one endpoint handler's fully inferred params, input, files and runtime context |
5391
5722
  | `EndpointFn` | _type_ | the call signature of one client method |
5392
5723
  | `TypedClient` | _type_ | the full typed client for a contract |
5393
5724
  | `TypedHttpClient` | _type_ | the typed client, HTTP endpoints only (`= ScopedHttpClient<C, unknown>`) |
@@ -5398,6 +5729,9 @@ from the root `stitchkit`.
5398
5729
  | `ScopedUrlFn` | _type_ | one URL method's signature with scoped-prefix keys folded in |
5399
5730
  | `MultipartFile` | _type_ | a `multipart` file field — `Blob \| FileDescriptor` |
5400
5731
  | `FileDescriptor` | _type_ | a React Native / Expo file — `{ uri, name, type }` |
5732
+ | `MultipartDescriptor` | _type_ | file fields, cardinality, delivery and request/text limits |
5733
+ | `MultipartFilePolicy` | _type_ | required/multiple, per-file bytes/count and declared MIME policy |
5734
+ | `MultipartBufferedFiles` | _type_ | `File` map inferred from a multipart descriptor |
5401
5735
  | `EndpointToolAnnotations` | _type_ | MCP behavioural hints on an endpoint (`readOnlyHint` / `destructiveHint` / `title`) |
5402
5736
  | `EndpointUiMeta` | _type_ | MCP Apps widget metadata on an endpoint |
5403
5737
  | `EndpointMcpInputRequired` | _type_ | typed MCP multi-round input request (`key`, message and Zod object schema) |
@@ -5476,7 +5810,8 @@ Also re-exports the error helpers from `stitchkit/contract`.
5476
5810
  | `MethodDef` | _type_ | one resolved endpoint inside a service |
5477
5811
  | `OperationIdentity` | _type_ | path-free service/action/scope/method identity shared by contract and native tool operations |
5478
5812
  | `Handlers` | _type_ | the typed handler map `implement` expects |
5479
- | `LifecycleHooks` | _type_ | `onRequest` / `beforeHandle` / `afterHandle` / `onError` |
5813
+ | `LifecycleHooks` | _type_ | `onRequest` / pre-body `authorize` / `beforeHandle` / `afterHandle` / `onError` |
5814
+ | `AuthorizationContext` | _type_ | HTTP pre-body context with validated params, `input: undefined` and no files |
5480
5815
  | `RouteGroup` | _type_ | a prefixed group of services with its own hooks |
5481
5816
  | `RawRoute` | _type_ | a non-contract `Request → Response` route with a concrete `BunServer` context |
5482
5817
  | `RawRouteContext` | _type_ | the Bun-bound routing context a raw handler receives |
@@ -5493,7 +5828,7 @@ Also re-exports the error helpers from `stitchkit/contract`.
5493
5828
 
5494
5829
  | Export | Kind | Summary |
5495
5830
  |--------|------|---------|
5496
- | `createAuthHook` | function | a scope-enforcing `beforeHandle` hook — [guide](../guide/auth-and-errors.md#createauthhook) |
5831
+ | `createAuthHook` | function | one scope gate for HTTP `authorize` and tool `beforeHandle` — [guide](../guide/auth-and-errors.md#createauthhook) |
5497
5832
  | `createErrorHook` | function | an async-capable, endpoint-aware `onError` hook from a code map + envelope renderer — [guide](../guide/auth-and-errors.md#createerrorhook) |
5498
5833
  | `ErrorHookConfig` | _type_ | async observer/renderer config for `createErrorHook` |
5499
5834
  | `ResolvedError` | _type_ | the normalised error handed to `createErrorHook`'s `render` |
@@ -5551,8 +5886,14 @@ Also re-exports the error helpers from `stitchkit/contract`.
5551
5886
  |--------|------|---------|
5552
5887
  | `streamSSE` | function | an async generator → SSE `Response` — [guide](../guide/server.md#sse-streaming) |
5553
5888
  | `parseSSE` | function | parse an SSE `Response` (also on the root entrypoint) |
5889
+ | `MultipartLifecycle` | _type_ | request-scoped rollback ownership for accepted streamed handles |
5554
5890
  | `MultipartResult` | _type_ | what `parseMultipart` returns |
5555
- | `parseMultipart` | function | parse a `multipart/form-data` request — [guide](../guide/server.md#multipart) |
5891
+ | `parseMultipart` | function | parse a typed buffered/streaming multipart descriptor — [guide](../guide/server.md#multipart) |
5892
+ | `defineMultipartStream` | function | bind typed streaming file receivers and a final endpoint handler |
5893
+ | `MultipartFileMetadata` | _type_ | field, filename, declared media type and optional declared size |
5894
+ | `MultipartReceiver` | _type_ | consumer-owned Web-stream storage receiver |
5895
+ | `MultipartReceiverResult` | _type_ | receiver value plus rollback cleanup |
5896
+ | `StreamingMultipartImplementation` | _type_ | receiver registry and handler shape inferred by `defineMultipartStream` |
5556
5897
  | `createRateLimiter` | function | token-bucket rate limiting — [guide](../guide/server.md#rate-limiting) |
5557
5898
  | `createCache` | function | an in-memory TTL cache |
5558
5899
  | `CacheOptions` | _type_ | bounded-cache options, including the maximum retained entry count |
@@ -5597,9 +5938,12 @@ audit event. See the [Observability guide](../guide/observability.md).
5597
5938
  | `createObservability` | function | configure framework-owned request completion and canonical tool event sinks — [guide](../guide/observability.md#createobservability) |
5598
5939
  | `RequestEvent` | _type_ | the normalised audit event handed to the sink |
5599
5940
  | `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 |
5941
+ | `Observability` | _type_ | `{ request?, toolCall, flush(), close() }` with bounded sink lifecycle |
5942
+ | `RequestEventSinkConfig` | _type_ | `write`, filter/sanitisation, `maxPending`, `onSinkError` and `onDrop` |
5602
5943
  | `RequestObservabilityConfig` | _type_ | request sink plus opt-in payload capture |
5944
+ | `SinkDropReason` | _type_ | `'capacity' \| 'closed'` |
5945
+ | `SinkError` | _type_ | isolated sink/projection failure and optional event |
5946
+ | `SinkDrop` | _type_ | rejected event, reason and current pending count |
5603
5947
  | `HttpRequestCompletion` | _type_ | the single framework-owned HTTP outcome projected to logging and request events |
5604
5948
  | `HttpRequestObserver` | _type_ | server-facing projection consumed by `HandlerConfig.observability` |
5605
5949