stitchkit 0.45.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 (122) hide show
  1. package/README.md +5 -4
  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/browser/socket-io.d.ts +3 -1
  11. package/dist/browser/socket-io.d.ts.map +1 -1
  12. package/dist/cli.js +3 -4
  13. package/dist/contract/define.d.ts +59 -15
  14. package/dist/contract/define.d.ts.map +1 -1
  15. package/dist/contract/errors.d.ts +1 -0
  16. package/dist/contract/errors.d.ts.map +1 -1
  17. package/dist/contract/index.d.ts +1 -1
  18. package/dist/contract/index.d.ts.map +1 -1
  19. package/dist/contract/index.js +2 -2
  20. package/dist/{index-w0mec79h.js → index-44xysy8r.js} +697 -301
  21. package/dist/{index-r6czv7yt.js → index-45dz4m51.js} +50 -3
  22. package/dist/index-8ekq6res.js +181 -0
  23. package/dist/{index-yh0p5ddn.js → index-ee621cmy.js} +39 -19
  24. package/dist/{index-mvjyq9h9.js → index-kp8xamqp.js} +309 -144
  25. package/dist/{index-36f1xkdb.js → index-nrytvb30.js} +5 -4
  26. package/dist/{index-6jypn22c.js → index-r1qp4rve.js} +9 -1
  27. package/dist/{index-s4qsmgwe.js → index-tss6bk5c.js} +4 -1
  28. package/dist/index-yxpe3phd.js +63 -0
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +315 -134
  32. package/dist/internal/errors.d.ts.map +1 -1
  33. package/dist/internal/secure-fetch.d.ts +23 -3
  34. package/dist/internal/secure-fetch.d.ts.map +1 -1
  35. package/dist/internal/typed.d.ts +7 -0
  36. package/dist/internal/typed.d.ts.map +1 -1
  37. package/dist/internal/within-dir.d.ts +6 -0
  38. package/dist/internal/within-dir.d.ts.map +1 -1
  39. package/dist/logger.d.ts +8 -0
  40. package/dist/logger.d.ts.map +1 -0
  41. package/dist/node.js +3 -4
  42. package/dist/observability/audit.d.ts +22 -3
  43. package/dist/observability/audit.d.ts.map +1 -1
  44. package/dist/observability/event.d.ts +7 -0
  45. package/dist/observability/event.d.ts.map +1 -1
  46. package/dist/observability/index.d.ts +1 -1
  47. package/dist/observability/index.d.ts.map +1 -1
  48. package/dist/observability/index.js +130 -134
  49. package/dist/observability/sanitize.d.ts +8 -1
  50. package/dist/observability/sanitize.d.ts.map +1 -1
  51. package/dist/observability/trace.d.ts +3 -1
  52. package/dist/observability/trace.d.ts.map +1 -1
  53. package/dist/react/cache-bridge.d.ts +4 -0
  54. package/dist/react/cache-bridge.d.ts.map +1 -1
  55. package/dist/react.js +46 -5
  56. package/dist/realtime/contract.d.ts +4 -1
  57. package/dist/realtime/contract.d.ts.map +1 -1
  58. package/dist/realtime/rejection.d.ts +10 -0
  59. package/dist/realtime/rejection.d.ts.map +1 -0
  60. package/dist/realtime/socket.d.ts +14 -1
  61. package/dist/realtime/socket.d.ts.map +1 -1
  62. package/dist/server/cache.d.ts +5 -1
  63. package/dist/server/cache.d.ts.map +1 -1
  64. package/dist/server/context.d.ts +8 -5
  65. package/dist/server/context.d.ts.map +1 -1
  66. package/dist/server/create.d.ts.map +1 -1
  67. package/dist/server/error-hook.d.ts +1 -0
  68. package/dist/server/error-hook.d.ts.map +1 -1
  69. package/dist/server/file.d.ts +6 -0
  70. package/dist/server/file.d.ts.map +1 -1
  71. package/dist/server/implement.d.ts +33 -2
  72. package/dist/server/implement.d.ts.map +1 -1
  73. package/dist/server/index.d.ts +5 -6
  74. package/dist/server/index.d.ts.map +1 -1
  75. package/dist/server/index.js +133 -14
  76. package/dist/server/middleware/auth.d.ts +7 -4
  77. package/dist/server/middleware/auth.d.ts.map +1 -1
  78. package/dist/server/middleware/cors.d.ts.map +1 -1
  79. package/dist/server/multipart.d.ts +13 -18
  80. package/dist/server/multipart.d.ts.map +1 -1
  81. package/dist/server/openapi.d.ts.map +1 -1
  82. package/dist/server/realtime.d.ts +2 -0
  83. package/dist/server/realtime.d.ts.map +1 -1
  84. package/dist/server/router.d.ts +0 -8
  85. package/dist/server/router.d.ts.map +1 -1
  86. package/dist/server/stream.d.ts.map +1 -1
  87. package/dist/server/types.d.ts +50 -22
  88. package/dist/server/types.d.ts.map +1 -1
  89. package/dist/tools/cli-args.d.ts +11 -3
  90. package/dist/tools/cli-args.d.ts.map +1 -1
  91. package/dist/tools/cli.d.ts +5 -0
  92. package/dist/tools/cli.d.ts.map +1 -1
  93. package/dist/tools/coerce.d.ts.map +1 -1
  94. package/dist/tools/json-schema.d.ts.map +1 -1
  95. package/dist/tools/mcp-handler.d.ts +4 -3
  96. package/dist/tools/mcp-handler.d.ts.map +1 -1
  97. package/dist/tools/mcp-prepare.d.ts +95 -0
  98. package/dist/tools/mcp-prepare.d.ts.map +1 -0
  99. package/dist/tools/mcp-round.d.ts +4 -0
  100. package/dist/tools/mcp-round.d.ts.map +1 -1
  101. package/dist/tools/mcp-stdio.d.ts +2 -1
  102. package/dist/tools/mcp-stdio.d.ts.map +1 -1
  103. package/dist/tools/mcp.d.ts +2 -78
  104. package/dist/tools/mcp.d.ts.map +1 -1
  105. package/dist/tools/mount-download.d.ts +5 -0
  106. package/dist/tools/mount-download.d.ts.map +1 -1
  107. package/dist/tools/native-mcp.d.ts +1 -1
  108. package/dist/tools/native-mcp.d.ts.map +1 -1
  109. package/dist/tools/oauth-provider.d.ts +20 -1
  110. package/dist/tools/oauth-provider.d.ts.map +1 -1
  111. package/dist/tools/tool-logger.d.ts +1 -1
  112. package/dist/tools/tool-logger.d.ts.map +1 -1
  113. package/dist/tools/view-file.d.ts +9 -0
  114. package/dist/tools/view-file.d.ts.map +1 -1
  115. package/dist/tools.d.ts +2 -1
  116. package/dist/tools.d.ts.map +1 -1
  117. package/dist/tools.js +505 -278
  118. package/llms-full.txt +473 -85
  119. package/llms.txt +1 -1
  120. package/package.json +2 -2
  121. package/dist/index-frfyw9fa.js +0 -33
  122. package/dist/index-x3fcszf8.js +0 -8
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,10 +714,9 @@ 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
- | `cors` | CORS policy — `{ origin, credentials, methods, headers, exposeHeaders }` |
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. |
681
720
  | `hooks` | lifecycle hooks (see below) |
