stitchkit 0.49.2 → 0.51.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 (42) hide show
  1. package/dist/cli.js +3 -3
  2. package/dist/contract/errors-factory.d.ts +27 -6
  3. package/dist/contract/errors-factory.d.ts.map +1 -1
  4. package/dist/contract/factory.d.ts +15 -2
  5. package/dist/contract/factory.d.ts.map +1 -1
  6. package/dist/contract/index.d.ts +1 -1
  7. package/dist/contract/index.d.ts.map +1 -1
  8. package/dist/contract/index.js +1 -1
  9. package/dist/{index-z992nfbx.js → index-8mt47qk7.js} +127 -123
  10. package/dist/{index-jewp9r0a.js → index-8qghvg18.js} +1 -1
  11. package/dist/{index-h05ygjqx.js → index-bmnhqtya.js} +4 -1
  12. package/dist/{index-p9d4cxt5.js → index-gff2mxzk.js} +1 -1
  13. package/dist/{index-0hj37z43.js → index-h55e1wyq.js} +2 -2
  14. package/dist/{index-v58mwa19.js → index-psrxjvbw.js} +2 -2
  15. package/dist/{index-45dz4m51.js → index-trz4ate5.js} +6 -2
  16. package/dist/{index-03j2t778.js → index-tw7mhqgy.js} +15 -4
  17. package/dist/index-ynts85ew.js +246 -0
  18. package/dist/index.js +1 -1
  19. package/dist/node.d.ts +3 -2
  20. package/dist/node.d.ts.map +1 -1
  21. package/dist/node.js +15 -6
  22. package/dist/observability/index.js +3 -3
  23. package/dist/server/implement.d.ts +101 -8
  24. package/dist/server/implement.d.ts.map +1 -1
  25. package/dist/server/index.d.ts +4 -3
  26. package/dist/server/index.d.ts.map +1 -1
  27. package/dist/server/index.js +19 -11
  28. package/dist/server/middleware/auth.d.ts +63 -3
  29. package/dist/server/middleware/auth.d.ts.map +1 -1
  30. package/dist/server/process-signals.d.ts +117 -0
  31. package/dist/server/process-signals.d.ts.map +1 -0
  32. package/dist/server/types.d.ts +38 -0
  33. package/dist/server/types.d.ts.map +1 -1
  34. package/dist/testing.js +2 -2
  35. package/dist/tools/list-names.d.ts +13 -1
  36. package/dist/tools/list-names.d.ts.map +1 -1
  37. package/dist/tools.d.ts +1 -1
  38. package/dist/tools.d.ts.map +1 -1
  39. package/dist/tools.js +13 -8
  40. package/llms-full.txt +404 -39
  41. package/package.json +1 -1
  42. package/dist/index-r1qp4rve.js +0 -57
package/llms-full.txt CHANGED
@@ -617,6 +617,21 @@ the exact config without `NonNullable` or another wrapper. Plain `ContractDef`
617
617
  keeps optional scope because ordinary `defineContract` still supports its
618
618
  default-public overload.
619
619
 
620
+ The union covers **per-endpoint overrides too**, not just the contract's scope —
621
+ that is where typos are densest, and one there used to compile and then fail
622
+ closed at request time (`[stitchkit] auth: no rule for scope "…"`):
623
+
624
+ ```ts
625
+ defineContract({ prefix: 'posts', scope: 'user' }, {
626
+ drop: { method: 'DELETE', path: '/:id', desc: 'Drop', scope: 'admn', … },
627
+ // ^ compile error, and TypeScript
628
+ // suggests 'admin'
629
+ })
630
+ ```
631
+
632
+ Contracts built with plain `defineContract` are unaffected — their endpoint
633
+ `scope` stays a free string.
634
+
620
635
  ## One source of truth
621
636
 
622
637
  A contract is plain data — no classes, no decorators, no codegen. It is imported
@@ -686,6 +701,79 @@ export const implement = createImplement<AppContext>()
686
701
  // every implement() call now has ctx.user typed
