stitchkit 0.50.0 → 0.52.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.
package/llms-full.txt CHANGED
@@ -737,7 +737,12 @@ Three boundaries worth knowing:
737
737
  it). It degrades to `unknown`, so it can no longer pose as a `string`; using it
738
738
  in a typed position fails.
739
739
  - The map states what **your** `beforeHandle` / `createAuthHook.inject` puts on
740
- the context. The framework does not verify that claim (→ ADR 0075).
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).
741
746
  - An endpoint hoisted out of the contract literal widens its `scope` to `string`,
742
747
  which is no key of any map, and lands in the error branch. An endpoint whose
743
748
  `scope` is added by a conditional spread is optional, so it resolves to **both**
@@ -753,6 +758,14 @@ The same map has a registry form and a streaming form:
753
758
  const implementAll = createScopedImplementRegistry<Scopes>()
754
759
  export const services = implementAll(apiContractRegistry, { posts, users })
755
760
 
761
+ // a service FILE declares its handlers typed but unbound — binding happens
762
+ // once, in the registry. Curried out of necessity: one call cannot both take
763
+ // the contract and contextually infer the handlers from it.
764
+ export const posts = implementFor.declare(postsContract)({
765
+ list: (ctx) => listPosts(ctx.userId), // ctx typed per endpoint scope
766
+ purge: (ctx) => (ctx.isAdmin ? purge() : []),
767
+ })
768
+
756
769
  // a streaming multipart handler that reads its scope's fields. The endpoint
757
770
  // must declare its own `scope`, and only that literal is accepted: an endpoint
758
771
  // inheriting the contract's scope cannot be verified from here, and guessing it