682
721
  | `logging` | `true` for built-in request logs, or a `LoggingConfig` (see below) |
683
722
  | `traceId` | override per-request trace-id resolution — may return `undefined` to fall back |
@@ -685,6 +724,34 @@ server. See [Testing & deployment](./testing-and-deployment.md).
685
724
  | `websocket` | Bun WebSocket handlers — e.g. from `createSocketIOServer` |
686
725
  | `routes` / `development` / `bun` | passthrough to `Bun.serve` |
687
726
 
727
+ ### Trusted HTTPS in development
728
+
729
+ Stitchkit does not own local certificate generation or a frontend development
730
+ server. When a device or browser feature requires a secure context, generate a
731
+ trusted certificate outside the application (for example with `mkcert`) and pass
732
+ its files through the Bun server boundary:
733
+
734
+ ```ts
735
+ import { readFileSync } from 'node:fs'
736
+ import { createServer } from 'stitchkit/server'
737
+
738
+ createServer({
739
+ services,
740
+ hostname: '0.0.0.0',
741
+ bun: {
742
+ tls: {
743
+ cert: readFileSync('./certs/dev.pem'),
744
+ key: readFileSync('./certs/dev-key.pem'),
745
+ },
746
+ },
747
+ })
748
+ ```
749
+
750
+ Trust the certificate authority on each test device and configure the frontend's
751
+ HTTPS mode in that frontend project. Certificate renewal, interface discovery
752
+ and device onboarding remain application infrastructure rather than framework
753
+ or starter behavior.
754
+
688
755
  ### Request logging
689
756
 
690
757
  `logging: true` is shorthand for `logging: {}` — **any object turns logging
@@ -766,7 +833,7 @@ A group gives a set of services a shared path prefix and its own hooks:
766
833
  createServer({
767
834
  groups: [
768
835
  { pathPrefix: '/api', services: [usersService, postsService] },
769
- { pathPrefix: '/api/admin', services: [adminService], hooks: { beforeHandle: adminAuth } },
836
+ { pathPrefix: '/api/admin', services: [adminService], hooks: { authorize: adminAuth } },
770
837
  ],
771
838
  })
772
839
  ```
@@ -782,7 +849,7 @@ or resource-scoped API:
782
849
  ```ts
783
850
  createServer({
784
851
  groups: [
785
- { pathPrefix: '/tenants/:tenantId', services: [widgetsService], hooks: { beforeHandle: auth } },
852
+ { pathPrefix: '/tenants/:tenantId', services: [widgetsService], hooks: { authorize: auth } },
786
853
  ],
787
854
  })
788
855
  // widgetsService (prefix 'widgets') → /tenants/:tenantId/widgets/...