687
702
  ```
688
703
 
704
+ ### Per-scope handler context — `createScopedImplement`
705
+
706
+ `createImplement<Ctx>()` gives every handler the **same** context type. In an app
707
+ where each scope guarantees different injected fields, that single type is a
708
+ superset: a `public` handler is told it has a `userId` the runtime never injects
709
+ there. `createScopedImplement` types each handler by its endpoint's **effective**
710
+ scope instead — the endpoint's own `scope`, else the contract's:
711
+
712
+ ```ts
713
+ import { createScopedImplement } from 'stitchkit/server'
714
+
715
+ export const implementFor = createScopedImplement<{
716
+ public: object // no extra fields
717
+ user: { userId: string }
718
+ admin: { userId: string; isAdmin: boolean }
719
+ }>()
720
+
721
+ implementFor(postsContract, {
722
+ list: (ctx) => listPosts(ctx.userId), // contract scope 'user'
723
+ purge: (ctx) => (ctx.isAdmin ? purge() : []), // endpoint scope 'admin'
724
+ ping: () => ({ ok: true }), // endpoint scope 'public'
725
+ })
726
+ ```
727
+
728
+ The map is **type-only** — scope fields are types, so there is no runtime
729
+ argument and no `{} as UserFields` at the call site. `'public'` must be a key:
730
+ a contract without a `scope` is `'public'`. A scope outside the map is a compile
731
+ error on that endpoint, naming the scope.
732
+
733
+ Three boundaries worth knowing:
734
+
735
+ - A field of another scope is not a compile error on access — `RuntimeContext`
736
+ keeps its `[key: string]: unknown` index signature (transports write through
737
+ it). It degrades to `unknown`, so it can no longer pose as a `string`; using it
738
+ in a typed position fails.
739
+ - The map states what **your** `beforeHandle` / `createAuthHook.inject` puts on
740
+ the context. The framework does not verify a hand-written claim (→ ADR 0075) —
741
+ so prefer not to write one: scoped auth rules declare their contribution by
742
+ performing it, and `createScopedImplement<AuthScopes<typeof hook>>()` derives
743
+ the map from the hook
744
+ ([details](./auth-and-errors.md#scoped-rules--the-map-createscopedimplement-consumes),
745
+ → ADR 0078).
746
+ - An endpoint hoisted out of the contract literal widens its `scope` to `string`,
747
+ which is no key of any map, and lands in the error branch. An endpoint whose
748
+ `scope` is added by a conditional spread is optional, so it resolves to **both**
749
+ scopes and is typed against what they guarantee in common. Write endpoints
750
+ inline for the precise type.
751
+ - Scope keys are string literals. A map declared as an index signature
752
+ (`Record<string, …>`) makes every scope valid and disables the check.
753
+
754
+ The same map has a registry form and a streaming form:
755
+
756
+ ```ts
757
+ // one contract registry, handlers still typed per endpoint scope
758
+ const implementAll = createScopedImplementRegistry<Scopes>()
759
+ export const services = implementAll(apiContractRegistry, { posts, users })
760
+
761
+ // a streaming multipart handler that reads its scope's fields. The endpoint
762
+ // must declare its own `scope`, and only that literal is accepted: an endpoint
763
+ // inheriting the contract's scope cannot be verified from here, and guessing it
764
+ // would rebuild the superset this factory removes.
765
+ implementFor(mediaContract, {
766
+ upload: implementFor.stream('admin', mediaContract.endpoints.upload, {
767
+ files: { file: receiveFile },
768
+ handler: ({ files, userId }) => store(files.file, userId),
769
+ }),
770
+ })
771
+ ```
772
+
773
+ Outside a scoped app, `createMultipartStream<Ctx>()` does for streaming handlers
774
+ what `createImplement<Ctx>()` does for ordinary ones. Plain
775
+ `defineMultipartStream` still gives the loose `RuntimeContext`.
776
+
689
777
  ### `implementRegistry` — one backend registry
690
778
 
691
779
  When contracts already live in one literal registry, bind the backend from that
@@ -702,7 +790,17 @@ export const apiServices = implementRegistry(apiContractRegistry, {
702
790
 
703
791
  Every registry key is required, extra keys fail, and each handler map is checked
704
792
  against its own contract. The returned service order follows the contract
705
- registry order. `createImplementRegistry<AppContext>()` fixes the application
793
+ registry order and the same services ride along under `.byKey`, so a consumer
794
+ whose registry keys are load-bearing (filtering a tool surface per caller) never
795
+ rebuilds a prefix lookup by hand:
796
+
797
+ ```ts
798
+ const services = implementRegistry(apiContractRegistry, handlers)
799
+ createServer({ services }) // the array, as before
800
+ const visible = [services.byKey.users] // the same objects, by key
801
+ ```
802
+
803
+ `createImplementRegistry<AppContext>()` fixes the application
706
804
  context once, like `createImplement` does for a single contract. Runtime callers
707
805
  also fail first on missing/extra keys and duplicate contract prefixes.
708
806
 
@@ -974,9 +1072,11 @@ createServer({
974
1072
 
975
1073
  A prefix may carry `:param` segments (they land on the context exactly as above).
976
1074
  Services listed under explicit `groups` are unaffected — the group prefix wins.
977
- Scope stays a free string; the core attaches no meaning beyond this lookup
978
- (→ ADR 0024). When each scope needs a different handler-context shape, declare one
979
- `createImplement<Ctx>()` per scope rather than a single superset context.
1075
+ Scope stays a free string (unless the contract is authored through
1076
+ `createContractFactory<Scope>()`); the core attaches no meaning beyond this lookup
1077
+ (→ ADR 0024). When each scope needs a different handler-context shape, use
1078
+ [`createScopedImplement`](#per-scope-handler-context--createscopedimplement)
1079
+ rather than a single superset context.
980
1080
 
981
1081
  ## Lifecycle hooks
982
1082
 
@@ -3578,7 +3678,9 @@ the scope vocabulary are yours.
3578
3678
  ## Scopes
3579
3679
 
3580
3680
  An endpoint declares a `scope` — a free string. The contract `meta.scope` is the
3581
- default for endpoints that declare none.
3681
+ default for endpoints that declare none. (Authored through
3682
+ [`createContractFactory<Scope>()`](./contracts.md#scope), both the contract's
3683
+ scope and any endpoint override are held to your own union instead.)
3582
3684
 
3583
3685
  ```ts
