@beignet/core 0.0.48 → 0.0.49

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 (89) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +64 -14
  3. package/dist/client/client.d.ts +0 -2
  4. package/dist/client/client.d.ts.map +1 -1
  5. package/dist/client/client.js +28 -25
  6. package/dist/client/client.js.map +1 -1
  7. package/dist/contracts/contract-builder.d.ts +7 -2
  8. package/dist/contracts/contract-builder.d.ts.map +1 -1
  9. package/dist/contracts/contract-builder.js +20 -2
  10. package/dist/contracts/contract-builder.js.map +1 -1
  11. package/dist/contracts/contract-group.d.ts.map +1 -1
  12. package/dist/contracts/contract-group.js +1 -0
  13. package/dist/contracts/contract-group.js.map +1 -1
  14. package/dist/contracts/contract-like.d.ts +2 -0
  15. package/dist/contracts/contract-like.d.ts.map +1 -1
  16. package/dist/contracts/contract-like.js +27 -1
  17. package/dist/contracts/contract-like.js.map +1 -1
  18. package/dist/contracts/index.d.ts +4 -0
  19. package/dist/contracts/index.d.ts.map +1 -1
  20. package/dist/contracts/index.js +4 -0
  21. package/dist/contracts/index.js.map +1 -1
  22. package/dist/contracts/query-transport.d.ts +126 -0
  23. package/dist/contracts/query-transport.d.ts.map +1 -0
  24. package/dist/contracts/query-transport.js +406 -0
  25. package/dist/contracts/query-transport.js.map +1 -0
  26. package/dist/contracts/schema-shape.d.ts +11 -0
  27. package/dist/contracts/schema-shape.d.ts.map +1 -1
  28. package/dist/contracts/schema-shape.js +13 -0
  29. package/dist/contracts/schema-shape.js.map +1 -1
  30. package/dist/contracts/types.d.ts +5 -0
  31. package/dist/contracts/types.d.ts.map +1 -1
  32. package/dist/contracts/types.js.map +1 -1
  33. package/dist/openapi/index.d.ts +8 -0
  34. package/dist/openapi/index.d.ts.map +1 -1
  35. package/dist/openapi/index.js +79 -5
  36. package/dist/openapi/index.js.map +1 -1
  37. package/dist/outbox/index.d.ts +8 -5
  38. package/dist/outbox/index.d.ts.map +1 -1
  39. package/dist/outbox/index.js +17 -3
  40. package/dist/outbox/index.js.map +1 -1
  41. package/dist/ports/cache.d.ts +9 -1
  42. package/dist/ports/cache.d.ts.map +1 -1
  43. package/dist/ports/cache.js +20 -5
  44. package/dist/ports/cache.js.map +1 -1
  45. package/dist/ports/index.d.ts +1 -1
  46. package/dist/ports/index.d.ts.map +1 -1
  47. package/dist/ports/index.js +1 -1
  48. package/dist/ports/index.js.map +1 -1
  49. package/dist/query-codec.d.ts +24 -0
  50. package/dist/query-codec.d.ts.map +1 -1
  51. package/dist/query-codec.js +207 -72
  52. package/dist/query-codec.js.map +1 -1
  53. package/dist/server/hooks/rate-limit.d.ts +16 -8
  54. package/dist/server/hooks/rate-limit.d.ts.map +1 -1
  55. package/dist/server/hooks/rate-limit.js +31 -17
  56. package/dist/server/hooks/rate-limit.js.map +1 -1
  57. package/dist/server/request-executor.d.ts.map +1 -1
  58. package/dist/server/request-executor.js +9 -9
  59. package/dist/server/request-executor.js.map +1 -1
  60. package/dist/server/request-preparation.d.ts.map +1 -1
  61. package/dist/server/request-preparation.js +11 -9
  62. package/dist/server/request-preparation.js.map +1 -1
  63. package/dist/server/server.d.ts.map +1 -1
  64. package/dist/server/server.js +2 -0
  65. package/dist/server/server.js.map +1 -1
  66. package/dist/server/use-case-route.d.ts +8 -5
  67. package/dist/server/use-case-route.d.ts.map +1 -1
  68. package/dist/server/use-case-route.js +44 -17
  69. package/dist/server/use-case-route.js.map +1 -1
  70. package/package.json +3 -3
  71. package/skills/app-architecture/SKILL.md +27 -1
  72. package/src/client/client.ts +29 -28
  73. package/src/contracts/contract-builder.ts +32 -2
  74. package/src/contracts/contract-group.ts +1 -0
  75. package/src/contracts/contract-like.ts +40 -1
  76. package/src/contracts/index.ts +23 -0
  77. package/src/contracts/query-transport.ts +697 -0
  78. package/src/contracts/schema-shape.ts +24 -0
  79. package/src/contracts/types.ts +5 -0
  80. package/src/openapi/index.ts +126 -2
  81. package/src/outbox/index.ts +26 -5
  82. package/src/ports/cache.ts +29 -7
  83. package/src/ports/index.ts +1 -1
  84. package/src/server/hooks/rate-limit.ts +50 -24
  85. package/src/server/request-executor.ts +17 -9
  86. package/src/server/request-preparation.ts +22 -9
  87. package/src/server/server.ts +2 -0
  88. package/src/server/use-case-route.ts +62 -23
  89. package/src/query-codec.ts +0 -130
