@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.
- package/CHANGELOG.md +24 -0
- package/README.md +64 -14
- package/dist/client/client.d.ts +0 -2
- package/dist/client/client.d.ts.map +1 -1
- package/dist/client/client.js +28 -25
- package/dist/client/client.js.map +1 -1
- package/dist/contracts/contract-builder.d.ts +7 -2
- package/dist/contracts/contract-builder.d.ts.map +1 -1
- package/dist/contracts/contract-builder.js +20 -2
- package/dist/contracts/contract-builder.js.map +1 -1
- package/dist/contracts/contract-group.d.ts.map +1 -1
- package/dist/contracts/contract-group.js +1 -0
- package/dist/contracts/contract-group.js.map +1 -1
- package/dist/contracts/contract-like.d.ts +2 -0
- package/dist/contracts/contract-like.d.ts.map +1 -1
- package/dist/contracts/contract-like.js +27 -1
- package/dist/contracts/contract-like.js.map +1 -1
- package/dist/contracts/index.d.ts +4 -0
- package/dist/contracts/index.d.ts.map +1 -1
- package/dist/contracts/index.js +4 -0
- package/dist/contracts/index.js.map +1 -1
- package/dist/contracts/query-transport.d.ts +126 -0
- package/dist/contracts/query-transport.d.ts.map +1 -0
- package/dist/contracts/query-transport.js +406 -0
- package/dist/contracts/query-transport.js.map +1 -0
- package/dist/contracts/schema-shape.d.ts +11 -0
- package/dist/contracts/schema-shape.d.ts.map +1 -1
- package/dist/contracts/schema-shape.js +13 -0
- package/dist/contracts/schema-shape.js.map +1 -1
- package/dist/contracts/types.d.ts +5 -0
- package/dist/contracts/types.d.ts.map +1 -1
- package/dist/contracts/types.js.map +1 -1
- package/dist/openapi/index.d.ts +8 -0
- package/dist/openapi/index.d.ts.map +1 -1
- package/dist/openapi/index.js +79 -5
- package/dist/openapi/index.js.map +1 -1
- package/dist/outbox/index.d.ts +8 -5
- package/dist/outbox/index.d.ts.map +1 -1
- package/dist/outbox/index.js +17 -3
- package/dist/outbox/index.js.map +1 -1
- package/dist/ports/cache.d.ts +9 -1
- package/dist/ports/cache.d.ts.map +1 -1
- package/dist/ports/cache.js +20 -5
- package/dist/ports/cache.js.map +1 -1
- package/dist/ports/index.d.ts +1 -1
- package/dist/ports/index.d.ts.map +1 -1
- package/dist/ports/index.js +1 -1
- package/dist/ports/index.js.map +1 -1
- package/dist/query-codec.d.ts +24 -0
- package/dist/query-codec.d.ts.map +1 -1
- package/dist/query-codec.js +207 -72
- package/dist/query-codec.js.map +1 -1
- package/dist/server/hooks/rate-limit.d.ts +16 -8
- package/dist/server/hooks/rate-limit.d.ts.map +1 -1
- package/dist/server/hooks/rate-limit.js +31 -17
- package/dist/server/hooks/rate-limit.js.map +1 -1
- package/dist/server/request-executor.d.ts.map +1 -1
- package/dist/server/request-executor.js +9 -9
- package/dist/server/request-executor.js.map +1 -1
- package/dist/server/request-preparation.d.ts.map +1 -1
- package/dist/server/request-preparation.js +11 -9
- package/dist/server/request-preparation.js.map +1 -1
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +2 -0
- package/dist/server/server.js.map +1 -1
- package/dist/server/use-case-route.d.ts +8 -5
- package/dist/server/use-case-route.d.ts.map +1 -1
- package/dist/server/use-case-route.js +44 -17
- package/dist/server/use-case-route.js.map +1 -1
- package/package.json +3 -3
- package/skills/app-architecture/SKILL.md +27 -1
- package/src/client/client.ts +29 -28
- package/src/contracts/contract-builder.ts +32 -2
- package/src/contracts/contract-group.ts +1 -0
- package/src/contracts/contract-like.ts +40 -1
- package/src/contracts/index.ts +23 -0
- package/src/contracts/query-transport.ts +697 -0
- package/src/contracts/schema-shape.ts +24 -0
- package/src/contracts/types.ts +5 -0
- package/src/openapi/index.ts +126 -2
- package/src/outbox/index.ts +26 -5
- package/src/ports/cache.ts +29 -7
- package/src/ports/index.ts +1 -1
- package/src/server/hooks/rate-limit.ts +50 -24
- package/src/server/request-executor.ts +17 -9
- package/src/server/request-preparation.ts +22 -9
- package/src/server/server.ts +2 -0
- package/src/server/use-case-route.ts +62 -23
- 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
|
+
}
|
package/src/contracts/types.ts
CHANGED
|
@@ -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
|
*/
|
package/src/openapi/index.ts
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
*/
|
package/src/outbox/index.ts
CHANGED
|
@@ -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
|
-
*
|
|
1481
|
-
*
|
|
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
|
*/
|
package/src/ports/cache.ts
CHANGED
|
@@ -3,11 +3,35 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export interface CacheSetOptions {
|
|
5
5
|
/**
|
|
6
|
-
* Time-to-live in seconds.
|
|
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
|
-
|
|
81
|
+
const ttlSeconds = resolveCacheTtlSeconds(options);
|
|
82
|
+
if (ttlSeconds === undefined) {
|
|
58
83
|
return null;
|
|
59
84
|
}
|
|
60
85
|
|
|
61
|
-
|
|
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;
|
package/src/ports/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
42
|
-
* that declare `ip` scopes without a trusted
|
|
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
|
|
135
|
+
`or "none" to explicitly accept one shared unknown-client bucket per contract.`,
|
|
128
136
|
);
|
|
129
137
|
}
|
|
130
138
|
|
|
131
|
-
function
|
|
132
|
-
|
|
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"
|
|
150
|
-
|
|
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.
|
|
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
|
|
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
|
|
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(
|
|
313
|
-
|
|
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(
|
|
339
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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,
|
package/src/server/server.ts
CHANGED
|
@@ -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 ?? {},
|