3584
3686
  export const posts = defineContract({ prefix: 'posts', scope: 'user' }, {
@@ -3668,7 +3770,61 @@ endpoints under the group declare `scope: 'tenant'`.
3668
3770
  | `onForbidden` | thrown when an identity is present but the rule rejects it (default 403) |
3669
3771
 
3670
3772
  Annotate `rules` with `satisfies Record<MyScope, AuthRule<User>>` so the compiler
3671
- catches a scope you forgot to cover.
3773
+ catches a scope you forgot to cover. With the scoped rule objects below, widen
3774
+ the annotation to
3775
+ `satisfies Record<MyScope, AuthRule<User> | ScopedAuthRule<User, object>>` — it
3776
+ keeps the coverage check and does not disturb the derivation. (When every scope
3777
+ comes from the derived map, the check is already implicit: a scope missing from
3778
+ `rules` is no key of the map, and a contract using it fails to compile.)
3779
+
3780
+ ### Scoped rules — the map `createScopedImplement` consumes
3781
+
3782
+ A rule may take an object form, `{ rule, inject }`, where `inject` **returns**
3783
+ the fields this scope contributes. That return type is a declaration the hook
3784
+ derives a scope→context map from — feed it to
3785
+ [`createScopedImplement`](./server.md#per-scope-handler-context--createscopedimplement)
3786
+ and the map can never drift from the hook, because it is computed from it:
3787
+
3788
+ ```ts
3789
+ const authHook = createAuthHook({
3790
+ resolve: sessionResolver, // no explicit generic — let it infer
3791
+ rules: {
3792
+ public: { rule: 'public', inject: (user) => ({ userId: user.id }) },
3793
+ user: { rule: 'authenticated', inject: (user) => ({ userId: user.id }) },
3794
+ admin: {
3795
+ rule: (user) => user.admin,
3796
+ inject: (user) => ({ userId: user.id, isAdmin: user.admin }),
3797
+ },
3798
+ },
3799
+ })
3800
+
3801
+ export const implementFor = createScopedImplement<AuthScopes<typeof authHook>>()
3802
+ ```
3803
+
3804
+ - A `'public'` rule's fields come out **optional**: public admits the anonymous
3805
+ caller, it does not refuse to know the logged-in one — `resolve` and `inject`
3806
+ still run, so a public `me` / `logout` handler reads `ctx.userId` as
3807
+ `string | undefined` and narrows, instead of being pushed toward a cast by a
3808
+ `public: object` map.
3809
+ - Any other rule's fields are required — the rule rejected the request before
3810
+ the handler if no identity resolved.
3811
+ - A scope with no rule is no key of the derived map, so a contract using it
3812
+ fails to compile — the type-level mirror of the hook's fail-closed
3813
+ `no rule for scope` throw.
3814
+
3815
+ The per-rule `inject` runs after the shared one, only when an identity
3816
+ resolved, and **before** the rule check — it may run for an identity the rule
3817
+ then rejects, so keep it pure and synchronous (an async `inject` is a compile
3818
+ error, and a runtime one for untyped callers: merging a Promise would merge
3819
+ nothing). Write rule objects inline in `rules` — a hoisted, annotated rule
3820
+ widens its inject and degrades that scope's fields to `object`.
3821
+
3822
+ Derivation needs the identity generic to be **inferred** (write no explicit
3823
+ `createAuthHook<User>` — TypeScript has no partial inference); with an explicit
3824
+ generic, or fields injected outside the hook, keep the hand-written map. Two
3825
+ hooks guarding different scopes compose by intersection:
3826
+ `createScopedImplement<AuthScopes<typeof userHook> & AuthScopes<typeof botHook>>()`.
3827
+ → ADR 0078
3672
3828
 
3673
3829
  ### Auth on the tool surface — `resolveFromContext`
3674
3830
 
@@ -3870,36 +4026,57 @@ onError: (ctx, err) => {
3870
4026
 
3871
4027
  ## Domain errors — `defineErrors`
3872
4028
 
3873
- Declare each domain code, HTTP status and optional structured-details schema in
3874
- one immutable registry. The generated functions construct typed branded
3875
- `AppError` instances; ordinary `throw` remains explicit at the call site:
4029
+ Declare each domain code, HTTP status, default message and optional
4030
+ structured-details schema in one immutable registry. The generated functions
4031
+ construct typed branded `AppError` instances; ordinary `throw` remains explicit
4032
+ at the call site:
3876
4033
 
3877
4034
  ```ts
3878
4035
  import { z } from 'zod'
3879
4036
 
3880
4037
  export const { errors, codes, definitions, isCode } = defineErrors({
3881
- SESSION_NOT_FOUND: { status: 404 },
4038
+ SESSION_NOT_FOUND: { status: 404, message: 'No such session' },
3882
4039
  QUOTA_EXCEEDED: {
3883
4040
  status: 429,
4041
+ message: 'Monthly quota exhausted',
3884
4042
  details: z.object({ retryAfterSeconds: z.number().int().positive() }),
3885
4043
  },
3886
4044
  })
3887
4045
 
3888
- throw errors.SESSION_NOT_FOUND({ message: 'No such session' })
4046
+ throw errors.SESSION_NOT_FOUND() // uses the declared message
3889
4047
  throw errors.QUOTA_EXCEEDED({
3890
- message: 'Try later',
4048
+ message: 'Try later', // overrides it here only
3891
4049
  details: { retryAfterSeconds: 30 },
3892
4050
  hint: 'Wait for the current window to expire',
3893
4051
  })
3894
4052
 
3895
4053
  // Construction without throwing is useful for composition or inspection.
3896
4054
  const error = errors.QUOTA_EXCEEDED({ details: { retryAfterSeconds: 30 } })
3897
- definitions.QUOTA_EXCEEDED.status // 429 — same source, no copied status map
4055
+ definitions.QUOTA_EXCEEDED.status // 429 — same source, no copied status map
4056
+ definitions[code].message // the declared text, by a `code` variable
3898
4057
 
3899
4058
  // client — match the code, never a magic string
3900
4059
  if (err instanceof ApiError && err.code === codes.SESSION_NOT_FOUND) { … }
3901
4060
  ```
3902
4061
 
4062
+ `message` is optional: declare it once instead of repeating the sentence at every
4063
+ `throw`, and a per-call `message` still wins. A code without one keeps the
4064
+ existing fallback — `AppError.message` is the code itself. An empty declared
4065
+ message is rejected when the registry is declared, not when the error is thrown.
4066
+
4067
+ **The tool envelope has no `message` field**, and what the model sees depends on
4068
+ whether the code declares `details`:
4069
+
4070
+ | | HTTP / typed client | MCP / agent / CLI |
4071
+ |---|---|---|
4072
+ | code **with** a `details` schema | the declared message | no text at all — only `details` and `hint` |
4073
+ | code **without** one | the declared message | the same text, delivered as `details.message` |
4074
+
4075
+ The model-facing envelope is `{ error, details?, _hint? }`; for a code with no
4076
+ details schema the framework fills `details` with `{ message }`, so declaring a
4077
+ message changes what the model reads there — it used to be the code itself. Put
4078
+ anything the model must reliably read in `details` or `hint`, not in `message`.
4079
+
3903
4080
  With no `details` schema, the options object forbids `details`. A required
3904
4081
  `z.object` makes `details` required; `z.object(...).optional()` makes it
3905
4082
  optional. Supplied details are parsed when the error is constructed, before any
@@ -4637,34 +4814,123 @@ createServer({
4637
4814
  })
4638
4815
  ```
4639
4816
 
4640
- Keep the managed handle and wire process policy explicitly:
4817
+ ### Process signals `bindProcessSignals`
4818
+
4819
+ Keep the managed handle and wire process policy explicitly. The framework
4820
+ registers no signal listener on its own; `bindProcessSignals` is that explicit
4821
+ step, and it owns the state machine:
4641
4822
 
4642
4823
  ```ts
4824
+ import { bindProcessSignals } from 'stitchkit/server'
4825
+
4643
4826
  const server = createServer({ services, socket })
4644
- const force = new AbortController()
4645
- let closing: Promise<void> | undefined
4646
4827
 
4647
- function shutdown() {
4648
- if (closing) {
4649
- force.abort() // a later signal shortens the same shutdown, not a second chain
4650
- return closing
4651
- }
4652
- closing = server.shutdown({
4653
- gracePeriodMs: 30_000,
4654
- forceTimeoutMs: 5_000,
4655
- signal: force.signal,
4656
- }).then(async result => {
4828
+ bindProcessSignals(server, {
4829
+ shutdown: { gracePeriodMs: 30_000, forceTimeoutMs: 5_000 },
4830
+ onShutdown: () => stopSchedulers(), // before the drain starts
4831
+ onComplete: async (result) => { // after it finishes
4657
4832
  await mcp.close()
4658
4833
  await prisma.$disconnect()
4659
- console.log(result)
4660
- })
4661
- return closing
4834
+ process.exitCode = result.outcome === 'clean' ? 0 : 1
4835
+ },
4836
+ onError: (error) => {
4837
+ console.error(error)
4838
+ process.exitCode = 1
4839
+ },
4840
+ })
4841
+ ```
4842
+
4843
+ - The first signal runs `onShutdown`, then one `shutdown()`.
4844
+ - A later signal **forces that same chain** — never a second shutdown. Signals
4845
+ delivered in the same turn as the first (a supervisor sending `SIGINT` and
4846
+ `SIGTERM` together) are not counted, so the grace period is not collapsed to
4847
+ zero.
4848
+ - The signal after the force — or one arriving while `onComplete` still runs —
4849
+ re-delivers the signal so its default disposition applies, letting an operator
4850
+ kill a process stuck on some other resource. This works only while nothing else
4851
+ in the process listens for that signal; if something does, the signal is
4852
+ swallowed and `onEscalationBlocked` fires instead. The framework will not call
4853
+ `process.exit` on your behalf.
4854
+ - `shutdown` budgets are validated when you bind, not when the signal arrives.
4855
+ `signal` is not accepted there — the binding owns it, and that is what makes a
4856
+ later signal force the first chain.
4857
+ - `onError(phase, error)` separates the phases. A failing `onShutdown`
4858
+ (`'prepare'`) is reported and the shutdown **still runs** — a failed
4859
+ preparation must not leave the server listening. A failing `onComplete`
4860
+ (`'complete'`) leaves `promise` resolved, because the transport did shut down.
4861
+ Only a failing `shutdown()` (`'shutdown'`) rejects it.
4862
+
4863
+ `close()` removes the listeners. Closing a binding that never received a signal
4864
+ resolves `promise` with `undefined`, so an awaiting application does not hang;
4865
+ closing one whose chain is already running leaves that chain to finish and keeps
4866
+ the handle claimed, since a second binding could not force it. `promise` is
4867
+ already observed internally, so ignoring it never raises an `unhandledRejection`.
4868
+
4869
+ #### Composite shutdown target — parallel domain drains
4870
+
4871
+ `bindProcessSignals` takes `Pick<ManagedServerHandle, 'shutdown'>` — an
4872
+ **interface**, not the server. An application whose shutdown spans several
4873
+ domains (transport, bots, agent runs, broadcasts) composes them into one target;
4874
+ the signal machine stays the framework's, the composition stays yours:
4875
+
4876
+ ```ts
4877
+ const composite: ShutdownTarget = {
4878
+ async shutdown(options?: ShutdownOptions): Promise<ShutdownResult> {
4879
+ const { signal } = ShutdownOptionsSchema.parse(options ?? {})
4880
+ // Domain drains run in parallel with the transport drain, all watching the
4881
+ // same force signal the second OS signal aborts.
4882
+ const [transport, bots, agents, broadcasts] = await Promise.all([
4883
+ server.shutdown(options),
4884
+ drainBots(signal),
4885
+ drainAgents(signal),
4886
+ drainBroadcasts(signal),
4887
+ ])
4888
+ return {
4889
+ ...transport,
4890
+ // Fold domain leftovers into the transport result so `onComplete` sees
4891
+ // one honest outcome.
4892
+ outcome: transport.outcome === 'clean' && bots + agents + broadcasts === 0
4893
+ ? 'clean'
4894
+ : 'forced',
4895
+ }
4896
+ },
4662
4897
  }
4663
4898
 
4664
- process.on('SIGTERM', () => void shutdown())
4665
- process.on('SIGINT', () => void shutdown())
4899
+ bindProcessSignals(composite, { shutdown: { gracePeriodMs: 30_000 } })
4900
+ ```
4901
+
4902
+ The transport half inside stays the real managed handle — its admission gate and
4903
+ HTTP drain are not reimplemented, only composed. Domain drains must watch the
4904
+ `signal` themselves: it is the only channel a second OS signal has into a
4905
+ running chain.
4906
+
4907
+ #### The last line of defence is yours
4908
+
4909
+ The framework never calls `process.exit`, and `process.exitCode` only takes
4910
+ effect once the event loop empties — which is exactly what a stuck resource
4911
+ prevents. If the deployment must exit before the supervisor's `SIGKILL`, arm an
4912
+ application-side timer:
4913
+
4914
+ ```ts
4915
+ onShutdown: () => {
4916
+ setTimeout(() => process.exit(1), 60_000).unref()
4917
+ },
4666
4918
  ```
4667
4919
 
4920
+ `unref()` keeps the timer from holding a healthy process open; it only fires if
4921
+ something else already is.
4922
+
4923
+ #### When NOT to use `bindProcessSignals`
4924
+
4925
+ - The application already runs a signal machine with states this one does not
4926
+ have (staged escalation policies, per-signal semantics beyond
4927
+ first/force/escalate). Two machines on the same signals is worse than either.
4928
+ - Signal policy must differ per signal (e.g. `SIGHUP` = reload, not shutdown) —
4929
+ bind only the terminating signals here and keep the rest yours.
4930
+ - Something else in the process must keep listening for the same signal: the
4931
+ escalation path restores the default disposition only when nothing else
4932
+ listens, and reports `onEscalationBlocked` otherwise.
4933
+
4668
4934
  The server owns HTTP/Socket.IO transport resources. MCP, databases, queues and
4669
4935
  domain run-state remain application resources and close explicitly after server
4670
4936
  drain. Do not call `runtime.stop()` or `socket.io.close()` in parallel with
@@ -4797,10 +5063,20 @@ createServer({
4797
5063
  Handlers read `ctx.tenantId` (a raw `string` — narrow it). To get it typed inside
4798
5064
  `ctx.params`, add `tenantId` to the endpoint's `params` schema (a `z.strictObject`
4799
5065
  that omits it will reject the request). When each scope guarantees different
4800
- injected fields, call `createImplement<TenantCtx>()` / `createImplement<BaseCtx>()`
4801
- **once per scope** and implement each contract with the matching factory — every
4802
- handler is typed to its scope, with no superset context that lies about a
4803
- `tenantId` a `public` handler never has.
5066
+ injected fields, declare the scope map once with
5067
+ [`createScopedImplement`](./server.md#per-scope-handler-context--createscopedimplement):
5068
+
5069
+ ```ts
5070
+ export const implementFor = createScopedImplement<{
5071
+ public: object
5072
+ tenant: { tenantId: string; userId: string }
5073
+ project: { projectId: string; userId: string }
5074
+ }>()
5075
+ ```
5076
+
5077
+ Each handler is then typed by its endpoint's effective scope — no superset
5078
+ context that promises a `tenantId` a `public` handler never receives. A contract
5079
+ that mixes scopes across its endpoints needs no splitting.
4804
5080
 
4805
5081
  ## 3. Auth — gate the tenant in the path
4806
5082
 
@@ -4990,6 +5266,69 @@ current one *up to* your target, and apply each snippet.
4990
5266
  runtime): bootstrap the server, one HTTP request, and any feature you rely on
4991
5267
  (Socket.IO connect, an MCP tool call, a multipart upload, …).
4992
5268
 
5269
+ ## Released migration: 0.50.0
5270
+
5271
+ ### The factory's scope union now covers per-endpoint overrides
5272
+
5273
+ `createContractFactory<Scope>()` already required a typed `scope` on the
5274
+ contract. It now holds a per-endpoint `scope` override to the same union. Nothing
5275
+ to migrate unless an override is outside the union — which was always a bug: the
5276
+ scope reached no auth rule, and `createAuthHook` threw
5277
+ `[stitchkit] auth: no rule for scope "…"` on the first request to it.
5278
+
5279
+ ```ts
5280
+ const { defineContract } = createContractFactory<'public' | 'user' | 'admin'>()
5281
+
5282
+ defineContract({ prefix: 'posts', scope: 'user' }, {
5283
+ // before: compiled; failed at request time
5284
+ // after: compile error naming the scope (TypeScript even suggests 'admin')
5285
+ purge: { method: 'DELETE', path: '/all', desc: 'Purge', scope: 'admn', output },
5286
+ })
5287
+ ```
5288
+
5289
+ Fix the typo, or widen the factory union if the scope is real. Contracts built
5290
+ with plain `defineContract` are unaffected.
5291
+
5292
+ ### A declared `defineErrors` message is now used
5293
+
5294
+ `ErrorDefinition` accepts `message`. Nothing to migrate unless a registry already
5295
+ wrote that key: it type-checked before (excess-property checking does not fire
5296
+ through a `const` generic) and was ignored, so the code itself went on the wire.
5297
+
5298
+ ```ts
5299
+ const { errors } = defineErrors({ GONE: { status: 410, message: 'Long gone' } })
5300
+ // before: errors.GONE().message === 'GONE'
5301
+ // after: errors.GONE().message === 'Long gone'
5302
+ ```
5303
+
5304
+ If a registry carried `message` as a note to the reader rather than as
5305
+ user-facing text, either fix the text or drop the key. A code with no `details`
5306
+ schema also shows that text to a model (its tool `details` is `{ message }`).
5307
+
5308
+ ### Optional: adopt `createScopedImplement`
5309
+
5310
+ If the app declares one superset handler context because different scopes inject
5311
+ different fields, replace it with a scope map. This is additive — `createImplement`
5312
+ still works.
5313
+
5314
+ ```ts
5315
+ // before — one context for every scope; `ctx.userId` is typed even in a
5316
+ // `public` handler, where the runtime never injects it
5317
+ interface AppContext extends RuntimeContext { userId: string; isAdmin: boolean }
5318
+ export const implement = createImplement<AppContext>()
5319
+
5320
+ // after — each handler typed by its endpoint's effective scope
5321
+ export const implementFor = createScopedImplement<{
5322
+ public: object
5323
+ user: { userId: string }
5324
+ admin: { userId: string; isAdmin: boolean }
5325
+ }>()
5326
+ ```
5327
+
5328
+ `'public'` must be a key (a contract with no `scope` is `'public'`). Write
5329
+ endpoints inline in the contract literal: an endpoint hoisted into a variable
5330
+ widens its `scope` to `string` and is reported as undeclared.
5331
+
4993
5332
  ## Released migration: 0.48.0
4994
5333
 
4995
5334
  ### Typed-client request options move to `.withOptions`
@@ -5919,13 +6258,14 @@ from the root `stitchkit`.
5919
6258
  | Export | Kind | Summary |
5920
6259
  |--------|------|---------|
5921
6260
  | `defineContract` | function | declare a contract — [guide](../guide/contracts.md#definecontract) |
5922
- | `createContractFactory` | function | a `defineContract` with a required allowed scope that retains each concrete literal — [guide](../guide/contracts.md#scope) |
6261
+ | `createContractFactory` | function | a `defineContract` whose scope — on the contract **and** on any endpoint override — is required and held to your union, retaining each concrete literal — [guide](../guide/contracts.md#scope) |
5923
6262
  | `ContractFactoryConfig` | _type_ | optional scoped-factory policy, including explicit tool exposure |
5924
6263
  | `ContractFactoryToolExposure` | _type_ | `'explicit'` — omitted endpoint exposure materializes as HTTP-only |
5925
6264
  | `ExplicitScopedDefineContract` | _type_ | scoped factory authoring with explicit tool opt-in |
5926
6265
  | `ExplicitToolExposureEndpoints` | _type_ | endpoint map after missing exposure is materialized as `['HTTP']` |
6266
+ | `FactoryScopedEndpoint` | _type_ | an endpoint authored through the factory — its own `scope` override is held to the same union |
5927
6267
  | `ScopedContractDef` | _type_ | a factory-defined contract whose `meta.scope` is the required concrete literal |
5928
- | `ScopedDefineContract` | _type_ | the `defineContract` `createContractFactory` returns |
6268
+ | `ScopedDefineContract` | _type_ | the `defineContract` `createContractFactory` returns — endpoint `scope` overrides join the union |
5929
6269
  | `ALL_TRANSPORTS` | constant | `['HTTP', 'MCP', 'AGENT', 'CLI']` |
5930
6270
  | `ContractDef` | _type_ | a defined contract |
5931
6271
  | `ContractMeta` | _type_ | a contract's `prefix` + optional `scope` and `meta` (a default every endpoint shallow-merges over) |
@@ -5975,7 +6315,7 @@ from the root `stitchkit`.
5975
6315
  | `defineErrors` | function | declare immutable domain error definitions → typed `AppError` constructors, codes and schemas — [guide](../guide/auth-and-errors.md#domain-errors--defineerrors) |
5976
6316
  | `DefinedErrors` | _type_ | the `{ errors, codes, definitions, isCode }` handle `defineErrors` returns |
5977
6317
  | `DefinedAppError` | _type_ | literal-code error instance with schema-refined details |
5978
- | `ErrorDefinition` | _type_ | `{ status, details? }` definition for one domain code |
6318
+ | `ErrorDefinition` | _type_ | `{ status, message?, details? }` definition for one domain code |
5979
6319
  | `ErrorDefinitions` | _type_ | string-keyed domain error definition registry |
5980
6320
  | `ErrorDetailsSchema` | _type_ | required or optional Zod object accepted for structured details |
5981
6321
  | `ErrorDetailsOutput` | _type_ | parsed details inferred from one definition |
@@ -6011,9 +6351,19 @@ Also re-exports the error helpers from `stitchkit/contract`.
6011
6351
  | `createHandler` | function | the router as a bare `(req) => Response` — [guide](../guide/server.md#createserver) |
6012
6352
  | `implement` | function | bind a contract to typed handlers — [guide](../guide/server.md#implement) |
6013
6353
  | `createImplement` | function | fix the handler context type once |
6354
+ | `createScopedImplement` | function | fix one scope→context map, then type every handler by its endpoint's effective scope — [guide](../guide/server.md#per-scope-handler-context--createscopedimplement) |
6355
+ | `ScopedHandlers` | _type_ | handler map typed per endpoint by its effective scope |
6356
+ | `ScopeContexts` | _type_ | scope → extra context fields map accepted by `createScopedImplement` |
6357
+ | `EffectiveScope` | _type_ | an endpoint's own `scope`, else its contract's group scope |
6358
+ | `createScopedImplementRegistry` | function | the registry form of `createScopedImplement` — one contract registry, handlers still typed per endpoint scope |
6359
+ | `ScopedRegistryHandlers` | _type_ | scoped handler registry inferred from a contract registry |
6360
+ | `ScopedImplementationRegistry` | _type_ | contract registry whose every group scope is a key of the scope map |
6361
+ | `StreamScope` | _type_ | the scope `createScopedImplement(...).stream` accepts for one endpoint |
6362
+ | `ExactScopedRegistryHandlers` | _type_ | fail-first scoped registry shape that rejects extra registry and endpoint keys |
6014
6363
  | `implementRegistry` | function | bind one exact contract registry to one exact backend handler registry |
6015
6364
  | `createImplementRegistry` | function | context-typed factory for `implementRegistry` |
6016
6365
  | `ImplementationRegistry` | _type_ | flat literal registry of concrete contracts accepted by `implementRegistry` |
6366
+ | `KeyedServices` | _type_ | registry result: the mount-ordered `ServiceDef[]` carrying the same services under `.byKey` |
6017
6367
  | `RegistryHandlers` | _type_ | exact backend handler registry inferred from a contract registry |
6018
6368
  | `ExactRegistryHandlers` | _type_ | fail-first handler shape that rejects extra registry and endpoint keys |
6019
6369
  | `staticRoute` | function | a raw route that serves a directory |
@@ -6075,6 +6425,11 @@ Also re-exports the error helpers from `stitchkit/contract`.
6075
6425
  | `AuthHook` | _type_ | the hook `createAuthHook` returns |
6076
6426
  | `AuthHookConfig` | _type_ | config for `createAuthHook` |
6077
6427
  | `AuthRule` | _type_ | `'public' \| 'authenticated' \| predicate` |
6428
+ | `ScopedAuthRule` | _type_ | a rule plus its typed context contribution — `{ rule, inject? }` |
6429
+ | `AuthRules` | _type_ | the `rules` map: bare rules or scoped rules |
6430
+ | `RuleScopes` | _type_ | scope→context map derived from a `rules` object; `'public'` fields become optional |
6431
+ | `ScopedAuthHook` | _type_ | an auth hook carrying its derived scope map at the type level |
6432
+ | `AuthScopes` | _type_ | recover the derived map — `createScopedImplement<AuthScopes<typeof hook>>()` |
6078
6433
  | `BearerResolverConfig` | _type_ | config for `createBearerResolver` |
6079
6434
  | `JwtPayload` | _type_ | a decoded JWT payload |
6080
6435
  | `SignJwtOptions` | _type_ | options for `signJwt` (expiry, claims) |
@@ -6125,10 +6480,19 @@ Also re-exports the error helpers from `stitchkit/contract`.
6125
6480
  | `MultipartResult` | _type_ | what `parseMultipart` returns |
6126
6481
  | `parseMultipart` | function | parse a typed buffered/streaming multipart descriptor — [guide](../guide/server.md#multipart) |
6127
6482
  | `defineMultipartStream` | function | bind typed streaming file receivers and a final endpoint handler |
6483
+ | `createMultipartStream` | function | fix one handler context type for streaming multipart endpoints |
6484
+ | `MultipartStreamConfig` | _type_ | receivers plus the context-typed handler accepted by the streaming builders |
6128
6485
  | `MultipartFileMetadata` | _type_ | field, filename, declared media type and optional declared size |
6129
6486
  | `MultipartReceiver` | _type_ | consumer-owned Web-stream storage receiver |
6130
6487
  | `MultipartReceiverResult` | _type_ | receiver value plus rollback cleanup |
6131
6488
  | `StreamingMultipartImplementation` | _type_ | receiver registry and handler shape inferred by `defineMultipartStream` |
6489
+ | `bindProcessSignals` | function | bind `SIGINT` / `SIGTERM` to one managed `shutdown()` — one chain, force on a later signal, default disposition on the one after — [guide](../guide/testing-and-deployment.md#process-signals--bindprocesssignals) |
6490
+ | `ProcessSignalsOptions` | _type_ | config for `bindProcessSignals` |
6491
+ | `ProcessSignalsBinding` | _type_ | the `{ promise, close }` handle `bindProcessSignals` returns |
6492
+ | `ProcessSignalsErrorPhase` | _type_ | `'prepare' \| 'shutdown' \| 'complete'` — which phase an `onError` report came from |
6493
+ | `SignalSource` | _type_ | injectable signal source — `process` by default |
6494
+ | `ProcessSignalName` | _type_ | signal names the binding accepts (owned, so the published types need no `@types/node`) |
6495
+ | `ShutdownTarget` | _type_ | the `shutdown`-only slice a binding needs — an interface, so a [composite target](../guide/testing-and-deployment.md#composite-shutdown-target--parallel-domain-drains) can drain several domains under one signal machine |
6132
6496
  | `createRateLimiter` | function | token-bucket rate limiting — [guide](../guide/server.md#rate-limiting) |
6133
6497
  | `createCache` | function | an in-memory TTL cache |
6134
6498
  | `CacheOptions` | _type_ | bounded-cache options, including the maximum retained entry count |
@@ -6257,6 +6621,7 @@ payload.
6257
6621
  | `resolveMedia` | function | resolve a media reference for a tool result |
6258
6622
  | `validateMcpSchemas` | function | object-shaped assertion over the exact advertised schema surface — compatibility, typed properties and portable formats ([guide](../guide/mcp-and-agents.md#mcp-schema-validation-profile)) |
6259
6623
  | `listToolNames` | function | every contract/runtime tool name with origin, identity and transports — for stable snapshots — [guide](../guide/mcp-and-agents.md#pinning-tool-names--listtoolnames) |
6624
+ | `listContractToolNames` | function | the same listing straight from contracts — no handlers or stub services needed |
6260
6625
  | `McpHandlerConfig` | _type_ | server surface plus stateless HTTP transport config |
6261
6626
  | `McpHttpConfig` | _type_ | HTTP auth, protected-resource, legacy-era and security options |
6262
6627
  | `McpHttpHandler` | _type_ | framework-owned `{ fetch(request), close() }` lifecycle |
@@ -6434,7 +6799,7 @@ runtime-agnostic pieces of `stitchkit/server` and the error helpers.
6434
6799
  | `serveNode` | function | build the router and start a Node HTTP server (via `srvx`) |
6435
6800
  | `createHandler` | function | the router as a bare `(req) => Response` (same as `/server`) |
6436
6801
  | `createSocketIOServer` | function | the typed Node Socket.IO server (`io` + `attach`; no Bun engine declarations) |
6437
- | `implement` / `createImplement` | function | bind a contract to typed handlers (same as `/server`) |
6802
+ | `implement` / `createImplement` / `createScopedImplement` / `createScopedImplementRegistry` / `createMultipartStream` | function | bind a contract to typed handlers, optionally typed per endpoint scope (same as `/server`) |
6438
6803
  | `NodeServerConfig` | _type_ | config for `serveNode` |
6439
6804
  | `NodeServerHandle` | _type_ | managed Node handle (`url`, `port`, `runtime`, `status`, `shutdown`) |
6440
6805
  | `NodeRuntimeServer` | _type_ | concrete `srvx/node` runtime escape hatch |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitchkit",
3
- "version": "0.49.2",
3
+ "version": "0.51.0",
4
4
  "description": "Contract-first backend framework — one defineContract() into an HTTP API, MCP tools, AI-agent tools and a typed client. Bun and Node.",
5
5
  "keywords": [
6
6
  "bun",