@@ -73,3 +73,27 @@ export function formatPathParamsMismatch(args: {
73
73
  .filter(Boolean)
74
74
  .join("; ");
75
75
  }
76
+
77
+ /** Compare an introspected query schema's fields with its transport fields. */
78
+ export function compareQueryTransportFields(args: {
79
+ schemaKeys: readonly string[];
80
+ transportKeys: readonly string[];
81
+ }): boolean {
82
+ const schemaKeys = [...args.schemaKeys].sort();
83
+ const transportKeys = [...args.transportKeys].sort();
84
+ return (
85
+ schemaKeys.length === transportKeys.length &&
86
+ schemaKeys.every((key, index) => key === transportKeys[index])
87
+ );
88
+ }
89
+
90
+ /** Format a query schema/transport mismatch consistently across consumers. */
91
+ export function formatQueryTransportMismatch(args: {
92
+ contractName: string;
93
+ schemaKeys: readonly string[];
94
+ transportKeys: readonly string[];
95
+ }): string {
96
+ const schemaKeys = [...args.schemaKeys].sort();
97
+ const transportKeys = [...args.transportKeys].sort();
98
+ return `Query transport fields for contract "${args.contractName}" must match its query schema fields (schema: ${schemaKeys.join(", ") || "none"}; transport: ${transportKeys.join(", ") || "none"}).`;
99
+ }
@@ -2,6 +2,7 @@ import type { StandardSchemaV1 } from "@standard-schema/spec";
2
2
  import type { IdempotencyMeta } from "../idempotency/index.js";
3
3
  import type { ContractDeprecationMeta } from "./lifecycle.js";
4
4
  import type { OpenAPIOperationMeta } from "./openapi-meta.js";
5
+ import type { QueryTransport } from "./query-transport.js";
5
6
  import type { RateLimitMeta } from "./rate-limit.js";
6
7
 
7
8
  /**
@@ -343,6 +344,10 @@ export type HttpContractConfig<
343
344
  * Query parameter schema.
344
345
  */
345
346
  query: TQuery;
347
+ /**
348
+ * Deterministic HTTP encoding for the query schema.
349
+ */
350
+ queryTransport: QueryTransport | null;
346
351
  /**
347
352
  * Request header schema or schemas.
348
353
  */
@@ -11,6 +11,8 @@ import {
11
11
  getContractHeaderSchemas,
12
12
  methodSupportsRequestBody,
13
13
  parsePathTemplate,
14
+ type QueryFieldTransport,
15
+ queryTransportSchema,
14
16
  resolveContract,
15
17
  STANDARD_ERROR_RESPONSE_SCHEMA,
16
18
  } from "../contracts/index.js";
@@ -20,7 +22,9 @@ import {
20
22
  } from "../contracts/lifecycle.js";
21
23
  import {
22
24
  comparePathParamsToTemplate,
25
+ compareQueryTransportFields,
23
26
  formatPathParamsMismatch,
27
+ formatQueryTransportMismatch,
24
28
  } from "../contracts/schema-shape.js";
25
29
  import {
26
30
  createZodIntrospector,
@@ -157,6 +161,21 @@ export interface ParameterObject {
157
161
  * Parameter schema.
158
162
  */
159
163
  schema?: SchemaObject | ReferenceObject;
164
+ /**
165
+ * OpenAPI parameter serialization style.
166
+ */
167
+ style?:
168
+ | "form"
169
+ | "simple"
170
+ | "matrix"
171
+ | "label"
172
+ | "spaceDelimited"
173
+ | "pipeDelimited"
174
+ | "deepObject";
175
+ /**
176
+ * Whether arrays and objects are expanded into separate parameter values.
177
+ */
178
+ explode?: boolean;
160
179
  /**
161
180
  * Parameter description.
162
181
  */
@@ -627,12 +646,36 @@ function addQueryParams(
627
646
  state: GeneratorState,
628
647
  ): void {
629
648
  if (!contract.query) return;
649
+ const transport = contract.queryTransport;
650
+ if (!transport) {
651
+ throw new Error(
652
+ `Contract "${contract.name}" declares a query schema without a query transport.`,
653
+ );
654
+ }
630
655
 
631
656
  const shape = state.introspector.getShape(contract.query);
632
- if (!shape) return;
657
+ if (!shape) {
658
+ throw new Error(
659
+ `Unable to inspect query schema for contract "${contract.name}". Pass a schemaIntrospector that can read its fields so OpenAPI requiredness and constraints remain aligned with the declared query transport.`,
660
+ );
661
+ }
633
662
 
634
- for (const key of Object.keys(shape)) {
663
+ const schemaKeys = Object.keys(shape);
664
+ const transportKeys = Object.keys(transport.fields);
665
+ if (!compareQueryTransportFields({ schemaKeys, transportKeys })) {
666
+ throw new Error(
667
+ formatQueryTransportMismatch({
668
+ contractName: contract.name,
669
+ schemaKeys,
670
+ transportKeys,
671
+ }),
672
+ );
673
+ }
674
+
675
+ for (const key of Object.keys(transport.fields)) {
635
676
  const originalField = shape[key];
677
+ const fieldTransport = transport.fields[key];
678
+ if (!originalField || !fieldTransport) continue;
636
679
  const optional = state.introspector.isOptional(originalField);
637
680
 
638
681
  const field = originalField;
@@ -644,6 +687,7 @@ function addQueryParams(
644
687
  state,
645
688
  "input",
646
689
  );
690
+ applyQueryTransportSchema(paramSchemaRef, fieldTransport, state);
647
691
 
648
692
  const param: ParameterObject = {
649
693
  name: key,
@@ -651,12 +695,92 @@ function addQueryParams(
651
695
  required: !optional,
652
696
  schema: paramSchemaRef,
653
697
  description,
698
+ style: fieldTransport.kind === "deep-object" ? "deepObject" : "form",
699
+ explode: true,
654
700
  };
655
701
 
656
702
  addParameter(operation, param);
657
703
  }
658
704
  }
659
705
 
706
+ function applyQueryTransportSchema(
707
+ schema: SchemaObject | ReferenceObject,
708
+ transport: QueryFieldTransport,
709
+ state: GeneratorState,
710
+ ): void {
711
+ if (!("$ref" in schema) || typeof schema.$ref !== "string") return;
712
+ const prefix = "#/components/schemas/";
713
+ if (!schema.$ref.startsWith(prefix)) return;
714
+ const name = schema.$ref.slice(prefix.length);
715
+ const converted = state.components.schemas?.[name];
716
+ if (!converted || !state.components.schemas) return;
717
+ state.components.schemas[name] = mergeQueryTransportSchema(
718
+ converted,
719
+ transport,
720
+ );
721
+ }
722
+
723
+ function mergeQueryTransportSchema(
724
+ converted: SchemaObject,
725
+ transport: QueryFieldTransport,
726
+ ): SchemaObject {
727
+ const declared = queryTransportSchema(transport);
728
+ const merged: SchemaObject = {
729
+ ...converted,
730
+ type: declared.type,
731
+ };
732
+ if (declared.format) merged.format = declared.format;
733
+ if (transport.kind === "integer") {
734
+ merged.minimum = Math.max(
735
+ typeof converted.minimum === "number"
736
+ ? converted.minimum
737
+ : Number.MIN_SAFE_INTEGER,
738
+ Number.MIN_SAFE_INTEGER,
739
+ );
740
+ merged.maximum = Math.min(
741
+ typeof converted.maximum === "number"
742
+ ? converted.maximum
743
+ : Number.MAX_SAFE_INTEGER,
744
+ Number.MAX_SAFE_INTEGER,
745
+ );
746
+ }
747
+ if (declared["x-beignet-empty-query"]) {
748
+ merged["x-beignet-empty-query"] = declared["x-beignet-empty-query"];
749
+ }
750
+
751
+ if (transport.kind === "array") {
752
+ const convertedItems =
753
+ typeof converted.items === "object" && converted.items !== null
754
+ ? (converted.items as SchemaObject)
755
+ : {};
756
+ merged.items = mergeQueryTransportSchema(convertedItems, transport.item);
757
+ }
758
+
759
+ if (transport.kind === "deep-object") {
760
+ const convertedProperties =
761
+ typeof converted.properties === "object" && converted.properties !== null
762
+ ? (converted.properties as Record<string, unknown>)
763
+ : {};
764
+ merged.properties = Object.fromEntries(
765
+ Object.entries(transport.fields).map(([key, field]) => {
766
+ const convertedProperty = convertedProperties[key];
767
+ return [
768
+ key,
769
+ mergeQueryTransportSchema(
770
+ typeof convertedProperty === "object" && convertedProperty !== null
771
+ ? (convertedProperty as SchemaObject)
772
+ : {},
773
+ field,
774
+ ),
775
+ ];
776
+ }),
777
+ );
778
+ merged.additionalProperties = false;
779
+ }
780
+
781
+ return merged;
782
+ }
783
+
660
784
  /**
661
785
  * Add header parameters from contract to operation.
662
786
  */
@@ -555,7 +555,8 @@ export interface DrainOutboxOptions {
555
555
  */
556
556
  registry: OutboxRegistry;
557
557
  /**
558
- * Event bus used for event messages.
558
+ * Event bus used for event messages. Required when the registry contains
559
+ * events.
559
560
  */
560
561
  eventBus?: {
561
562
  publish<E extends EventPayloadDef>(
@@ -565,7 +566,8 @@ export interface DrainOutboxOptions {
565
566
  ): MaybePromise<void>;
566
567
  };
567
568
  /**
568
- * Job dispatcher used for job messages.
569
+ * Job dispatcher used for job messages. Required when the registry contains
570
+ * jobs.
569
571
  */
570
572
  jobs?: JobDispatcherPort;
571
573
  /**
@@ -1476,15 +1478,17 @@ async function deliverOutboxMessage(
1476
1478
  * Claim and deliver one batch of outbox messages.
1477
1479
  *
1478
1480
  * This does not loop forever; production workers should call it on their own
1479
- * polling cadence. Event and job messages require matching registry entries.
1480
- * Failed messages are retried with backoff until `maxAttempts`, then
1481
- * dead-lettered.
1481
+ * polling cadence. Event and job messages require matching registry entries
1482
+ * and delivery transports. Required transports are validated before a batch
1483
+ * is claimed. Failed messages are retried with backoff until `maxAttempts`,
1484
+ * then dead-lettered.
1482
1485
  */
1483
1486
  export async function drainOutbox(
1484
1487
  options: DrainOutboxOptions,
1485
1488
  ): Promise<DrainOutboxResult> {
1486
1489
  const batchSize = options.batchSize ?? 100;
1487
1490
  assertPositiveInteger("batchSize", batchSize);
1491
+ assertOutboxDeliveryCapabilities(options);
1488
1492
  const instrumentation = createProviderInstrumentation(
1489
1493
  options.instrumentation,
1490
1494
  {
@@ -1659,6 +1663,23 @@ export async function drainOutbox(
1659
1663
  return result;
1660
1664
  }
1661
1665
 
1666
+ function assertOutboxDeliveryCapabilities(options: DrainOutboxOptions): void {
1667
+ const missing: string[] = [];
1668
+
1669
+ if (options.registry.events.size > 0 && !options.eventBus) {
1670
+ missing.push("events require an event bus");
1671
+ }
1672
+ if (options.registry.jobs.size > 0 && !options.jobs) {
1673
+ missing.push("jobs require a job dispatcher");
1674
+ }
1675
+
1676
+ if (missing.length > 0) {
1677
+ throw new OutboxRegistryError(
1678
+ `Cannot drain this outbox registry: ${missing.join("; ")}.`,
1679
+ );
1680
+ }
1681
+ }
1682
+
1662
1683
  /**
1663
1684
  * Domain event recorder port re-exported for outbox integrations.
1664
1685
  */
@@ -3,11 +3,35 @@
3
3
  */
4
4
  export interface CacheSetOptions {
5
5
  /**
6
- * Time-to-live in seconds. Omit this for a value that does not expire.
6
+ * Time-to-live in seconds. Values must be positive safe integers. Omit this
7
+ * for a value that does not expire.
7
8
  */
8
9
  ttlSeconds?: number;
9
10
  }
10
11
 
12
+ /**
13
+ * Resolve and validate the optional TTL shared by cache adapters.
14
+ *
15
+ * @param options - Cache write options.
16
+ * @returns The positive TTL, or `undefined` for a persistent value.
17
+ */
18
+ export function resolveCacheTtlSeconds(
19
+ options?: CacheSetOptions,
20
+ ): number | undefined {
21
+ const ttlSeconds = options?.ttlSeconds;
22
+ if (ttlSeconds === undefined) {
23
+ return undefined;
24
+ }
25
+
26
+ if (!Number.isSafeInteger(ttlSeconds) || ttlSeconds <= 0) {
27
+ throw new RangeError(
28
+ "Cache ttlSeconds must be a positive safe integer when provided.",
29
+ );
30
+ }
31
+
32
+ return ttlSeconds;
33
+ }
34
+
11
35
  /**
12
36
  * App-facing string cache port.
13
37
  *
@@ -54,15 +78,12 @@ type MemoryCacheEntry = {
54
78
  };
55
79
 
56
80
  function resolveExpiresAt(options: CacheSetOptions | undefined): number | null {
57
- if (options?.ttlSeconds == null) {
81
+ const ttlSeconds = resolveCacheTtlSeconds(options);
82
+ if (ttlSeconds === undefined) {
58
83
  return null;
59
84
  }
60
85
 
61
- if (options.ttlSeconds <= 0) {
62
- return Date.now();
63
- }
64
-
65
- return Date.now() + options.ttlSeconds * 1000;
86
+ return Math.min(Number.MAX_SAFE_INTEGER, Date.now() + ttlSeconds * 1000);
66
87
  }
67
88
 
68
89
  function isExpired(entry: MemoryCacheEntry): boolean {
@@ -120,6 +141,7 @@ export function createMemoryCache(
120
141
  return (await getFreshEntry(key)) != null;
121
142
  },
122
143
  async remember(key, factory, options) {
144
+ resolveCacheTtlSeconds(options);
123
145
  const cached = await cache.get(key);
124
146
  if (cached != null) {
125
147
  return cached;
@@ -483,7 +483,7 @@ export type { CachePort, CacheSetOptions } from "./cache.js";
483
483
  /**
484
484
  * Cache helper exports.
485
485
  */
486
- export { createMemoryCache } from "./cache.js";
486
+ export { createMemoryCache, resolveCacheTtlSeconds } from "./cache.js";
487
487
  /**
488
488
  * Clock port exports.
489
489
  */
@@ -4,7 +4,11 @@
4
4
 
5
5
  import type { RateLimitScope } from "../../contracts/index.js";
6
6
  import { AppError, httpErrors } from "../../errors/index.js";
7
- import type { ActivityActor, RateLimitPort } from "../../ports/index.js";
7
+ import {
8
+ type ActivityActor,
9
+ AuthUnauthorizedError,
10
+ type RateLimitPort,
11
+ } from "../../ports/index.js";
8
12
  import {
9
13
  createProviderInstrumentation,
10
14
  type ProviderInstrumentationTarget,
@@ -38,8 +42,9 @@ type EarlyRateLimitScope = Exclude<RateLimitScope, "user">;
38
42
  * Strategy for resolving the client IP used by `ip`-scoped limits.
39
43
  *
40
44
  * - `"none"`: do not trust request headers for IP resolution. Every request
41
- * shares one `ip:unknown` bucket; this is the explicit opt-out for apps
42
- * that declare `ip` scopes without a trusted client-IP source.
45
+ * to one contract shares that contract's unknown-client bucket; this is the
46
+ * explicit opt-out for apps that declare `ip` scopes without a trusted
47
+ * client-IP source.
43
48
  * - `"x-forwarded-for-last"`: the last `x-forwarded-for` entry. Use this only
44
49
  * when the app is always behind a trusted reverse proxy that appends the
45
50
  * socket address.
@@ -59,7 +64,9 @@ export interface RateLimitOptions<Ctx> {
59
64
  /**
60
65
  * Build a rate-limit key after context exists.
61
66
  *
62
- * This is used for user-scoped limits and any late key strategy.
67
+ * This is used for user-scoped limits and any late key strategy. The
68
+ * returned value is the complete key and is not automatically namespaced by
69
+ * contract.
63
70
  */
64
71
  key?: (args: {
65
72
  ctx: Ctx;
@@ -69,7 +76,8 @@ export interface RateLimitOptions<Ctx> {
69
76
  /**
70
77
  * Build a rate-limit key before request parsing and context creation.
71
78
  *
72
- * This is used for global and IP-scoped limits.
79
+ * This is used for global and IP-scoped limits. The returned value is the
80
+ * complete key and is not automatically namespaced by contract.
73
81
  */
74
82
  earlyKey?: (args: {
75
83
  req: HttpRequestLike;
@@ -124,16 +132,23 @@ function ipSourceConfigurationError(contractNames: string): Error {
124
132
  `createRateLimitHooks(...) has no client IP source configured, but contract(s) ${contractNames} declare an "ip"-scoped rate limit. ` +
125
133
  `Set trustedProxy.clientIp or ipSource to a header source written by a trusted edge, ` +
126
134
  `for example "x-forwarded-for-last", "x-forwarded-for-first", "x-real-ip", "cf-connecting-ip", or a custom function, ` +
127
- `or "none" to explicitly accept one shared ip:unknown bucket for all clients.`,
135
+ `or "none" to explicitly accept one shared unknown-client bucket per contract.`,
128
136
  );
129
137
  }
130
138
 
131
- function emitUserKey(userId: string): string {
132
- return `user:${userId}`;
139
+ function namespaceContractKey(contractName: string, key: string): string {
140
+ const encodedContractName = contractName
141
+ .replaceAll("%", "%25")
142
+ .replaceAll(":", "%3A");
143
+ return `contract:${encodedContractName}:${key}`;
144
+ }
145
+
146
+ function emitUserKey(contractName: string, userId: string): string {
147
+ return namespaceContractKey(contractName, `user:${userId}`);
133
148
  }
134
149
 
135
- function emitIpKey(ip: string): string {
136
- return `ip:${ip}`;
150
+ function emitIpKey(contractName: string, ip: string): string {
151
+ return namespaceContractKey(contractName, `ip:${ip}`);
137
152
  }
138
153
 
139
154
  function defaultRateLimitKey<Ctx extends CtxWithRateLimit>(
@@ -141,36 +156,41 @@ function defaultRateLimitKey<Ctx extends CtxWithRateLimit>(
141
156
  ctx: Ctx;
142
157
  req: HttpRequestLike;
143
158
  scope: RateLimitScope;
159
+ contractName: string;
144
160
  },
145
161
  getClientIp: (req: HttpRequestLike) => string | undefined,
146
162
  ): string {
147
- const { ctx, req, scope } = args;
163
+ const { contractName, ctx, req, scope } = args;
148
164
 
149
- if (scope === "user" && ctx.actor?.type === "user" && ctx.actor.id) {
150
- return emitUserKey(ctx.actor.id);
165
+ if (scope === "user") {
166
+ if (ctx.actor?.type !== "user" || !ctx.actor.id) {
167
+ throw new AuthUnauthorizedError();
168
+ }
169
+ return emitUserKey(contractName, ctx.actor.id);
151
170
  }
152
171
 
153
172
  if (scope === "ip") {
154
173
  const ip = getClientIp(req) || "unknown";
155
- return emitIpKey(ip);
174
+ return emitIpKey(contractName, ip);
156
175
  }
157
176
 
158
- return "global";
177
+ return namespaceContractKey(contractName, "global");
159
178
  }
160
179
 
161
180
  function defaultEarlyRateLimitKey(
162
181
  args: {
163
182
  req: HttpRequestLike;
164
183
  scope: EarlyRateLimitScope;
184
+ contractName: string;
165
185
  },
166
186
  getClientIp: (req: HttpRequestLike) => string | undefined,
167
187
  ): string {
168
188
  if (args.scope === "ip") {
169
189
  const ip = getClientIp(args.req) || "unknown";
170
- return emitIpKey(ip);
190
+ return emitIpKey(args.contractName, ip);
171
191
  }
172
192
 
173
- return "global";
193
+ return namespaceContractKey(args.contractName, "global");
174
194
  }
175
195
 
176
196
  async function enforceRateLimit(
@@ -233,7 +253,10 @@ async function enforceRateLimit(
233
253
  * The hook reads `contract.metadata.rateLimit`. Global and IP-scoped limits run
234
254
  * in `onRequest` before context creation; user-scoped limits run in
235
255
  * `beforeHandle` after route hooks have resolved identity and `ctx.actor` is
236
- * available. Exceeded limits throw the framework `TooManyRequests` app error
256
+ * available. Default keys include the contract name so unrelated contracts do
257
+ * not share counters. A user-scoped limit without a resolved user actor fails
258
+ * with `AuthUnauthorizedError` instead of falling back to a global bucket.
259
+ * Exceeded limits throw the framework `TooManyRequests` app error
237
260
  * with `scope`, `retryAfterSeconds`, and `resetAt` details, and the 429
238
261
  * response carries a `Retry-After` header when the limiter reports a reset
239
262
  * time. The bucket key is
@@ -243,11 +266,12 @@ async function enforceRateLimit(
243
266
  * `ip`-scoped limits require an explicit `trustedProxy.clientIp`, `ipSource`,
244
267
  * or custom `earlyKey`: the hook's `validate` phase fails `createServer(...)`
245
268
  * startup when a registered contract declares an `ip` scope without one,
246
- * instead of silently collapsing all clients into a shared `ip:unknown` bucket.
269
+ * instead of silently collapsing all clients into one shared bucket.
247
270
  * Contracts added later through `server.route(...)` are not visible to
248
271
  * `validate`, so enforcing an `ip`-scoped limit without a client-IP source
249
272
  * throws the same configuration error at request time as a backstop. Pass
250
- * `ipSource: "none"` to explicitly opt in to the shared `ip:unknown` bucket.
273
+ * `ipSource: "none"` to explicitly opt in to one unknown-client bucket per
274
+ * contract.
251
275
  *
252
276
  * @param options - Optional key builders and client-IP source.
253
277
  * @returns A server hook backed by `ctx.ports.rateLimit`.
@@ -309,8 +333,9 @@ export function createRateLimitHooks<Ctx extends CtxWithRateLimit>(
309
333
 
310
334
  const key =
311
335
  options.earlyKey?.({ req, scope }) ??
312
- defaultEarlyRateLimitKey({ req, scope }, (r) =>
313
- getClientIp(r, requestInfo, contract.name),
336
+ defaultEarlyRateLimitKey(
337
+ { req, scope, contractName: contract.name },
338
+ (r) => getClientIp(r, requestInfo, contract.name),
314
339
  );
315
340
 
316
341
  await enforceRateLimit(ports, {
@@ -335,8 +360,9 @@ export function createRateLimitHooks<Ctx extends CtxWithRateLimit>(
335
360
 
336
361
  const key =
337
362
  options.key?.({ ctx, req, scope }) ??
338
- defaultRateLimitKey({ ctx, req, scope }, (r) =>
339
- getClientIp(r, requestInfo, contract.name),
363
+ defaultRateLimitKey(
364
+ { ctx, req, scope, contractName: contract.name },
365
+ (r) => getClientIp(r, requestInfo, contract.name),
340
366
  );
341
367
 
342
368
  await enforceRateLimit(ctx.ports, {
@@ -949,17 +949,25 @@ export function createRequestExecutor<
949
949
  error,
950
950
  owner: "framework",
951
951
  };
952
- finalResponse = normalizeHttpResponse(result.response);
953
- finalError = result.error;
954
- finalOwner = responseOwnerFor(finalResponse, result.owner);
955
- result = await applyTransformHooks(result, true);
956
- finalResponse = normalizeHttpResponse(result.response);
957
- finalError = result.error;
958
- finalOwner = responseOwnerFor(finalResponse, result.owner);
959
- responseValidation = "not-applicable";
960
952
  } else {
961
- throw error;
953
+ result = await resolveErrorResult(
954
+ error,
955
+ result.ctx,
956
+ pathValue,
957
+ queryValue,
958
+ headersValue,
959
+ bodyValue,
960
+ { owner: "framework" },
961
+ );
962
962
  }
963
+ finalResponse = normalizeHttpResponse(result.response);
964
+ finalError = result.error;
965
+ finalOwner = responseOwnerFor(finalResponse, result.owner);
966
+ result = await applyTransformHooks(result, true);
967
+ finalResponse = normalizeHttpResponse(result.response);
968
+ finalError = result.error;
969
+ finalOwner = responseOwnerFor(finalResponse, result.owner);
970
+ responseValidation = "not-applicable";
963
971
  }
964
972
  }
965
973
 
@@ -1,10 +1,11 @@
1
1
  import {
2
+ decodeQueryTransport,
2
3
  getContractHeaderSchemas,
3
4
  type HttpContractConfig,
4
5
  methodSupportsRequestBody,
6
+ QueryTransportError,
5
7
  type StandardSchema,
6
8
  } from "../contracts/index.js";
7
- import { decodeQueryValue } from "../query-codec.js";
8
9
  import type { HttpRequestLike, HttpResponseLike } from "./http.js";
9
10
  import {
10
11
  parseStandardSchema,
@@ -82,7 +83,10 @@ function requestValidationDetails(
82
83
  ...additionalDetails,
83
84
  };
84
85
 
85
- if (error instanceof SchemaValidationError) {
86
+ if (
87
+ error instanceof SchemaValidationError ||
88
+ error instanceof QueryTransportError
89
+ ) {
86
90
  return {
87
91
  ...details,
88
92
  issues: error.issues,
@@ -282,17 +286,26 @@ export async function prepareRequestInputs(args: {
282
286
  maxRequestBodyBytes,
283
287
  rawRoute,
284
288
  } = args;
285
- const rawQuery: Record<string, unknown> = {};
286
- for (const key of new Set(url.searchParams.keys())) {
287
- const values = url.searchParams.getAll(key);
288
- const decoded = values.map(decodeQueryValue);
289
- rawQuery[key] = decoded.length === 1 ? decoded[0] : decoded;
290
- }
289
+ const rawQuery = Object.fromEntries(
290
+ [...new Set(url.searchParams.keys())].map((key) => {
291
+ const values = url.searchParams.getAll(key);
292
+ return [key, values.length === 1 ? values[0] : values];
293
+ }),
294
+ );
291
295
 
292
296
  let query: unknown = rawQuery;
293
297
  if (contract.query) {
298
+ if (!contract.queryTransport) {
299
+ throw new Error(
300
+ `Contract "${contract.name}" declares a query schema without a query transport.`,
301
+ );
302
+ }
294
303
  try {
295
- query = await parseStandardSchema(contract.query, query);
304
+ const decoded = decodeQueryTransport(
305
+ contract.queryTransport,
306
+ url.searchParams,
307
+ );
308
+ query = await parseStandardSchema(contract.query, decoded);
296
309
  } catch (error) {
297
310
  return {
298
311
  ok: false,
@@ -807,6 +807,7 @@ export async function createServer<
807
807
  path,
808
808
  pathParams: null,
809
809
  query: null,
810
+ queryTransport: null,
810
811
  body: null,
811
812
  responses: {},
812
813
  metadata: {},
@@ -929,6 +930,7 @@ function rawRouteContract(init: RawRouteInit): HttpContractConfig {
929
930
  path: init.path,
930
931
  pathParams: null,
931
932
  query: null,
933
+ queryTransport: null,
932
934
  body: null,
933
935
  responses: {},
934
936
  metadata: init.metadata ?? {},