@@ -785,9 +798,22 @@ export const apiServices = implementRegistry(apiContractRegistry, {
785
798
 
786
799
  Every registry key is required, extra keys fail, and each handler map is checked
787
800
  against its own contract. The returned service order follows the contract
788
- registry order. `createImplementRegistry<AppContext>()` fixes the application
801
+ registry order and the same services ride along under `.byKey`, so a consumer
802
+ whose registry keys are load-bearing (filtering a tool surface per caller) never
803
+ rebuilds a prefix lookup by hand:
804
+
805
+ ```ts
806
+ const services = implementRegistry(apiContractRegistry, handlers)
807
+ createServer({ services }) // the array, as before
808
+ const visible = [services.byKey.users] // the same objects, by key
809
+ ```
810
+
811
+ `createImplementRegistry<AppContext>()` fixes the application
789
812
  context once, like `createImplement` does for a single contract. Runtime callers
790
- also fail first on missing/extra keys and duplicate contract prefixes.
813
+ also fail first on missing/extra keys and on a duplicate prefix **within one
814
+ scope** — the same prefix under two different group scopes is legal (their URLs
815
+ are separated by `scopePrefixes`, and a genuine path clash is the router's own
816
+ construction error).
791
817
 
792
818
  The registry is intentionally flat: every key must point to one concrete
793
819
  `defineContract()` result. Composed namespace arrays are mounted explicitly with
@@ -3755,7 +3781,61 @@ endpoints under the group declare `scope: 'tenant'`.
3755
3781
  | `onForbidden` | thrown when an identity is present but the rule rejects it (default 403) |
3756
3782
 
3757
3783
  Annotate `rules` with `satisfies Record<MyScope, AuthRule<User>>` so the compiler
3758
- catches a scope you forgot to cover.
3784
+ catches a scope you forgot to cover. With the scoped rule objects below, widen
3785
+ the annotation to
3786
+ `satisfies Record<MyScope, AuthRule<User> | ScopedAuthRule<User, object>>` — it
3787
+ keeps the coverage check and does not disturb the derivation. (When every scope
3788
+ comes from the derived map, the check is already implicit: a scope missing from
3789
+ `rules` is no key of the map, and a contract using it fails to compile.)
3790
+
3791
+ ### Scoped rules — the map `createScopedImplement` consumes
3792
+
3793
+ A rule may take an object form, `{ rule, inject }`, where `inject` **returns**
3794
+ the fields this scope contributes. That return type is a declaration the hook
3795
+ derives a scope→context map from — feed it to
3796
+ [`createScopedImplement`](./server.md#per-scope-handler-context--createscopedimplement)
3797
+ and the map can never drift from the hook, because it is computed from it:
3798
+
3799
+ ```ts
3800
+ const authHook = createAuthHook({
3801
+ resolve: sessionResolver, // no explicit generic — let it infer
3802
+ rules: {
3803
+ public: { rule: 'public', inject: (user) => ({ userId: user.id }) },
3804
+ user: { rule: 'authenticated', inject: (user) => ({ userId: user.id }) },
3805
+ admin: {
3806
+ rule: (user) => user.admin,
3807
+ inject: (user) => ({ userId: user.id, isAdmin: user.admin }),
3808
+ },
3809
+ },
3810
+ })
3811
+
3812
+ export const implementFor = createScopedImplement<AuthScopes<typeof authHook>>()
3813
+ ```
3814
+
3815
+ - A `'public'` rule's fields come out **optional**: public admits the anonymous
3816
+ caller, it does not refuse to know the logged-in one — `resolve` and `inject`
3817
+ still run, so a public `me` / `logout` handler reads `ctx.userId` as
3818
+ `string | undefined` and narrows, instead of being pushed toward a cast by a
3819
+ `public: object` map.
3820
+ - Any other rule's fields are required — the rule rejected the request before
3821
+ the handler if no identity resolved.
3822
+ - A scope with no rule is no key of the derived map, so a contract using it
3823
+ fails to compile — the type-level mirror of the hook's fail-closed
3824
+ `no rule for scope` throw.
3825
+
3826
+ The per-rule `inject` runs after the shared one, only when an identity
3827
+ resolved, and **before** the rule check — it may run for an identity the rule
3828
+ then rejects, so keep it pure and synchronous (an async `inject` is a compile
3829
+ error, and a runtime one for untyped callers: merging a Promise would merge
3830
+ nothing). Write rule objects inline in `rules` — a hoisted, annotated rule
3831
+ widens its inject and degrades that scope's fields to `object`.
3832
+
3833
+ Derivation needs the identity generic to be **inferred** (write no explicit
3834
+ `createAuthHook<User>` — TypeScript has no partial inference); with an explicit
3835
+ generic, or fields injected outside the hook, keep the hand-written map. Two
3836
+ hooks guarding different scopes compose by intersection:
3837
+ `createScopedImplement<AuthScopes<typeof userHook> & AuthScopes<typeof botHook>>()`.
3838
+ → ADR 0078
3759
3839
 
3760
3840
  ### Auth on the tool surface — `resolveFromContext`
3761
3841
 
@@ -4797,6 +4877,71 @@ closing one whose chain is already running leaves that chain to finish and keeps
4797
4877
  the handle claimed, since a second binding could not force it. `promise` is
4798
4878
  already observed internally, so ignoring it never raises an `unhandledRejection`.
4799
4879
 
4880
+ #### Composite shutdown target — parallel domain drains
4881
+
4882
+ `bindProcessSignals` takes `Pick<ManagedServerHandle, 'shutdown'>` — an
4883
+ **interface**, not the server. An application whose shutdown spans several
4884
+ domains (transport, bots, agent runs, broadcasts) composes them into one target;
4885
+ the signal machine stays the framework's, the composition stays yours:
4886
+
4887
+ ```ts
4888
+ const composite: ShutdownTarget = {
4889
+ async shutdown(options?: ShutdownOptions): Promise<ShutdownResult> {
4890
+ const { signal } = ShutdownOptionsSchema.parse(options ?? {})
4891
+ // Domain drains run in parallel with the transport drain, all watching the
4892
+ // same force signal the second OS signal aborts.
4893
+ const [transport, bots, agents, broadcasts] = await Promise.all([
4894
+ server.shutdown(options),
4895
+ drainBots(signal),
4896
+ drainAgents(signal),
4897
+ drainBroadcasts(signal),
4898
+ ])
4899
+ return {
4900
+ ...transport,
4901
+ // Fold domain leftovers into the transport result so `onComplete` sees
4902
+ // one honest outcome.
4903
+ outcome: transport.outcome === 'clean' && bots + agents + broadcasts === 0
4904
+ ? 'clean'
4905
+ : 'forced',
4906
+ }
4907
+ },
4908
+ }
4909
+
4910
+ bindProcessSignals(composite, { shutdown: { gracePeriodMs: 30_000 } })
4911
+ ```
4912
+
4913
+ The transport half inside stays the real managed handle — its admission gate and
4914
+ HTTP drain are not reimplemented, only composed. Domain drains must watch the
4915
+ `signal` themselves: it is the only channel a second OS signal has into a
4916
+ running chain.
4917
+
4918
+ #### The last line of defence is yours
4919
+
4920
+ The framework never calls `process.exit`, and `process.exitCode` only takes
4921
+ effect once the event loop empties — which is exactly what a stuck resource
4922
+ prevents. If the deployment must exit before the supervisor's `SIGKILL`, arm an
4923
+ application-side timer:
4924
+
4925
+ ```ts
4926
+ onShutdown: () => {
4927
+ setTimeout(() => process.exit(1), 60_000).unref()
4928
+ },
4929
+ ```
4930
+
4931
+ `unref()` keeps the timer from holding a healthy process open; it only fires if
4932
+ something else already is.
4933
+
4934
+ #### When NOT to use `bindProcessSignals`
4935
+
4936
+ - The application already runs a signal machine with states this one does not
4937
+ have (staged escalation policies, per-signal semantics beyond
4938
+ first/force/escalate). Two machines on the same signals is worse than either.
4939
+ - Signal policy must differ per signal (e.g. `SIGHUP` = reload, not shutdown) —
4940
+ bind only the terminating signals here and keep the rest yours.
4941
+ - Something else in the process must keep listening for the same signal: the
4942
+ escalation path restores the default disposition only when nothing else
4943
+ listens, and reports `onEscalationBlocked` otherwise.
4944
+
4800
4945
  The server owns HTTP/Socket.IO transport resources. MCP, databases, queues and
4801
4946
  domain run-state remain application resources and close explicitly after server
4802
4947
  drain. Do not call `runtime.stop()` or `socket.io.close()` in parallel with
@@ -6217,7 +6362,7 @@ Also re-exports the error helpers from `stitchkit/contract`.
6217
6362
  | `createHandler` | function | the router as a bare `(req) => Response` — [guide](../guide/server.md#createserver) |
6218
6363
  | `implement` | function | bind a contract to typed handlers — [guide](../guide/server.md#implement) |
6219
6364
  | `createImplement` | function | fix the handler context type once |
6220
- | `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) |
6365
+ | `createScopedImplement` | function | fix one scope→context map, then type every handler by its endpoint's effective scope; `.declare(contract)(handlers)` types without binding for the registry path, `.stream(scope, …)` covers streaming multipart — [guide](../guide/server.md#per-scope-handler-context--createscopedimplement) |
6221
6366
  | `ScopedHandlers` | _type_ | handler map typed per endpoint by its effective scope |
6222
6367
  | `ScopeContexts` | _type_ | scope → extra context fields map accepted by `createScopedImplement` |
6223
6368
  | `EffectiveScope` | _type_ | an endpoint's own `scope`, else its contract's group scope |
@@ -6229,6 +6374,7 @@ Also re-exports the error helpers from `stitchkit/contract`.
6229
6374
  | `implementRegistry` | function | bind one exact contract registry to one exact backend handler registry |
6230
6375
  | `createImplementRegistry` | function | context-typed factory for `implementRegistry` |
6231
6376
  | `ImplementationRegistry` | _type_ | flat literal registry of concrete contracts accepted by `implementRegistry` |
6377
+ | `KeyedServices` | _type_ | registry result: the mount-ordered `ServiceDef[]` carrying the same services under `.byKey` |
6232
6378
  | `RegistryHandlers` | _type_ | exact backend handler registry inferred from a contract registry |
6233
6379
  | `ExactRegistryHandlers` | _type_ | fail-first handler shape that rejects extra registry and endpoint keys |
6234
6380
  | `staticRoute` | function | a raw route that serves a directory |
@@ -6290,6 +6436,11 @@ Also re-exports the error helpers from `stitchkit/contract`.
6290
6436
  | `AuthHook` | _type_ | the hook `createAuthHook` returns |
6291
6437
  | `AuthHookConfig` | _type_ | config for `createAuthHook` |
6292
6438
  | `AuthRule` | _type_ | `'public' \| 'authenticated' \| predicate` |
6439
+ | `ScopedAuthRule` | _type_ | a rule plus its typed context contribution — `{ rule, inject? }` |
6440
+ | `AuthRules` | _type_ | the `rules` map: bare rules or scoped rules |
6441
+ | `RuleScopes` | _type_ | scope→context map derived from a `rules` object; `'public'` fields become optional |
6442
+ | `ScopedAuthHook` | _type_ | an auth hook carrying its derived scope map at the type level |
6443
+ | `AuthScopes` | _type_ | recover the derived map — `createScopedImplement<AuthScopes<typeof hook>>()` |
6293
6444
  | `BearerResolverConfig` | _type_ | config for `createBearerResolver` |
6294
6445
  | `JwtPayload` | _type_ | a decoded JWT payload |
6295
6446
  | `SignJwtOptions` | _type_ | options for `signJwt` (expiry, claims) |
@@ -6352,7 +6503,7 @@ Also re-exports the error helpers from `stitchkit/contract`.
6352
6503
  | `ProcessSignalsErrorPhase` | _type_ | `'prepare' \| 'shutdown' \| 'complete'` — which phase an `onError` report came from |
6353
6504
  | `SignalSource` | _type_ | injectable signal source — `process` by default |
6354
6505
  | `ProcessSignalName` | _type_ | signal names the binding accepts (owned, so the published types need no `@types/node`) |
6355
- | `ShutdownTarget` | _type_ | the `shutdown`-only slice of a managed handle a binding needs |
6506
+ | `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 |
6356
6507
  | `createRateLimiter` | function | token-bucket rate limiting — [guide](../guide/server.md#rate-limiting) |
6357
6508
  | `createCache` | function | an in-memory TTL cache |
6358
6509
  | `CacheOptions` | _type_ | bounded-cache options, including the maximum retained entry count |
@@ -6481,6 +6632,7 @@ payload.
6481
6632
  | `resolveMedia` | function | resolve a media reference for a tool result |
6482
6633
  | `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)) |
6483
6634
  | `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) |
6635
+ | `listContractToolNames` | function | the same listing straight from contracts — no handlers or stub services needed |
6484
6636
  | `McpHandlerConfig` | _type_ | server surface plus stateless HTTP transport config |
6485
6637
  | `McpHttpConfig` | _type_ | HTTP auth, protected-resource, legacy-era and security options |
6486
6638
  | `McpHttpHandler` | _type_ | framework-owned `{ fetch(request), close() }` lifecycle |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitchkit",
3
- "version": "0.50.0",
3
+ "version": "0.52.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",
@@ -1,57 +0,0 @@
1
- // src/server/middleware/cors.ts
2
- var DEFAULT_CORS_ALLOW_HEADERS = "Content-Type, Authorization, X-Trace-Id, traceparent, tracestate";
3
- var DEFAULT_CORS_EXPOSE_HEADERS = "Content-Disposition, Content-Length, Content-Range, Accept-Ranges, ETag, Last-Modified, X-Request-Id";
4
- function assertCorsConfig(config) {
5
- if (config.credentials && (config.origin === undefined || config.origin === "*")) {
6
- throw new Error("[stitchkit] cors: `credentials: true` cannot be combined with a wildcard origin. " + "Set `origin` to an explicit string or list.");
7
- }
8
- if (config.origin === undefined) {
9
- throw new Error("[stitchkit] cors: `origin` is required. Pass an explicit origin (or list), or '*' to deliberately allow every origin — omit `cors` entirely to emit no CORS headers.");
10
- }
11
- if (Array.isArray(config.origin) && config.origin.length === 0) {
12
- throw new Error("[stitchkit] cors: `origin` cannot be an empty list. Pass explicit origins, or '*' to deliberately allow every origin.");
13
- }
14
- }
15
- function resolveOrigin(config, requestOrigin) {
16
- if (config.origin === undefined)
17
- return;
18
- if (config.origin === "*") {
19
- return "*";
20
- }
21
- if (Array.isArray(config.origin)) {
22
- if (!requestOrigin)
23
- return;
24
- const lower = requestOrigin.toLowerCase();
25
- return config.origin.some((o) => o.toLowerCase() === lower) ? requestOrigin : undefined;
26
- }
27
- return config.origin;
28
- }
29
- function corsHeaders(config, requestOrigin) {
30
- const allowOrigin = resolveOrigin(config, requestOrigin);
31
- const headers = {
32
- "Access-Control-Allow-Methods": config.methods ?? "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS",
33
- "Access-Control-Allow-Headers": config.headers ?? DEFAULT_CORS_ALLOW_HEADERS
34
- };
35
- const expose = Array.isArray(config.exposeHeaders) ? config.exposeHeaders.join(", ") : config.exposeHeaders ?? DEFAULT_CORS_EXPOSE_HEADERS;
36
- if (expose !== "") {
37
- headers["Access-Control-Expose-Headers"] = expose;
38
- }
39
- if (allowOrigin !== undefined) {
40
- headers["Access-Control-Allow-Origin"] = allowOrigin;
41
- if (config.credentials) {
42
- headers["Access-Control-Allow-Credentials"] = "true";
43
- }
44
- }
45
- if (Array.isArray(config.origin)) {
46
- headers.Vary = "Origin";
47
- }
48
- return headers;
49
- }
50
- function corsPreflightResponse(config, req) {
51
- return new Response(null, {
52
- status: 204,
53
- headers: corsHeaders(config, req.headers.get("origin"))
54
- });
55
- }
56
-
57
- export { DEFAULT_CORS_ALLOW_HEADERS, DEFAULT_CORS_EXPOSE_HEADERS, assertCorsConfig, corsHeaders, corsPreflightResponse };