@@ -791,8 +858,8 @@ createServer({
791
858
  **Where the prefix param lands.** The router matches the *full* path (group
792
859
  prefix + service prefix + endpoint path) and collects every `:param` — from the
793
860
  prefix and from the endpoint alike — into one set. Each is spread onto the
794
- context root, so it is available as **`ctx.tenantId`** (a raw `string`) in both
795
- 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:
796
863
 
797
864
  ```ts
798
865
  beforeHandle: (ctx) => {
@@ -856,14 +923,17 @@ Scope stays a free string; the core attaches no meaning beyond this lookup
856
923
 
857
924
  ## Lifecycle hooks
858
925
 
859
- 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:
860
929
 
861
930
  ```ts
862
931
  createServer({
863
932
  services,
864
933
  hooks: {
865
934
  onRequest(req) { /* logging, global rate limit — may return a Response to short-circuit */ },
866
- 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 */ },
867
937
  afterHandle(ctx, result, ep) { /* transform the result data */ },
868
938
  onError(ctx, error, ep) { /* custom error response — return a Response */ },
869
939
  },
@@ -872,9 +942,13 @@ createServer({
872
942
 
873
943
  - **`onRequest`** — runs first, with the raw `Request`. Return a `Response` to
874
944
  short-circuit (a rate-limit 429, a redirect); return nothing to continue.
875
- - **`beforeHandle`** — runs after the context is built, before the handler.
876
- Throw an `AppError` to reject. This is where auth lives —
877
- [`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.
878
952
  - **`afterHandle`** — receives the handler result; return a replacement to
879
953
  transform it.
880
954
  - **`onError`** — receives any thrown error; return a `Response` to customise
@@ -1177,7 +1251,7 @@ focused helper — not a sub-framework.
1177
1251
  |--------|------|
1178
1252
  | `serveFile()` | serve a file with `Range` / `304` / `HEAD` (media seeking) |
1179
1253
  | `streamSSE()` | turn an `AsyncGenerator` into a Server-Sent-Events `Response` |
1180
- | `parseMultipart()` | parse a `multipart/form-data` request with a size cap |
1254
+ | `parseMultipart()` | parse a typed buffered/streaming multipart descriptor |
1181
1255
  | `createRateLimiter()` | per-key token-bucket rate limiting |
1182
1256
  | `createCache()` + `cacheHeaders()` | in-memory TTL cache; `Cache-Control` builder |
1183
1257
  | `createEventBus<EventMap>()` | typed in-process pub/sub |
@@ -1204,36 +1278,95 @@ The client side is [`parseSSE`](./client.md#sse).
1204
1278
 
1205
1279
  ### Multipart
1206
1280
 
1207
- ```ts
1208
- import { parseMultipart } from 'stitchkit/server'
1281
+ The contract owns one descriptor for buffered and streaming delivery:
1209
1282
 
1210
- const { file, fields } = await parseMultipart(req, { maxBytes: 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
+ })
1211
1299
  ```
1212
1300
 
1213
- When an endpoint declares `multipart`, the framework parses the upload for you
1214
- and the file arrives as `ctx.file` call `parseMultipart` directly only from a
1215
- 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.
1216
1308
 
1217
- The upload cap defaults to **25 MB**. Raise it per route with
1218
- `EndpointDef.maxUploadBytes`, or set a server-wide default with
1219
- `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:
1220
1312
 
1221
1313
  ```ts
1222
- // contract
1223
- upload: { method: 'POST', path: '/', desc: 'Upload a video',
1224
- 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
+ })
1225
1326
 
1226
- // server default for every multipart route that declares no own cap
1227
- 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
+ })
1228
1341
  ```
1229
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
+
1230
1360
  ### Rate limiting
1231
1361
 
1232
1362
  ```ts
1233
1363
  import { createRateLimiter } from 'stitchkit/server'
1234
1364
 
1235
- const limiter = createRateLimiter({ capacity: 60, refillPerSecond: 1 })
1236
- // in onRequest: if (!limiter.take(ip)) return new Response('Too many', { status: 429 })
1365
+ const limiter = createRateLimiter()
1366
+ // in onRequest:
1367
+ if (!limiter.check(ip, { window: 60_000, max: 60 })) {
1368
+ return new Response('Too many', { status: 429 })
1369
+ }
1237
1370
  ```
1238
1371
 
1239
1372
  ### Event bus
@@ -1419,10 +1552,43 @@ contract:
1419
1552
  - for `GET` / `DELETE`, the remaining fields become the **query string**
1420
1553
  (arrays become repeated keys),
1421
1554
  - for `POST` / `PUT` / `PATCH`, they become the **JSON body**,
1422
- - a `multipart` field is a `Blob` (web / Bun) or a platform `FileDescriptor`
1423
- (`{ uri, name, type }`, for React Native / Expo) and is sent as `form-data`.
1424
- The exported `MultipartFile` / `FileDescriptor` types let you annotate your own
1425
- 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.
1426
1592
 
1427
1593
  ### Many contracts at once
1428
1594
 
@@ -1999,15 +2165,15 @@ handlers must still be idempotent where retries matter.
1999
2165
 
2000
2166
  A tool call runs the same handler an HTTP request would. `lifecycle` makes it
2001
2167
  run the same gate: a `beforeHandle` (throw to reject) and an `afterHandle`
2002
- (transform the result) — the tool-side twin of `createServer`'s hooks. Pass the
2003
- **same** [`createAuthHook`](./auth-and-errors.md#createauthhook) result you give
2004
- 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:
2005
2171
 
2006
2172
  ```ts
2007
2173
  createMcpHandler({ serverInfo, auth, services, lifecycle: { beforeHandle: authHook } })
2008
2174
  ```
2009
2175
 
2010
- 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
2011
2177
  `scope` is not enforced on the MCP / agent surface. `mountMcp`, `mountAgent` and
2012
2178
  `buildMcpServer` take `lifecycle` too.
2013
2179
 
@@ -2259,6 +2425,15 @@ timeouts, redirect count and body size are bounded. Its bounded cache respects
2259
2425
  `Last-Modified`, coalesces concurrent misses and reports sanitized cache events;
2260
2426
  invalid or unavailable identity receives only a short fail-closed cache entry.
2261
2427
 
2428
+ Resolution itself is rate-limited on two levels (`CimdCachePolicy`):
2429
+ `maxResolutionsPerClient` (default 10) bounds ONE `client_id` per
2430
+ `resolutionWindowMs` window (default 60 s) — a client whose document disables
2431
+ caching burns its own budget, not the server's — while `maxResolutions`
2432
+ (default 120) caps the server-wide outbound rate as the flood backstop.
2433
+ Positive and negative cache entries keep separate pools of `maxEntries` each,
2434
+ so a flood of unresolvable ids cannot evict warmed clients and a burst of
2435
+ successes cannot reset a failing client's backoff.
2436
+
2262
2437
  ### Authorization hardening (MCP 2026-07-28)
2263
2438
 
2264
2439
  - **`iss` on every authorization response (RFC 9207, SEP-2468).** Success *and*
@@ -2360,7 +2535,7 @@ the merged `params` + `input`. `context` is merged into every tool handler's
2360
2535
  | `runtimeTools` | framework-managed pathless operations from `defineRuntimeTool` |
2361
2536
 
2362
2537
  `lifecycle` works the same as on the MCP server — without it an agent tool call
2363
- bypasses the HTTP `beforeHandle` auth gate. Pass your `createAuthHook` result.
2538
+ bypasses the HTTP `authorize` gate. Pass your `createAuthHook` result.
2364
2539
 
2365
2540
  ### Adding tool-only args — `extend`
2366
2541
 
@@ -2586,6 +2761,11 @@ mountMcp(server, services, { hooks: createToolLogger() })
2586
2761
  // [tool] warn get_widget (widgets.get) NOT_FOUND 4ms
2587
2762
  ```
2588
2763
 
2764
+ By default the line goes to `console.error` — **stderr, never stdout**. That is
2765
+ deliberate: in a stdio-MCP server stdout is the JSON-RPC protocol channel, and a
2766
+ log line written there corrupts the stream. If you pass your own `log`, keep it
2767
+ off stdout for any stdio deployment.
2768
+
2589
2769
  Pass `log` to redirect the line, or `onRecord` to feed a metrics sink the
2590
2770
  structured `ToolCallRecord`. That record carries `traceId` whenever an
2591
2771
  observability context is active, so a tool call made inside an HTTP request
@@ -2750,7 +2930,7 @@ await createCli({
2750
2930
  version: '1.0.0',
2751
2931
  auth: await resolveIdentityFromToken(process.env.MYAPP_TOKEN),
2752
2932
  context: (identity) => ({ user: identity }), // resolveFromContext reads this
2753
- lifecycle: { beforeHandle: authHook }, // same gate as HTTP
2933
+ lifecycle: { beforeHandle: authHook }, // same policy; HTTP wires it as authorize
2754
2934
  services,
2755
2935
  })
2756
2936
  ```
@@ -2798,6 +2978,8 @@ fallback, heartbeats, acks, a mature client. stitchkit does not ship its own
2798
2978
  WebSocket engine; it ships thin, typed wrappers over Socket.IO and a bridge that
2799
2979
  syncs socket events into the TanStack Query cache. See
2800
2980
  [ADR 0008](../decisions/0008-thin-wrappers.md).
2981
+ The separate contract shape and rejection ownership are recorded in
2982
+ [ADR 0069](../decisions/0069-realtime-contracts-validate-without-owning-delivery.md).
2801
2983
 
2802
2984
  ## Zod-first event contract
2803
2985
 
@@ -2870,6 +3052,16 @@ createServer({
2870
3052
  realtime.emit('note:created', note)
2871
3053
  ```
2872
3054
 
3055
+ The canonical room-broadcast example below is executed by the test suite. Its
3056
+ body is kept byte-identical to `packages/core/examples/realtime-room.ts`.
3057
+
3058
+ ```ts canonical-realtime-room
3059
+ export function publishExampleNote(realtime: ExampleRealtimePublisher): void {
3060
+ const note = { id: 'note-1', text: 'Ready' };
3061
+ realtime.to('general').emit('note:created', note);
3062
+ }
3063
+ ```
3064
+
2873
3065
  | Handle field | Purpose |
2874
3066
  |--------------|---------|
2875
3067
  | `io` | raw Socket.IO server for middleware, handshake auth and transport ownership |
@@ -3127,11 +3319,9 @@ policies; this helper only applies declared CRUD semantics.
3127
3319
 
3128
3320
  ## Raw binary lane (Bun)
3129
3321
 
3130
- Socket.IO carries binary fine for most streams a binary event (`pcm(frame)`)
3131
- is enough. But a *truly* high-throughput binary channel (video, large
3132
- transfers) may want a raw WebSocket with no Socket.IO framing, on the **same**
3133
- port. On Bun that is awkward: `Bun.serve` has a single `websocket` handler, and
3134
- `createSocketIOServer().websocket` claims it.
3322
+ For a high-throughput raw binary channel beside Socket.IO, use the orthogonal
3323
+ composition boundary from
3324
+ [ADR 0020](../decisions/0020-raw-websocket-lane.md).
3135
3325
 
3136
3326
  `composeWebSocketHandlers` composes that one handler from several lanes. A raw
3137
3327
  lane stamps its own marker onto `ws.data` at upgrade and is matched positively;
@@ -3293,10 +3483,12 @@ The framework attaches no meaning to the strings — `'public'`, `'user'`,
3293
3483
 
3294
3484
  ## `createAuthHook`
3295
3485
 
3296
- `createAuthHook` builds a `beforeHandle` hook that enforces `endpoint.scope`.
3297
- Every request runs the same three steps resolve the identity, read the scope,
3298
- allow / 401 / 403 so the flow lives in the framework and you supply only
3299
- `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.
3300
3492
 
3301
3493
  ```ts
3302
3494
  import { createAuthHook, createServer } from 'stitchkit/server'
@@ -3311,7 +3503,7 @@ const authHook = createAuthHook<User>({
3311
3503
  inject: (ctx, user) => { ctx.user = user },
3312
3504
  })
3313
3505
 
3314
- createServer({ services, hooks: { beforeHandle: authHook } })
3506
+ createServer({ services, hooks: { authorize: authHook } })
3315
3507
  ```
3316
3508
 
3317
3509
  ### `AuthRule`
@@ -3321,8 +3513,9 @@ The value of each `rules` entry, keyed by scope:
3321
3513
  - **`'public'`** — always passes; the identity is attached if present.
3322
3514
  - **`'authenticated'`** — any resolved identity passes; no identity ⇒ 401.
3323
3515
  - **a function** `(identity, ctx) => boolean | Promise<boolean>` — a custom
3324
- check. It receives the full context, so a resource-scoped rule can read the
3325
- 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.
3326
3519
 
3327
3520
  #### Resource-scoped rule — reading a path/prefix param
3328
3521
 
@@ -3369,14 +3562,15 @@ catches a scope you forgot to cover.
3369
3562
 
3370
3563
  ### Auth on the tool surface — `resolveFromContext`
3371
3564
 
3372
- The hook runs in `beforeHandle`, so it guards **every transport** — HTTP, MCP
3373
- and agent calls all pass through it. But identity is resolved differently per
3374
- surface:
3565
+ The same hook guards every transport, but the lifecycle slot and identity
3566
+ source differ:
3375
3567
 
3376
- - **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.
3377
3570
  - **Tool calls (MCP / agent)** — there is no `req`. The transport authenticated
3378
3571
  the caller (an MCP API key) and `buildMcpServer`'s `context` injected the
3379
- identity into `ctx`. `resolveFromContext(ctx)` locates it.
3572
+ identity into `ctx`. `lifecycle.beforeHandle` calls
3573
+ `resolveFromContext(ctx)` to locate it.
3380
3574
 
3381
3575
  ```ts
3382
3576
  const authHook = createAuthHook<User>({
@@ -3391,6 +3585,11 @@ differs. If you omit `resolveFromContext`, a scoped tool call has no identity
3391
3585
  and **fails closed** (rejected by `onAnonymous`) — the hook never silently
3392
3586
  passes a tool call it cannot authenticate. → [ADR 0014](../decisions/0014-tool-http-parity.md)
3393
3587
 
3588
+ ```ts
3589
+ createServer({ services, hooks: { authorize: authHook } })
3590
+ createMcpHandler({ services, lifecycle: { beforeHandle: authHook } })
3591
+ ```
3592
+
3394
3593
  ## `createBearerResolver`
3395
3594
 
3396
3595
  For API-key or bearer-token auth (the usual MCP case), `createBearerResolver`
@@ -3414,9 +3613,11 @@ pick the scheme), and checks `exp` and `nbf`.
3414
3613
 
3415
3614
  ```ts
3416
3615
  import { verifyJwt, extractToken } from 'stitchkit/server'
3616
+ import { env } from './env'
3417
3617
 
3418
3618
  const token = extractToken(req) // from Authorization, or a cookie name
3419
- const payload = await verifyJwt(token, process.env.JWT_SECRET!)
3619
+ if (!token) throw unauthorized('Bearer token is required')
3620
+ const payload = await verifyJwt(token, env.JWT_SECRET)
3420
3621
  ```
3421
3622
 
3422
3623
  `extractToken(req, cookieName?)` reads a bearer token from the `Authorization`
@@ -3614,6 +3815,7 @@ const onError = createErrorHook({
3614
3815
  NOT_FOUND: 'not_found', METHOD_NOT_ALLOWED: 'not_found',
3615
3816
  CONFLICT: 'conflict', RATE_LIMITED: 'rate_limited',
3616
3817
  INTERNAL_SERVER_ERROR: 'internal',
3818
+ REALTIME_CONTRACT_VIOLATION: 'internal',
3617
3819
  } satisfies Record<StitchErrorCode, string>,
3618
3820
  // `ctx` is the request's RuntimeContext — read `ctx.traceId` for a
3619
3821
  // correlation id in the envelope. Declaring it is optional.
@@ -3748,6 +3950,66 @@ Each sink runs fire-and-forget and fails independently: a slow or broken request
3748
3950
  sink cannot block the response, suppress operational logging or break the tool
3749
3951
  sink.
3750
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
+
3751
4013
  ### RequestEvent
3752
4014
 
3753
4015
  Every surface produces the same shape — so a single audit table stays
@@ -3967,8 +4229,10 @@ each request.
3967
4229
 
3968
4230
  A payload goes into an audit row only after `sanitizePayload`:
3969
4231
 
3970
- - **secret-named keys are masked** — `password`, `token`, `apiKey`, `secret`,
3971
- `authorization`, `cookie`, (value `[redacted]`);
4232
+ - **secret-named keys are masked** — a key whose words include a secret term is
4233
+ redacted (`password`, `sessionToken`, `X-Api-Key`, `authorization`, `cookie`,
4234
+ …), while identifiers that merely contain one (`authorId`, `sessionCount`,
4235
+ `tokenizer`) survive;
3972
4236
  - **binary blobs** (`Uint8Array`, `Blob`, `FormData`) collapse to metadata —
3973
4237
  never the bytes;
3974
4238
  - the result is **capped** — anything over the byte limit becomes a preview.
@@ -4277,8 +4541,9 @@ Notes for a Node host:
4277
4541
  to join the two logs.
4278
4542
  - **Rate limiting** — `createRateLimiter` in `onRequest` for a global limit;
4279
4543
  per-route limits belong in `beforeHandle`.
4280
- - **Auth** — a `createAuthHook` `beforeHandle` guards every transport at once;
4281
- 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.
4282
4547
  - **Errors** — handlers throw `AppError`; let the standard envelope render them.
4283
4548
  Add an `onError` hook only to integrate an error tracker.
4284
4549
  - **Secrets** — read them from the environment; never commit them.
@@ -4337,7 +4602,7 @@ matched value is on the context root as `ctx.tenantId`
4337
4602
  ```ts
4338
4603
  createServer({
4339
4604
  groups: [
4340
- { pathPrefix: '/tenants/:tenantId', services: [widgetsService], hooks: { beforeHandle: authHook } },
4605
+ { pathPrefix: '/tenants/:tenantId', services: [widgetsService], hooks: { authorize: authHook } },
4341
4606
  ],
4342
4607
  })
4343
4608
  // → /tenants/:tenantId/widgets
@@ -4351,7 +4616,7 @@ the flat `services` list — no hand-partitioning, the mapping lives in one plac
4351
4616
  createServer({
4352
4617
  services, // mixed scopes, listed once
4353
4618
  scopePrefixes: { tenant: 'tenants/:tenantId', project: 'projects/:projectId' },
4354
- hooks: { beforeHandle: authHook },
4619
+ hooks: { authorize: authHook },
4355
4620
  })
4356
4621
  // `tenant`-scoped → /tenants/:tenantId/..., `project` → /projects/:projectId/..., the rest flat
4357
4622
  ```
@@ -4552,6 +4817,86 @@ current one *up to* your target, and apply each snippet.
4552
4817
  runtime): bootstrap the server, one HTTP request, and any feature you rely on
4553
4818
  (Socket.IO connect, an MCP tool call, a multipart upload, …).
4554
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
+
4555
4900
  ## Worked example — frozen on 0.3, jumping to 0.7
4556
4901
 
4557
4902
  1. `bun.lock` → consumer resolves `stitchkit@0.3.x`.
@@ -4564,7 +4909,7 @@ current one *up to* your target, and apply each snippet.
4564
4909
  (`STITCH_ERROR_STATUS`, `serveFile`, `scopePrefixes`, `afterToolCall`'s
4565
4910
  `MethodDef`, `maxUploadBytes`) are available to adopt, not required.
4566
4911
 
4567
- ## Unreleased breaking migrations
4912
+ ## Released migration: 0.44.0
4568
4913
 
4569
4914
  ### MCP TypeScript SDK v2 and protocol `2026-07-28`
4570
4915
 
@@ -4759,14 +5104,29 @@ implemented or advertised.
4759
5104
  3. Delete all session mode, event-store and session-id code.
4760
5105
  4. Move OAuth client policy under `clientRegistration`; publish CIMD or enable
4761
5106
  DCR explicitly.
4762
- 5. Snapshot `listToolNames`, run one contract tool, one runtime tool, any raw
5107
+ 5. Make `authorizeUser` return the exact consented scope subset. The framework
5108
+ validates that it is a subset of the request before saving the authorization
5109
+ code:
5110
+
5111
+ ```ts
5112
+ // before
5113
+ authorizeUser: async () => ({ userId })
5114
+
5115
+ // after
5116
+ authorizeUser: async (_req, request) => ({
5117
+ userId,
5118
+ approvedScopes: request.scope?.split(' ') ?? [],
5119
+ })
5120
+ ```
5121
+
5122
+ 6. Snapshot `listToolNames`, run one contract tool, one runtime tool, any raw
4763
5123
  multimodal tool and every MCP App resource you use.
4764
- 6. Exercise modern HTTP and stdio with protocol `2026-07-28`; exercise legacy
5124
+ 7. Exercise modern HTTP and stdio with protocol `2026-07-28`; exercise legacy
4765
5125
  only if `legacy: 'serve'` is part of your support policy.
4766
- 7. Run the consumer's typecheck and runtime gates. A browser/HTTP-only consumer
5126
+ 8. Run the consumer's typecheck and runtime gates. A browser/HTTP-only consumer
4767
5127
  must continue to work without either MCP package.
4768
5128
 
4769
- ## Historical breaking migrations through 0.43.1
5129
+ ## Historical breaking migrations through 0.44.0
4770
5130
 
4771
5131
  HTTP observability now completes inside the framework handler instead of a
4772
5132
  nested fetch wrapper. Configure request and tool sinks explicitly:
@@ -5224,6 +5584,7 @@ The browser-and-server entrypoint. Re-exports everything from
5224
5584
  | `createUrlBuilders` | function | build one exact URL builder per contract in a registry |
5225
5585
  | `UrlBuilderConfig` | _type_ | explicit `{ baseUrl }` source for a URL builder |
5226
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) |
5227
5588
  | `ContractClientConfig` | _type_ | per-tenant / resource-scoped client config — dynamic `pathPrefix` + `stripPrefixKeys` ([guide](../guide/client.md#contractclientconfig--per-tenant--resource-scoped-clients)) |
5228
5589
  | `contractEndpointMatchers` | function | compile exact pathname matchers for selected HTTP contract operations and expected-401 policy |
5229
5590
  | `PathPrefixArgs` | _type_ | required string-valued keys exposed to a typed dynamic `pathPrefix` callback |
@@ -5252,6 +5613,17 @@ The browser-and-server entrypoint. Re-exports everything from
5252
5613
  | `SocketEventMap` | _type_ | the shape of an event map |
5253
5614
  | `RealtimeClient` | _type_ | validated client inferred from a realtime contract |
5254
5615
  | `RealtimeClientOptions` | _type_ | transport options and the rejected-event hook for `createRealtimeClient` |
5616
+ | `RealtimeContract` | _type_ | shared server-to-client and client-to-server event registries |
5617
+ | `RealtimeEventRegistry` | _type_ | string-keyed registry of event definitions |
5618
+ | `RealtimeEventDefinition` | _type_ | one tuple-shaped event and optional acknowledgement schema |
5619
+ | `RealtimeEventArguments` | _type_ | tuple inferred from an event definition |
5620
+ | `RealtimeEmitArguments` | _type_ | emit arguments including an inferred acknowledgement callback |
5621
+ | `RealtimeEventHandler` | _type_ | handler inferred from an event definition |
5622
+ | `InferRealtimeEventMap` | _type_ | inferred Socket.IO-compatible event map |
5623
+ | `RealtimeRejectDirection` | _type_ | server/client inbound/outbound rejection direction |
5624
+ | `RealtimeRejectedEvent` | _type_ | structured rejected event with event, direction, phase, reason and fault |
5625
+ | `RealtimeRejectedEventHook` | _type_ | sync/async observer for structured realtime rejections |
5626
+ | `ValidatedRealtimeSocket` | _type_ | runtime-validating `on`/`emit` surface inferred from registries |
5255
5627
  | `RetainedTopics` | _type_ | the `createRetainedTopics` handle |
5256
5628
  | `ParseSSEOptions` | _type_ | options for `parseSSE` |
5257
5629
 
@@ -5302,6 +5674,7 @@ from the root `stitchkit`.
5302
5674
  | `TransportSource` | _type_ | `http \| mcp \| agent \| cli` — the value of `ctx.source` |
5303
5675
  | `RuntimeContext` | _type_ | the loose context seen by transport and hooks |
5304
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 |
5305
5678
  | `EndpointFn` | _type_ | the call signature of one client method |
5306
5679
  | `TypedClient` | _type_ | the full typed client for a contract |
5307
5680
  | `TypedHttpClient` | _type_ | the typed client, HTTP endpoints only (`= ScopedHttpClient<C, unknown>`) |
@@ -5312,6 +5685,9 @@ from the root `stitchkit`.
5312
5685
  | `ScopedUrlFn` | _type_ | one URL method's signature with scoped-prefix keys folded in |
5313
5686
  | `MultipartFile` | _type_ | a `multipart` file field — `Blob \| FileDescriptor` |
5314
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 |
5315
5691
  | `EndpointToolAnnotations` | _type_ | MCP behavioural hints on an endpoint (`readOnlyHint` / `destructiveHint` / `title`) |
5316
5692
  | `EndpointUiMeta` | _type_ | MCP Apps widget metadata on an endpoint |
5317
5693
  | `EndpointMcpInputRequired` | _type_ | typed MCP multi-round input request (`key`, message and Zod object schema) |
@@ -5390,7 +5766,8 @@ Also re-exports the error helpers from `stitchkit/contract`.
5390
5766
  | `MethodDef` | _type_ | one resolved endpoint inside a service |
5391
5767
  | `OperationIdentity` | _type_ | path-free service/action/scope/method identity shared by contract and native tool operations |
5392
5768
  | `Handlers` | _type_ | the typed handler map `implement` expects |
5393
- | `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 |
5394
5771
  | `RouteGroup` | _type_ | a prefixed group of services with its own hooks |
5395
5772
  | `RawRoute` | _type_ | a non-contract `Request → Response` route with a concrete `BunServer` context |
5396
5773
  | `RawRouteContext` | _type_ | the Bun-bound routing context a raw handler receives |
@@ -5407,7 +5784,7 @@ Also re-exports the error helpers from `stitchkit/contract`.
5407
5784
 
5408
5785
  | Export | Kind | Summary |
5409
5786
  |--------|------|---------|
5410
- | `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) |
5411
5788
  | `createErrorHook` | function | an async-capable, endpoint-aware `onError` hook from a code map + envelope renderer — [guide](../guide/auth-and-errors.md#createerrorhook) |
5412
5789
  | `ErrorHookConfig` | _type_ | async observer/renderer config for `createErrorHook` |
5413
5790
  | `ResolvedError` | _type_ | the normalised error handed to `createErrorHook`'s `render` |
@@ -5465,10 +5842,17 @@ Also re-exports the error helpers from `stitchkit/contract`.
5465
5842
  |--------|------|---------|
5466
5843
  | `streamSSE` | function | an async generator → SSE `Response` — [guide](../guide/server.md#sse-streaming) |
5467
5844
  | `parseSSE` | function | parse an SSE `Response` (also on the root entrypoint) |
5845
+ | `MultipartLifecycle` | _type_ | request-scoped rollback ownership for accepted streamed handles |
5468
5846
  | `MultipartResult` | _type_ | what `parseMultipart` returns |
5469
- | `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` |
5470
5853
  | `createRateLimiter` | function | token-bucket rate limiting — [guide](../guide/server.md#rate-limiting) |
5471
5854
  | `createCache` | function | an in-memory TTL cache |
5855
+ | `CacheOptions` | _type_ | bounded-cache options, including the maximum retained entry count |
5472
5856
  | `cacheHeaders` | function | build a `Cache-Control` header |
5473
5857
  | `EventBusOptions` | _type_ | options for `createEventBus` |
5474
5858
  | `EventHandler` | _type_ | one event-bus subscriber |
@@ -5510,9 +5894,12 @@ audit event. See the [Observability guide](../guide/observability.md).
5510
5894
  | `createObservability` | function | configure framework-owned request completion and canonical tool event sinks — [guide](../guide/observability.md#createobservability) |
5511
5895
  | `RequestEvent` | _type_ | the normalised audit event handed to the sink |
5512
5896
  | `ObservabilityConfig` | _type_ | independent request and tool sink configuration |
5513
- | `Observability` | _type_ | the `{ request?, toolCall }` wiring result |
5514
- | `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` |
5515
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 |
5516
5903
  | `HttpRequestCompletion` | _type_ | the single framework-owned HTTP outcome projected to logging and request events |
5517
5904
  | `HttpRequestObserver` | _type_ | server-facing projection consumed by `HandlerConfig.observability` |
5518
5905
 
@@ -5596,6 +5983,7 @@ payload.
5596
5983
  | `StdioMcpServerConfig` | _type_ | config for `createStdioMcpServer` |
5597
5984
  | `McpServerBuildConfig` | _type_ | shared config for `buildMcpServer` |
5598
5985
  | `McpServerSharedConfig` | _type_ | transport-neutral options shared by direct and finite surface configs |
5986
+ | `McpServer` | _type_ | official split-SDK server instance accepted by raw extension points |
5599
5987
  | `DirectMcpSurfaceConfig` | _type_ | static or identity-dynamic `services` / `runtimeTools` source |
5600
5988
  | `FiniteMcpSurfaceConfig` | _type_ | bounded `surfaces` registry plus typed selector |
5601
5989
  | `McpSurfaceDefinition` | _type_ | one immutable `{ services, runtimeTools }` MCP surface |
@@ -5626,7 +6014,7 @@ payload.
5626
6014
  | `CimdClientMetadataFetcher` | _type_ | injectable secure network boundary for metadata loading |
5627
6015
  | `CimdFetchResponse` | _type_ | bounded metadata fetch result passed across the injectable network boundary |
5628
6016
  | `CimdFetchPolicy` | _type_ | CIMD timeout, redirect and size limits |
5629
- | `CimdCachePolicy` | _type_ | bounded HTTP-aware positive/negative cache policy |
6017
+ | `CimdCachePolicy` | _type_ | bounded HTTP-aware cache policy: separate positive/negative pools (`maxEntries`), per-client and server-wide resolution rate limits (`maxResolutionsPerClient` / `maxResolutions` per `resolutionWindowMs`) |
5630
6018
  | `CimdCacheEvent` | _type_ | observable CIMD cache hit, miss, revalidation and eviction event |
5631
6019
  | `createSecureClientMetadataFetcher` | function | production HTTPS, DNS/IP-pinned CIMD fetcher |
5632
6020
  | `RuntimeAgentModelOutput` | _type_ | AI SDK model-facing text/JSON/content output returned by `present.agent` |
@@ -5739,7 +6127,7 @@ Advanced building blocks — the shared machinery the mounts are built on.
5739
6127
 
5740
6128
  Server-only, for Node ≥ 22 (Bun uses `stitchkit/server`). The runtime-agnostic
5741
6129
  core plus a Node HTTP adapter — [ADR 0013](../decisions/0013-runtime-agnostic-core.md),
5742
- [deployment guide](../guide/testing-and-deployment.md#node). Re-exports the
6130
+ [deployment guide](../guide/testing-and-deployment.md#deploy-on-node). Re-exports the
5743
6131
  runtime-agnostic pieces of `stitchkit/server` and the error helpers.
5744
6132
 
5745
6133
  | Export | Kind | Summary |