@cosmicdrift/kumiko-framework 0.372.0 → 0.373.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/agents/examples/rate-limiting.md +65 -1
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +47 -16
- package/dist/engine/feature-ast/extractors/handlers.js +1 -1
- package/dist/engine/feature-ast/extractors/hooks.js +8 -8
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +150 -6
- package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/types/index.js +1 -1
- package/dist/files/bind-pending-file-refs.d.ts +31 -1
- package/dist/files/bind-pending-file-refs.js +80 -6
- package/dist/files/index.d.ts +2 -0
- package/dist/files/index.js +1 -0
- package/dist/files/load-save-file-refs.js +3 -1
- package/dist/pipeline/dispatch-shared.js +55 -35
- package/dist/rate-limit/index.d.ts +1 -1
- package/dist/rate-limit/resolver.d.ts +1 -1
- package/dist/rate-limit/resolver.js +104 -2
- package/dist/rate-limit/unlimited-resolver.js +1 -0
- package/package.json +5 -5
- package/src/changes.json +13 -0
|
@@ -13,8 +13,29 @@ End-to-end rate limiting: L1 global-IP + L2 auth + L3 handler opt-in.
|
|
|
13
13
|
## What it shows
|
|
14
14
|
|
|
15
15
|
- Handler `rateLimit: { per, limit, windowSeconds }`
|
|
16
|
+
- Several buckets on one handler: `rateLimit: [{ per: "user", ... }, { per: "tenant+handler", ... }]`
|
|
16
17
|
- How L1/L2 from `buildServer` stack with L3
|
|
17
18
|
|
|
19
|
+
## Combined buckets
|
|
20
|
+
|
|
21
|
+
Pass a list to enforce several budgets on one handler, for example a per-caller and a per-tenant cap:
|
|
22
|
+
|
|
23
|
+
```ts illustration
|
|
24
|
+
r.writeHandler({
|
|
25
|
+
// ...
|
|
26
|
+
rateLimit: [
|
|
27
|
+
{ per: "user", limit: 2, windowSeconds: 3600 },
|
|
28
|
+
{ per: "tenant+handler", limit: 3, windowSeconds: 3600 },
|
|
29
|
+
],
|
|
30
|
+
});
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Each bucket applies on its own. The check is all-or-nothing: if any bucket is exhausted the call
|
|
34
|
+
answers 429 `rate_limited` for that bucket (the one with the longest wait when several are
|
|
35
|
+
exhausted) and no bucket is charged. Each `per` may appear once; an empty list fails at boot.
|
|
36
|
+
An anonymous handler needs every bucket to be ip-keyed, and `{ disabled: true, reason }`
|
|
37
|
+
stays a single declaration.
|
|
38
|
+
|
|
18
39
|
## Per-payload limits
|
|
19
40
|
|
|
20
41
|
A public write handler that mails an address typed by the caller is also limited per
|
|
@@ -67,7 +88,8 @@ bun test
|
|
|
67
88
|
// in each layer's test.
|
|
68
89
|
|
|
69
90
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
70
|
-
import {
|
|
91
|
+
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
92
|
+
import { createTestUser, TestUsers } from "@cosmicdrift/kumiko-framework/seeding";
|
|
71
93
|
import { setupTestStack, type TestStack } from "@cosmicdrift/kumiko-testing/stack";
|
|
72
94
|
import { createRateLimitShowcaseFeature } from "../feature";
|
|
73
95
|
|
|
@@ -130,6 +152,35 @@ describe("L3 — handler opt-in (per-user budget on expensive-search)", () => {
|
|
|
130
152
|
});
|
|
131
153
|
});
|
|
132
154
|
|
|
155
|
+
describe("L3 — combined buckets (per-user and per-tenant budget on submit-report)", () => {
|
|
156
|
+
const submitReport = "rl-showcase:write:submit-report";
|
|
157
|
+
|
|
158
|
+
async function rejectedBucket(user: SessionUser): Promise<string | undefined> {
|
|
159
|
+
const res = await stack.http.write(submitReport, {}, user);
|
|
160
|
+
expect(res.status).toBe(429);
|
|
161
|
+
const body = (await res.json()) as { error: { details?: { bucket?: string } } };
|
|
162
|
+
return body.error.details?.bucket;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
test("the user bucket rejects first; the tenant bucket only counts accepted requests", async () => {
|
|
166
|
+
await stack.http.writeOk(submitReport, {}, admin);
|
|
167
|
+
await stack.http.writeOk(submitReport, {}, admin);
|
|
168
|
+
expect(await rejectedBucket(admin)).toBe("user");
|
|
169
|
+
|
|
170
|
+
// The rejected third call did not touch the tenant bucket: one of its 3 tokens is left.
|
|
171
|
+
await stack.http.writeOk(submitReport, {}, TestUsers.user);
|
|
172
|
+
expect(await rejectedBucket(TestUsers.user)).toBe("tenant+handler");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("a caller without Admin or User role is rejected with access_denied before any bucket is charged", async () => {
|
|
176
|
+
const outsider = createTestUser({ id: 9, tenantId: admin.tenantId, roles: ["Guest"] });
|
|
177
|
+
const res = await stack.http.write(submitReport, {}, outsider);
|
|
178
|
+
expect(res.status).toBe(403);
|
|
179
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
180
|
+
expect(body.error.code).toBe("access_denied");
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
133
184
|
describe("L1 — global IP middleware (covers every /api/* request)", () => {
|
|
134
185
|
test("21st request from same IP returns 429 with l1:ip bucket", async () => {
|
|
135
186
|
// L1 sits ahead of auth + every /api/* route. Target an authless,
|
|
@@ -205,6 +256,19 @@ export function createRateLimitShowcaseFeature(): FeatureDefinition {
|
|
|
205
256
|
access: { roles: ["Admin", "User"] },
|
|
206
257
|
rateLimit: { per: "user", limit: 3, windowSeconds: 60 },
|
|
207
258
|
});
|
|
259
|
+
|
|
260
|
+
// Combined budget: each caller gets 2 submits per hour and the tenant 3 per hour in
|
|
261
|
+
// total. Both buckets are checked together; a request one bucket rejects charges neither.
|
|
262
|
+
r.writeHandler({
|
|
263
|
+
name: "submit-report",
|
|
264
|
+
schema: z.object({}),
|
|
265
|
+
handler: async () => ({ isSuccess: true as const, data: {} }),
|
|
266
|
+
access: { roles: ["Admin", "User"] },
|
|
267
|
+
rateLimit: [
|
|
268
|
+
{ per: "user", limit: 2, windowSeconds: 3600 },
|
|
269
|
+
{ per: "tenant+handler", limit: 3, windowSeconds: 3600 },
|
|
270
|
+
],
|
|
271
|
+
});
|
|
208
272
|
});
|
|
209
273
|
}
|
|
210
274
|
```
|
|
@@ -10,6 +10,7 @@ export declare function validateAdditionalRateLimits(featureName: string, handle
|
|
|
10
10
|
export declare function validatePerCommandTransaction(featureName: string, handlerName: string, handler: FeatureDefinition["writeHandlers"][string]): void;
|
|
11
11
|
export declare function validateTenantlessAnonymous(featureName: string, handlerName: string, handler: FeatureDefinition["writeHandlers"][string]): void;
|
|
12
12
|
export declare function validateRateLimitDisabledReason(featureName: string, kind: "write" | "query" | "stream", handlerName: string, rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"]): void;
|
|
13
|
+
export declare function validateRateLimitBucketList(featureName: string, kind: "write" | "query" | "stream", handlerName: string, rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"]): void;
|
|
13
14
|
export declare function validateNoAllRoleInHandlerAccess(featureName: string, kind: "write" | "query" | "stream", handlerName: string, access: NonNullable<FeatureDefinition["writeHandlers"][string]["access"]>): void;
|
|
14
15
|
export declare function validateAnonymousRateLimit(featureName: string, kind: "write" | "query" | "stream", handlerName: string, access: NonNullable<FeatureDefinition["writeHandlers"][string]["access"]>, rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"]): void;
|
|
15
16
|
export declare function validateMultiStreamProjections(feature: FeatureDefinition): void;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SELECT_OPTION_TONES } from "@cosmicdrift/kumiko-types/fields";
|
|
2
|
-
import { isRateLimitDisabled } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
|
+
import { declaredRateLimitOptions, isRateLimitDisabled } from "@cosmicdrift/kumiko-types/handlers";
|
|
3
3
|
import { ZodObject } from "zod";
|
|
4
4
|
import { VARIANT_NAME_PATTERN } from "../../derivatives/variant-key.js";
|
|
5
5
|
import { access as accessPresets } from "../config-helpers.js";
|
|
@@ -133,6 +133,7 @@ export function validateHandlerAccess(feature) {
|
|
|
133
133
|
`Set { roles: [...] } for role-based access, or { openToAll: { reason: "..." } } for any authenticated user.`);
|
|
134
134
|
}
|
|
135
135
|
validateNoAllRoleInHandlerAccess(feature.name, kind, name, handler.access);
|
|
136
|
+
validateRateLimitBucketList(feature.name, kind, name, handler.rateLimit);
|
|
136
137
|
validateAnonymousRateLimit(feature.name, kind, name, handler.access, handler.rateLimit);
|
|
137
138
|
validateRateLimitDisabledReason(feature.name, kind, name, handler.rateLimit);
|
|
138
139
|
}
|
|
@@ -158,8 +159,17 @@ export function validateAdditionalRateLimits(featureName, handlerName, handler)
|
|
|
158
159
|
}
|
|
159
160
|
validateAnonymousHasIpBucket(where, handler);
|
|
160
161
|
const shape = handler.schema instanceof ZodObject ? handler.schema.shape : undefined;
|
|
162
|
+
const seenFields = new Set();
|
|
161
163
|
for (const entry of additionalRateLimits) {
|
|
162
164
|
validatePayloadBucket(where, shape, entry);
|
|
165
|
+
const field = entry.per.payloadField;
|
|
166
|
+
// Two entries on one field hash the same value into the same Redis key, so enforceAll
|
|
167
|
+
// would deduct only one of the costs.
|
|
168
|
+
if (seenFields.has(field)) {
|
|
169
|
+
throw new Error(`${where} with payloadField "${field}" more than once — both entries would share one ` +
|
|
170
|
+
`Redis bucket per value. Keep one entry per payloadField.`);
|
|
171
|
+
}
|
|
172
|
+
seenFields.add(field);
|
|
163
173
|
}
|
|
164
174
|
}
|
|
165
175
|
function validateAnonymousHasIpBucket(where, handler) {
|
|
@@ -168,9 +178,7 @@ function validateAnonymousHasIpBucket(where, handler) {
|
|
|
168
178
|
// skip: only anonymous callers need an ip bucket
|
|
169
179
|
if (!allowsAnonymous)
|
|
170
180
|
return;
|
|
171
|
-
const hasIpBucket = rateLimit
|
|
172
|
-
!isRateLimitDisabled(rateLimit) &&
|
|
173
|
-
IP_BUCKETED_RATE_LIMIT_PER.has(rateLimit.per);
|
|
181
|
+
const hasIpBucket = declaredRateLimitOptions(rateLimit).some((option) => IP_BUCKETED_RATE_LIMIT_PER.has(option.per));
|
|
174
182
|
if (!hasIpBucket) {
|
|
175
183
|
throw new Error(`${where} and allows anonymous callers, but has no ip-keyed rateLimit ` +
|
|
176
184
|
`(per: "ip" or "ip+handler", not disabled). Payload buckets only complement the IP bucket. Set rateLimit: { per: "ip", ... } on the handler.`);
|
|
@@ -217,13 +225,16 @@ export function validateTenantlessAnonymous(featureName, handlerName, handler) {
|
|
|
217
225
|
throw new Error(`${where} but its access is not exactly { roles: ["anonymous"] } — a tenantless request ` +
|
|
218
226
|
`carries no principal beyond the anonymous visitor. Set access to { roles: ["anonymous"] } or remove tenantlessAnonymous.`);
|
|
219
227
|
}
|
|
220
|
-
|
|
228
|
+
const options = declaredRateLimitOptions(handler.rateLimit);
|
|
229
|
+
if (options.length === 0) {
|
|
221
230
|
throw new Error(`${where} without a rateLimit — a handler reachable on hosts that resolve no tenant ` +
|
|
222
231
|
`needs a real upper bound (rateLimit: { per: "ip", ... }); { disabled: true } does not count.`);
|
|
223
232
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
233
|
+
for (const { per } of options) {
|
|
234
|
+
if (IP_SCOPED_RATE_LIMIT_PER.includes(per))
|
|
235
|
+
continue;
|
|
236
|
+
throw new Error(`${where} with rateLimit.per "${per}" — every anonymous visitor shares the same ` +
|
|
237
|
+
`tenant/user bucket, so one caller could exhaust it for all. Use ${IP_SCOPED_RATE_LIMIT_PER.map((allowed) => `"${allowed}"`).join(" or ")}.`);
|
|
227
238
|
}
|
|
228
239
|
}
|
|
229
240
|
export function validateRateLimitDisabledReason(featureName, kind, handlerName, rateLimit) {
|
|
@@ -236,6 +247,23 @@ export function validateRateLimitDisabledReason(featureName, kind, handlerName,
|
|
|
236
247
|
`be rate-limited.`);
|
|
237
248
|
}
|
|
238
249
|
}
|
|
250
|
+
export function validateRateLimitBucketList(featureName, kind, handlerName, rateLimit) {
|
|
251
|
+
// skip: a single option or a disabled declaration has no bucket list to check
|
|
252
|
+
if (!Array.isArray(rateLimit))
|
|
253
|
+
return;
|
|
254
|
+
const where = `${kind} handler "${featureName}:${kind}:${handlerName}"`;
|
|
255
|
+
if (rateLimit.length === 0) {
|
|
256
|
+
throw new Error(`${where} declares rateLimit as an empty array — declare at least one bucket or drop the option.`);
|
|
257
|
+
}
|
|
258
|
+
const seen = new Set();
|
|
259
|
+
for (const { per } of rateLimit) {
|
|
260
|
+
if (seen.has(per)) {
|
|
261
|
+
throw new Error(`${where} declares rateLimit with per "${per}" more than once — both entries would share one ` +
|
|
262
|
+
`Redis bucket with conflicting limits. Keep one entry per "per".`);
|
|
263
|
+
}
|
|
264
|
+
seen.add(per);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
239
267
|
// No session ever carries the role "all" — `roles: ["all"]` is unreachable dead config, not a wildcard.
|
|
240
268
|
export function validateNoAllRoleInHandlerAccess(featureName, kind, handlerName, access) {
|
|
241
269
|
// skip: openToAll has no roles list to check
|
|
@@ -260,19 +288,22 @@ export function validateAnonymousRateLimit(featureName, kind, handlerName, acces
|
|
|
260
288
|
// skip: disabled declarations carry no `.per` to bucket on
|
|
261
289
|
if (isRateLimitDisabled(rateLimit))
|
|
262
290
|
return;
|
|
263
|
-
|
|
291
|
+
const options = declaredRateLimitOptions(rateLimit);
|
|
292
|
+
if (options.length === 0) {
|
|
264
293
|
throw new Error(`${kind} handler "${featureName}:${kind}:${handlerName}" allows anonymous callers but declares no ` +
|
|
265
294
|
`rateLimit — an anonymous, internet-facing endpoint needs an upper bound. Set rateLimit: ` +
|
|
266
295
|
`{ per: "ip", limit: ..., windowSeconds: ... }, or the documented exception ` +
|
|
267
296
|
`rateLimit: { disabled: true, reason: "..." }.`);
|
|
268
297
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
298
|
+
for (const { per } of options) {
|
|
299
|
+
// skip: this bucket is keyed on something safe (ip / tenant)
|
|
300
|
+
if (!USER_BUCKETED_RATE_LIMIT_PER.has(per))
|
|
301
|
+
continue;
|
|
302
|
+
throw new Error(`${kind} handler "${featureName}:${kind}:${handlerName}" allows anonymous callers but uses ` +
|
|
303
|
+
`rateLimit.per="${per}" — every anonymous request shares user.id="anonymous", ` +
|
|
304
|
+
`so this bucket would be a single global tap any caller could drain. ` +
|
|
305
|
+
`Use rateLimit.per="ip" or "ip+handler" for anonymous endpoints.`);
|
|
306
|
+
}
|
|
276
307
|
}
|
|
277
308
|
// --- MultiStreamProjection delivery-invariant ---
|
|
278
309
|
//
|
|
@@ -178,7 +178,7 @@ function readHandlerHeaderFields(obj, methodName, sourceFile) {
|
|
|
178
178
|
const accessResult = readHeaderField(readObjectPropertyInitializer(obj, "access"), readOptionalAccessRule, methodName, sourceFile, "access must be a recognized AccessRule ({ roles: [...] } or { openToAll: { reason } }), or a reference to one");
|
|
179
179
|
if (accessResult.kind === "error")
|
|
180
180
|
return accessResult;
|
|
181
|
-
const rateLimitResult = readHeaderField(readObjectPropertyInitializer(obj, "rateLimit"), readOptionalRateLimit, methodName, sourceFile, "rateLimit must be { per, limit, windowSeconds } or { disabled: true, reason }, or a reference to one");
|
|
181
|
+
const rateLimitResult = readHeaderField(readObjectPropertyInitializer(obj, "rateLimit"), readOptionalRateLimit, methodName, sourceFile, "rateLimit must be { per, limit, windowSeconds }, a non-empty list of those, or { disabled: true, reason }, or a reference to one");
|
|
182
182
|
if (rateLimitResult.kind === "error")
|
|
183
183
|
return rateLimitResult;
|
|
184
184
|
const additionalRateLimitsResult = readHeaderField(readObjectPropertyInitializer(obj, "additionalRateLimits"), readOptionalAdditionalRateLimits, methodName, sourceFile, "additionalRateLimits must be an array of { per: { payloadField }, limit, windowSeconds }, or a reference to one");
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { isEscapeHatchGrant } from "@cosmicdrift/kumiko-types/handlers";
|
|
1
|
+
import { isEscapeHatchGrant, isRateLimitOption } from "@cosmicdrift/kumiko-types/handlers";
|
|
2
2
|
import { SyntaxKind } from "ts-morph";
|
|
3
3
|
import { sourceLocationFromNode } from "../source-location.js";
|
|
4
4
|
import { containsRawRefSentinel, fail, findFunctionLiteral, isPlainObject, ok, readDataLiteralNode, readNameLiteral, readNameOrRef, readNameOrRefOrList, readObjectPropertyInitializer, } from "./shared.js";
|
|
@@ -52,6 +52,12 @@ export function readOptionalAccessRule(value) {
|
|
|
52
52
|
return undefined;
|
|
53
53
|
}
|
|
54
54
|
export function readOptionalRateLimit(value) {
|
|
55
|
+
if (Array.isArray(value)) {
|
|
56
|
+
if (!value.every(isRateLimitOption))
|
|
57
|
+
return undefined;
|
|
58
|
+
const [first, ...rest] = value;
|
|
59
|
+
return first === undefined ? undefined : [first, ...rest];
|
|
60
|
+
}
|
|
55
61
|
if (!isPlainObject(value))
|
|
56
62
|
return undefined;
|
|
57
63
|
if (value["disabled"] === true) {
|
|
@@ -63,13 +69,7 @@ export function readOptionalRateLimit(value) {
|
|
|
63
69
|
return undefined;
|
|
64
70
|
return { disabled: true, reason: value["reason"] };
|
|
65
71
|
}
|
|
66
|
-
|
|
67
|
-
return undefined;
|
|
68
|
-
if (typeof value["limit"] !== "number")
|
|
69
|
-
return undefined;
|
|
70
|
-
if (typeof value["windowSeconds"] !== "number")
|
|
71
|
-
return undefined;
|
|
72
|
-
return value;
|
|
72
|
+
return isRateLimitOption(value) ? value : undefined;
|
|
73
73
|
}
|
|
74
74
|
function isPayloadRateLimitOption(value) {
|
|
75
75
|
if (!isPlainObject(value))
|
|
@@ -189,7 +189,31 @@ export declare const PATTERN_SCHEMAS_BY_KIND: {
|
|
|
189
189
|
limit: z.ZodNumber;
|
|
190
190
|
windowSeconds: z.ZodNumber;
|
|
191
191
|
cost: z.ZodOptional<z.ZodNumber>;
|
|
192
|
-
}, z.core.$strict>, z.ZodObject<{
|
|
192
|
+
}, z.core.$strict>, z.ZodTuple<[z.ZodObject<{
|
|
193
|
+
per: z.ZodEnum<{
|
|
194
|
+
ip: "ip";
|
|
195
|
+
"ip+handler": "ip+handler";
|
|
196
|
+
tenant: "tenant";
|
|
197
|
+
"tenant+handler": "tenant+handler";
|
|
198
|
+
user: "user";
|
|
199
|
+
"user+handler": "user+handler";
|
|
200
|
+
}>;
|
|
201
|
+
limit: z.ZodNumber;
|
|
202
|
+
windowSeconds: z.ZodNumber;
|
|
203
|
+
cost: z.ZodOptional<z.ZodNumber>;
|
|
204
|
+
}, z.core.$strict>], z.ZodObject<{
|
|
205
|
+
per: z.ZodEnum<{
|
|
206
|
+
ip: "ip";
|
|
207
|
+
"ip+handler": "ip+handler";
|
|
208
|
+
tenant: "tenant";
|
|
209
|
+
"tenant+handler": "tenant+handler";
|
|
210
|
+
user: "user";
|
|
211
|
+
"user+handler": "user+handler";
|
|
212
|
+
}>;
|
|
213
|
+
limit: z.ZodNumber;
|
|
214
|
+
windowSeconds: z.ZodNumber;
|
|
215
|
+
cost: z.ZodOptional<z.ZodNumber>;
|
|
216
|
+
}, z.core.$strict>>, z.ZodObject<{
|
|
193
217
|
disabled: z.ZodLiteral<true>;
|
|
194
218
|
reason: z.ZodString;
|
|
195
219
|
}, z.core.$strict>]>, z.ZodObject<{
|
|
@@ -257,7 +281,31 @@ export declare const PATTERN_SCHEMAS_BY_KIND: {
|
|
|
257
281
|
limit: z.ZodNumber;
|
|
258
282
|
windowSeconds: z.ZodNumber;
|
|
259
283
|
cost: z.ZodOptional<z.ZodNumber>;
|
|
260
|
-
}, z.core.$strict>, z.ZodObject<{
|
|
284
|
+
}, z.core.$strict>, z.ZodTuple<[z.ZodObject<{
|
|
285
|
+
per: z.ZodEnum<{
|
|
286
|
+
ip: "ip";
|
|
287
|
+
"ip+handler": "ip+handler";
|
|
288
|
+
tenant: "tenant";
|
|
289
|
+
"tenant+handler": "tenant+handler";
|
|
290
|
+
user: "user";
|
|
291
|
+
"user+handler": "user+handler";
|
|
292
|
+
}>;
|
|
293
|
+
limit: z.ZodNumber;
|
|
294
|
+
windowSeconds: z.ZodNumber;
|
|
295
|
+
cost: z.ZodOptional<z.ZodNumber>;
|
|
296
|
+
}, z.core.$strict>], z.ZodObject<{
|
|
297
|
+
per: z.ZodEnum<{
|
|
298
|
+
ip: "ip";
|
|
299
|
+
"ip+handler": "ip+handler";
|
|
300
|
+
tenant: "tenant";
|
|
301
|
+
"tenant+handler": "tenant+handler";
|
|
302
|
+
user: "user";
|
|
303
|
+
"user+handler": "user+handler";
|
|
304
|
+
}>;
|
|
305
|
+
limit: z.ZodNumber;
|
|
306
|
+
windowSeconds: z.ZodNumber;
|
|
307
|
+
cost: z.ZodOptional<z.ZodNumber>;
|
|
308
|
+
}, z.core.$strict>>, z.ZodObject<{
|
|
261
309
|
disabled: z.ZodLiteral<true>;
|
|
262
310
|
reason: z.ZodString;
|
|
263
311
|
}, z.core.$strict>]>, z.ZodObject<{
|
|
@@ -303,7 +351,31 @@ export declare const PATTERN_SCHEMAS_BY_KIND: {
|
|
|
303
351
|
limit: z.ZodNumber;
|
|
304
352
|
windowSeconds: z.ZodNumber;
|
|
305
353
|
cost: z.ZodOptional<z.ZodNumber>;
|
|
306
|
-
}, z.core.$strict>, z.ZodObject<{
|
|
354
|
+
}, z.core.$strict>, z.ZodTuple<[z.ZodObject<{
|
|
355
|
+
per: z.ZodEnum<{
|
|
356
|
+
ip: "ip";
|
|
357
|
+
"ip+handler": "ip+handler";
|
|
358
|
+
tenant: "tenant";
|
|
359
|
+
"tenant+handler": "tenant+handler";
|
|
360
|
+
user: "user";
|
|
361
|
+
"user+handler": "user+handler";
|
|
362
|
+
}>;
|
|
363
|
+
limit: z.ZodNumber;
|
|
364
|
+
windowSeconds: z.ZodNumber;
|
|
365
|
+
cost: z.ZodOptional<z.ZodNumber>;
|
|
366
|
+
}, z.core.$strict>], z.ZodObject<{
|
|
367
|
+
per: z.ZodEnum<{
|
|
368
|
+
ip: "ip";
|
|
369
|
+
"ip+handler": "ip+handler";
|
|
370
|
+
tenant: "tenant";
|
|
371
|
+
"tenant+handler": "tenant+handler";
|
|
372
|
+
user: "user";
|
|
373
|
+
"user+handler": "user+handler";
|
|
374
|
+
}>;
|
|
375
|
+
limit: z.ZodNumber;
|
|
376
|
+
windowSeconds: z.ZodNumber;
|
|
377
|
+
cost: z.ZodOptional<z.ZodNumber>;
|
|
378
|
+
}, z.core.$strict>>, z.ZodObject<{
|
|
307
379
|
disabled: z.ZodLiteral<true>;
|
|
308
380
|
reason: z.ZodString;
|
|
309
381
|
}, z.core.$strict>]>, z.ZodObject<{
|
|
@@ -712,7 +784,31 @@ export declare const patternSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
712
784
|
limit: z.ZodNumber;
|
|
713
785
|
windowSeconds: z.ZodNumber;
|
|
714
786
|
cost: z.ZodOptional<z.ZodNumber>;
|
|
715
|
-
}, z.core.$strict>, z.ZodObject<{
|
|
787
|
+
}, z.core.$strict>, z.ZodTuple<[z.ZodObject<{
|
|
788
|
+
per: z.ZodEnum<{
|
|
789
|
+
ip: "ip";
|
|
790
|
+
"ip+handler": "ip+handler";
|
|
791
|
+
tenant: "tenant";
|
|
792
|
+
"tenant+handler": "tenant+handler";
|
|
793
|
+
user: "user";
|
|
794
|
+
"user+handler": "user+handler";
|
|
795
|
+
}>;
|
|
796
|
+
limit: z.ZodNumber;
|
|
797
|
+
windowSeconds: z.ZodNumber;
|
|
798
|
+
cost: z.ZodOptional<z.ZodNumber>;
|
|
799
|
+
}, z.core.$strict>], z.ZodObject<{
|
|
800
|
+
per: z.ZodEnum<{
|
|
801
|
+
ip: "ip";
|
|
802
|
+
"ip+handler": "ip+handler";
|
|
803
|
+
tenant: "tenant";
|
|
804
|
+
"tenant+handler": "tenant+handler";
|
|
805
|
+
user: "user";
|
|
806
|
+
"user+handler": "user+handler";
|
|
807
|
+
}>;
|
|
808
|
+
limit: z.ZodNumber;
|
|
809
|
+
windowSeconds: z.ZodNumber;
|
|
810
|
+
cost: z.ZodOptional<z.ZodNumber>;
|
|
811
|
+
}, z.core.$strict>>, z.ZodObject<{
|
|
716
812
|
disabled: z.ZodLiteral<true>;
|
|
717
813
|
reason: z.ZodString;
|
|
718
814
|
}, z.core.$strict>]>, z.ZodObject<{
|
|
@@ -779,7 +875,31 @@ export declare const patternSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
779
875
|
limit: z.ZodNumber;
|
|
780
876
|
windowSeconds: z.ZodNumber;
|
|
781
877
|
cost: z.ZodOptional<z.ZodNumber>;
|
|
782
|
-
}, z.core.$strict>, z.ZodObject<{
|
|
878
|
+
}, z.core.$strict>, z.ZodTuple<[z.ZodObject<{
|
|
879
|
+
per: z.ZodEnum<{
|
|
880
|
+
ip: "ip";
|
|
881
|
+
"ip+handler": "ip+handler";
|
|
882
|
+
tenant: "tenant";
|
|
883
|
+
"tenant+handler": "tenant+handler";
|
|
884
|
+
user: "user";
|
|
885
|
+
"user+handler": "user+handler";
|
|
886
|
+
}>;
|
|
887
|
+
limit: z.ZodNumber;
|
|
888
|
+
windowSeconds: z.ZodNumber;
|
|
889
|
+
cost: z.ZodOptional<z.ZodNumber>;
|
|
890
|
+
}, z.core.$strict>], z.ZodObject<{
|
|
891
|
+
per: z.ZodEnum<{
|
|
892
|
+
ip: "ip";
|
|
893
|
+
"ip+handler": "ip+handler";
|
|
894
|
+
tenant: "tenant";
|
|
895
|
+
"tenant+handler": "tenant+handler";
|
|
896
|
+
user: "user";
|
|
897
|
+
"user+handler": "user+handler";
|
|
898
|
+
}>;
|
|
899
|
+
limit: z.ZodNumber;
|
|
900
|
+
windowSeconds: z.ZodNumber;
|
|
901
|
+
cost: z.ZodOptional<z.ZodNumber>;
|
|
902
|
+
}, z.core.$strict>>, z.ZodObject<{
|
|
783
903
|
disabled: z.ZodLiteral<true>;
|
|
784
904
|
reason: z.ZodString;
|
|
785
905
|
}, z.core.$strict>]>, z.ZodObject<{
|
|
@@ -824,7 +944,31 @@ export declare const patternSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
824
944
|
limit: z.ZodNumber;
|
|
825
945
|
windowSeconds: z.ZodNumber;
|
|
826
946
|
cost: z.ZodOptional<z.ZodNumber>;
|
|
827
|
-
}, z.core.$strict>, z.ZodObject<{
|
|
947
|
+
}, z.core.$strict>, z.ZodTuple<[z.ZodObject<{
|
|
948
|
+
per: z.ZodEnum<{
|
|
949
|
+
ip: "ip";
|
|
950
|
+
"ip+handler": "ip+handler";
|
|
951
|
+
tenant: "tenant";
|
|
952
|
+
"tenant+handler": "tenant+handler";
|
|
953
|
+
user: "user";
|
|
954
|
+
"user+handler": "user+handler";
|
|
955
|
+
}>;
|
|
956
|
+
limit: z.ZodNumber;
|
|
957
|
+
windowSeconds: z.ZodNumber;
|
|
958
|
+
cost: z.ZodOptional<z.ZodNumber>;
|
|
959
|
+
}, z.core.$strict>], z.ZodObject<{
|
|
960
|
+
per: z.ZodEnum<{
|
|
961
|
+
ip: "ip";
|
|
962
|
+
"ip+handler": "ip+handler";
|
|
963
|
+
tenant: "tenant";
|
|
964
|
+
"tenant+handler": "tenant+handler";
|
|
965
|
+
user: "user";
|
|
966
|
+
"user+handler": "user+handler";
|
|
967
|
+
}>;
|
|
968
|
+
limit: z.ZodNumber;
|
|
969
|
+
windowSeconds: z.ZodNumber;
|
|
970
|
+
cost: z.ZodOptional<z.ZodNumber>;
|
|
971
|
+
}, z.core.$strict>>, z.ZodObject<{
|
|
828
972
|
disabled: z.ZodLiteral<true>;
|
|
829
973
|
reason: z.ZodString;
|
|
830
974
|
}, z.core.$strict>]>, z.ZodObject<{
|
|
@@ -157,6 +157,7 @@ const rateLimitOptionSchema = z
|
|
|
157
157
|
cost: z.number().optional(),
|
|
158
158
|
})
|
|
159
159
|
.strict();
|
|
160
|
+
const rateLimitOptionListSchema = z.tuple([rateLimitOptionSchema], rateLimitOptionSchema);
|
|
160
161
|
const additionalRateLimitsSchema = z.array(z
|
|
161
162
|
.object({
|
|
162
163
|
per: z.object({ payloadField: z.string() }).strict(),
|
|
@@ -247,7 +248,7 @@ const WRITE_HANDLER_PATTERN_HEADER_SHAPE = {
|
|
|
247
248
|
...WRITE_HANDLER_HEADER_SHAPE,
|
|
248
249
|
access: orRawRef(accessRuleSchema),
|
|
249
250
|
agent: orRawRef(agentHandlerHintsSchema),
|
|
250
|
-
rateLimit: orRawRef(z.union([rateLimitOptionSchema, rateLimitDisabledSchema])),
|
|
251
|
+
rateLimit: orRawRef(z.union([rateLimitOptionSchema, rateLimitOptionListSchema, rateLimitDisabledSchema])),
|
|
251
252
|
additionalRateLimits: orRawRef(additionalRateLimitsSchema),
|
|
252
253
|
escapeHatch: orRawRef(escapeHatchSchema),
|
|
253
254
|
};
|
|
@@ -255,13 +256,13 @@ const QUERY_HANDLER_PATTERN_HEADER_SHAPE = {
|
|
|
255
256
|
...QUERY_HANDLER_HEADER_SHAPE,
|
|
256
257
|
access: orRawRef(accessRuleSchema),
|
|
257
258
|
agent: orRawRef(agentHandlerHintsSchema),
|
|
258
|
-
rateLimit: orRawRef(z.union([rateLimitOptionSchema, rateLimitDisabledSchema])),
|
|
259
|
+
rateLimit: orRawRef(z.union([rateLimitOptionSchema, rateLimitOptionListSchema, rateLimitDisabledSchema])),
|
|
259
260
|
escapeHatch: orRawRef(escapeHatchSchema),
|
|
260
261
|
};
|
|
261
262
|
const STREAM_HANDLER_PATTERN_HEADER_SHAPE = {
|
|
262
263
|
...STREAM_HANDLER_HEADER_SHAPE,
|
|
263
264
|
access: orRawRef(accessRuleSchema),
|
|
264
|
-
rateLimit: orRawRef(z.union([rateLimitOptionSchema, rateLimitDisabledSchema])),
|
|
265
|
+
rateLimit: orRawRef(z.union([rateLimitOptionSchema, rateLimitOptionListSchema, rateLimitDisabledSchema])),
|
|
265
266
|
escapeHatch: orRawRef(escapeHatchSchema),
|
|
266
267
|
};
|
|
267
268
|
function requireHandlerBody(val, ctx, headerKeys) {
|
|
@@ -7,8 +7,8 @@ export type { ExtensionOptionsArgs, ExtensionOptionsFor, KumikoExtensionOptionsM
|
|
|
7
7
|
export type { BootCheckContext, BootCheckFn, BootOptionName, EntityObjectDefinition, FeatureDefinition, FeatureMetricDef, FeatureMetricType, FeatureRegistrar, HookDefinition, MetricOptions, Registry, SecretKeyDefinition, SecretKeyHandle, SecretNamespaceDefinition, SecretNamespaceHandle, SecretNamespaceOptions, SecretOptions, StackBootRequirement, StoreTableDef, StoreTableEntry, StoreTableOptions, UiHintOption, UiHints, } from "@cosmicdrift/kumiko-types/feature";
|
|
8
8
|
export type { FieldIconKey } from "@cosmicdrift/kumiko-types/field-icon";
|
|
9
9
|
export type { AnyFileFieldDef, BigIntFieldDef, BooleanFieldDef, ClientDerivedFieldDef, DateFieldDef, DecimalFieldDef, DefaultCurrency, DeriveContext, DerivedFieldDef, DerivedFieldsMap, DerivedValueType, EmbeddedDerivedCellDef, EmbeddedFieldDef, EmbeddedSubFieldDef, EntityDefinition, EntityEventMigration, EntityIndexDef, FieldAccess, FieldDefinition, FieldsMap, FileFieldDef, FilesFieldDef, Findability, ImageFieldDef, ImagesFieldDef, JsonbFieldDef, LocatedTimestampFieldDef, LongTextFieldDef, LongTextFindability, MoneyCurrencySource, MoneyFieldDef, MultiSelectFieldDef, NumberFieldDef, OptionsQueryPayload, OptionsQueryPayloadValue, ParentRefDef, PersonalAnnotations, PersonalAnnotationsLongText, PersonalAnnotationsNoFind, PersonalSubject, ReferenceFieldDef, ResolvedPiiFlags, RetentionDef, SelectFieldDef, SelectOptionTone, TextFieldDef, TimestampFieldDef, TransitionMap, TzFieldDef, } from "@cosmicdrift/kumiko-types/fields";
|
|
10
|
-
export type { AccessRule, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AggregateStreamHandle, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BeforeTransactionPhase, BeforeTransactionResult, CamelToKebab, ChatSendFailureCode, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, DeclarativeEventMigration, DeliveryErrorCode, DeliveryFailureCode, DeliverySkipReason, DispatchWriteRef, EntityRef, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, EventMigrationDef, EventPiiFields, EventPiiStance, EventUpcastCtx, EventUpcastFn, FetchForWritingArgs, HandlerContext, HandlerRef, JobContext, JobRunnerRef, JobTriggerEvent, MemberReader, NameOrRef, NotifyDeferred, NotifyDelivery, NotifyDeliveryStatus, NotifyDispatched, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, PayloadBucketNormalization, PayloadRateLimitOption, PreparedWriteEvent, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitDeclaration, RateLimitDisabled, RateLimitOption, RateLimitPer, RoleAccessPersonalData, RoleAccessRule, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, TenantWriteTarget, TryAppendEventFn, TryAppendEventResult, UnsafeAppendEventFn, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "@cosmicdrift/kumiko-types/handlers";
|
|
11
|
-
export { DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, HTTP_ERROR_CODE_PATTERN, isDeliveryErrorCode, isOpenToAllGranted, isRateLimitDisabled, isUiAccessGranted, } from "@cosmicdrift/kumiko-types/handlers";
|
|
10
|
+
export type { AccessRule, ActiveMembership, ActiveMembershipRejection, ActiveMembershipResult, AgentExposure, AgentHandlerHints, AgentRisk, AggregateStreamHandle, AppContext, AppendEventArgs, AppendEventFn, AuthClaimsContext, AuthClaimsFn, AuthClaimsHookDef, BeforeTransactionPhase, BeforeTransactionResult, CamelToKebab, ChatSendFailureCode, ClaimKeyDefinition, ClaimKeyHandle, ClaimKeyJsType, ClaimKeyType, DeclarativeEventMigration, DeliveryErrorCode, DeliveryFailureCode, DeliverySkipReason, DispatchWriteRef, EntityRef, EscapeHatchAuditSink, EscapeHatchDeclaration, EscapeHatchKind, EscapeHatchReporter, EscapeHatchTarget, EscapeHatchUseEvent, EventDef, EventMigrationDef, EventPiiFields, EventPiiStance, EventUpcastCtx, EventUpcastFn, FetchForWritingArgs, HandlerContext, HandlerRef, JobContext, JobRunnerRef, JobTriggerEvent, MemberReader, NameOrRef, NotifyDeferred, NotifyDelivery, NotifyDeliveryStatus, NotifyDispatched, NotifyFactory, NotifyFn, NotifyJobDispatcher, NotifyOptions, NotifyPriority, NotifyResult, OpenToAllAccessRule, OpenToAllDeclaration, OpenToAllPersonalData, PayloadBucketNormalization, PayloadRateLimitOption, PreparedWriteEvent, QualifiedEventName, QueryEvent, QueryHandlerDef, QueryHandlerFn, RateLimitDeclaration, RateLimitDisabled, RateLimitOption, RateLimitOptions, RateLimitPer, RoleAccessPersonalData, RoleAccessRule, SessionUser, SessionUserOrigin, StreamHandlerDef, StreamHandlerFn, TenantWriteTarget, TryAppendEventFn, TryAppendEventResult, UnsafeAppendEventFn, WriteEvent, WriteHandlerDef, WriteHandlerFn, WriteResult, } from "@cosmicdrift/kumiko-types/handlers";
|
|
11
|
+
export { DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, declaredRateLimitOptions, HTTP_ERROR_CODE_PATTERN, isDeliveryErrorCode, isOpenToAllGranted, isRateLimitDisabled, isRateLimitOption, isUiAccessGranted, } from "@cosmicdrift/kumiko-types/handlers";
|
|
12
12
|
export type { DeleteContext, EntityHookMap, HookMap, HookOwner, HookPhase, LifecycleHookFn, LifecycleResult, OwnedFn, PhasedHook, PostDeleteBatchHookFn, PostDeleteHookFn, PostQueryHookFn, PostSaveBatchHookFn, PostSaveHookFn, PreDeleteHookFn, PreQueryHookFn, PreSaveHookFn, SaveContext, SearchPayloadContributorFn, ValidationError, ValidationHookFn, } from "@cosmicdrift/kumiko-types/hooks";
|
|
13
13
|
export type { HttpRouteDefinition, HttpRouteHandler, HttpRouteHandlerDeps, HttpRouteMethod, } from "@cosmicdrift/kumiko-types/http-route";
|
|
14
14
|
export type { EntityId, TenantId } from "@cosmicdrift/kumiko-types/identifiers";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Barrel: re-exports all types from @cosmicdrift/kumiko-types, plus the
|
|
2
2
|
// runtime helpers below that stay framework-side.
|
|
3
3
|
export { isAgentVisibleScreen, resolveAgentExposure, } from "@cosmicdrift/kumiko-types/agent-exposure";
|
|
4
|
-
export { DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, HTTP_ERROR_CODE_PATTERN, isDeliveryErrorCode, isOpenToAllGranted, isRateLimitDisabled, isUiAccessGranted, } from "@cosmicdrift/kumiko-types/handlers";
|
|
4
|
+
export { DELIVERY_FAILURE_CODES, DELIVERY_SKIP_REASONS, declaredRateLimitOptions, HTTP_ERROR_CODE_PATTERN, isDeliveryErrorCode, isOpenToAllGranted, isRateLimitDisabled, isRateLimitOption, isUiAccessGranted, } from "@cosmicdrift/kumiko-types/handlers";
|
|
5
5
|
export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-types/identifiers";
|
|
6
6
|
export { metricField, relatedListGroupHeaderLabel, relatedListGroupKey, } from "@cosmicdrift/kumiko-types/screen";
|
|
7
7
|
export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, isFileField } from "../field-helpers.js";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { TenantDb } from "../db/tenant-db.ts";
|
|
2
2
|
import { type Registry, type SaveContext, type SessionUser } from "../engine/types/index.ts";
|
|
3
|
-
import type
|
|
3
|
+
import { type FileRefRows } from "./load-save-file-refs.ts";
|
|
4
4
|
export declare function fileRefIdsOf(value: unknown): readonly string[];
|
|
5
5
|
export declare function bindPendingFileRefs(args: {
|
|
6
6
|
readonly registry: Registry;
|
|
@@ -9,3 +9,33 @@ export declare function bindPendingFileRefs(args: {
|
|
|
9
9
|
readonly db: TenantDb;
|
|
10
10
|
readonly fileRefRows: FileRefRows;
|
|
11
11
|
}): Promise<void>;
|
|
12
|
+
export type PendingFileRef = {
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly storageKey: string;
|
|
15
|
+
readonly mimeType: string;
|
|
16
|
+
readonly size: number;
|
|
17
|
+
readonly version: number;
|
|
18
|
+
};
|
|
19
|
+
type PendingFileRefLookup = {
|
|
20
|
+
readonly fileRefId: string;
|
|
21
|
+
readonly entityName: string;
|
|
22
|
+
readonly fieldName: string;
|
|
23
|
+
readonly user: SessionUser;
|
|
24
|
+
readonly db: TenantDb;
|
|
25
|
+
};
|
|
26
|
+
export declare function loadPendingFileRef(lookup: PendingFileRefLookup): Promise<PendingFileRef | undefined>;
|
|
27
|
+
export type BindPendingFileRefOutcome = "bound" | "not-pending" | "version-conflict";
|
|
28
|
+
/**
|
|
29
|
+
* Binds one of the caller's own pending uploads to a record from a custom handler, with the
|
|
30
|
+
* same ownership predicate as the save pipeline. Anonymous callers never own an upload.
|
|
31
|
+
*
|
|
32
|
+
* The helper checks the upload, not the target: the caller must already be authorized to write
|
|
33
|
+
* `entityId` itself (for example a record the handler just wrote); `canWriteRecord` is not checked.
|
|
34
|
+
*
|
|
35
|
+
* A parallel bind of the same fileRef loses with "version-conflict" (or "not-pending" once the
|
|
36
|
+
* winner has committed); anything else throws.
|
|
37
|
+
*/
|
|
38
|
+
export declare function bindPendingFileRef(args: PendingFileRefLookup & {
|
|
39
|
+
readonly entityId: string;
|
|
40
|
+
}): Promise<BindPendingFileRefOutcome>;
|
|
41
|
+
export {};
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { isAnonymousSessionUser } from "../engine/system-user.js";
|
|
1
2
|
import { isFileField, isUuid, } from "../engine/types/index.js";
|
|
2
3
|
import { reraiseAsKumikoError } from "../errors/index.js";
|
|
3
4
|
import { getFileRefExecutor } from "./create-file-ref.js";
|
|
4
5
|
import { FILE_REF_ENTITY_NAME } from "./file-ref-entity.js";
|
|
6
|
+
import { loadFileRefRows } from "./load-save-file-refs.js";
|
|
5
7
|
export function fileRefIdsOf(value) {
|
|
6
8
|
if (typeof value === "string")
|
|
7
9
|
return isUuid(value) ? [value] : [];
|
|
@@ -15,6 +17,16 @@ function isPendingUploadFor(row, entityName, fieldName, user) {
|
|
|
15
17
|
row["fieldName"] === fieldName &&
|
|
16
18
|
row["insertedById"] === user.id);
|
|
17
19
|
}
|
|
20
|
+
function streamVersionOf(row) {
|
|
21
|
+
return typeof row["version"] === "number" ? row["version"] : undefined;
|
|
22
|
+
}
|
|
23
|
+
async function bindPendingRow(args) {
|
|
24
|
+
const { row, fileRefId, entityName, fieldName, entityId, user, db } = args;
|
|
25
|
+
if (!isPendingUploadFor(row, entityName, fieldName, user))
|
|
26
|
+
return { kind: "not-pending" };
|
|
27
|
+
const bound = await getFileRefExecutor().update({ id: fileRefId, version: streamVersionOf(row), changes: { entityId } }, user, db);
|
|
28
|
+
return bound.isSuccess ? { kind: "bound" } : { kind: "failed", error: bound.error };
|
|
29
|
+
}
|
|
18
30
|
// A form (or a server handler via createFileRefFromBytes) uploads before the
|
|
19
31
|
// record references the file, so its fileRefs carry entityType + fieldName but
|
|
20
32
|
// no entityId. Binding them in the save's own transaction makes record-bound
|
|
@@ -35,7 +47,6 @@ export async function bindPendingFileRefs(args) {
|
|
|
35
47
|
// skip: without the files feature the file_refs table is not part of the schema
|
|
36
48
|
if (registry.getEntity(FILE_REF_ENTITY_NAME) === undefined)
|
|
37
49
|
return;
|
|
38
|
-
const executor = getFileRefExecutor();
|
|
39
50
|
for (const [fieldName, fieldDef] of Object.entries(entity.fields)) {
|
|
40
51
|
if (!isFileField(fieldDef))
|
|
41
52
|
continue;
|
|
@@ -43,12 +54,75 @@ export async function bindPendingFileRefs(args) {
|
|
|
43
54
|
// ones bound earlier; only the ids this save names can be pending uploads.
|
|
44
55
|
for (const fileRefId of fileRefIdsOf(result.changes[fieldName])) {
|
|
45
56
|
const row = fileRefRows.get(fileRefId);
|
|
46
|
-
if (row === undefined
|
|
57
|
+
if (row === undefined)
|
|
47
58
|
continue;
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
59
|
+
const outcome = await bindPendingRow({
|
|
60
|
+
row,
|
|
61
|
+
fileRefId,
|
|
62
|
+
entityName,
|
|
63
|
+
fieldName,
|
|
64
|
+
entityId: String(result.id),
|
|
65
|
+
user,
|
|
66
|
+
db,
|
|
67
|
+
});
|
|
68
|
+
if (outcome.kind === "failed")
|
|
69
|
+
throw reraiseAsKumikoError(outcome.error);
|
|
52
70
|
}
|
|
53
71
|
}
|
|
54
72
|
}
|
|
73
|
+
async function loadPendingRow(lookup) {
|
|
74
|
+
const { fileRefId, entityName, fieldName, user, db } = lookup;
|
|
75
|
+
// Every anonymous visitor shares one user id, so insertedById cannot tell their uploads apart.
|
|
76
|
+
if (isAnonymousSessionUser(user))
|
|
77
|
+
return undefined;
|
|
78
|
+
if (!isUuid(fileRefId))
|
|
79
|
+
return undefined;
|
|
80
|
+
const row = (await loadFileRefRows(db, [fileRefId], user.tenantId)).get(fileRefId);
|
|
81
|
+
return row !== undefined && isPendingUploadFor(row, entityName, fieldName, user)
|
|
82
|
+
? row
|
|
83
|
+
: undefined;
|
|
84
|
+
}
|
|
85
|
+
// fileName is left out on purpose: it is personal data and may be encrypted; read it through
|
|
86
|
+
// the fileRef executor's detail when it is needed.
|
|
87
|
+
export async function loadPendingFileRef(lookup) {
|
|
88
|
+
const row = await loadPendingRow(lookup);
|
|
89
|
+
if (row === undefined)
|
|
90
|
+
return undefined;
|
|
91
|
+
const { storageKey, mimeType, size } = row;
|
|
92
|
+
const version = streamVersionOf(row);
|
|
93
|
+
if (typeof storageKey !== "string" ||
|
|
94
|
+
typeof mimeType !== "string" ||
|
|
95
|
+
typeof size !== "number" ||
|
|
96
|
+
version === undefined) {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
return { id: lookup.fileRefId, storageKey, mimeType, size, version };
|
|
100
|
+
}
|
|
101
|
+
const CONCURRENT_BIND_ERROR_CODES = ["version_conflict", "unique_violation"];
|
|
102
|
+
/**
|
|
103
|
+
* Binds one of the caller's own pending uploads to a record from a custom handler, with the
|
|
104
|
+
* same ownership predicate as the save pipeline. Anonymous callers never own an upload.
|
|
105
|
+
*
|
|
106
|
+
* The helper checks the upload, not the target: the caller must already be authorized to write
|
|
107
|
+
* `entityId` itself (for example a record the handler just wrote); `canWriteRecord` is not checked.
|
|
108
|
+
*
|
|
109
|
+
* A parallel bind of the same fileRef loses with "version-conflict" (or "not-pending" once the
|
|
110
|
+
* winner has committed); anything else throws.
|
|
111
|
+
*/
|
|
112
|
+
export async function bindPendingFileRef(args) {
|
|
113
|
+
const row = await loadPendingRow(args);
|
|
114
|
+
// Without a stream version the update would run unversioned and could overwrite a concurrent bind.
|
|
115
|
+
if (row === undefined || streamVersionOf(row) === undefined)
|
|
116
|
+
return "not-pending";
|
|
117
|
+
const outcome = await bindPendingRow({ ...args, row });
|
|
118
|
+
switch (outcome.kind) {
|
|
119
|
+
case "bound":
|
|
120
|
+
return "bound";
|
|
121
|
+
case "not-pending":
|
|
122
|
+
return "not-pending";
|
|
123
|
+
case "failed":
|
|
124
|
+
if (CONCURRENT_BIND_ERROR_CODES.includes(outcome.error.code))
|
|
125
|
+
return "version-conflict";
|
|
126
|
+
throw reraiseAsKumikoError(outcome.error);
|
|
127
|
+
}
|
|
128
|
+
}
|
package/dist/files/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export type { BindPendingFileRefOutcome, PendingFileRef } from "./bind-pending-file-refs.ts";
|
|
2
|
+
export { bindPendingFileRef, loadPendingFileRef } from "./bind-pending-file-refs.ts";
|
|
1
3
|
export type { CreatedFileRef, CreateFileRefContext, CreateFileRefInput, UploadedFileBytes, UploadFileBytesContext, } from "./create-file-ref-from-bytes.ts";
|
|
2
4
|
export { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytesBeforeTransaction, } from "./create-file-ref-from-bytes.ts";
|
|
3
5
|
export { createFilesFeature, FILES_FEATURE } from "./feature.ts";
|
package/dist/files/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export { bindPendingFileRef, loadPendingFileRef } from "./bind-pending-file-refs.js";
|
|
1
2
|
export { createFileRefFromBytes, createFileRefFromUploadedBytes, uploadFileBytesBeforeTransaction, } from "./create-file-ref-from-bytes.js";
|
|
2
3
|
export { createFilesFeature, FILES_FEATURE } from "./feature.js";
|
|
3
4
|
// `createFileHandle` is an implementation detail — construct handles via
|
|
@@ -33,12 +33,14 @@ export async function loadFileRefRows(db, ids, tenantId) {
|
|
|
33
33
|
// skip: nothing referenced, nothing to load
|
|
34
34
|
if (ids.length === 0)
|
|
35
35
|
return new Map();
|
|
36
|
+
// Versions before rows: a bind committing in between leaves a stale row paired with an older
|
|
37
|
+
// version, so the binding update conflicts instead of overwriting the winner's entityId.
|
|
38
|
+
const streamVersions = await getStreamVersions(tenantDbRunner(db), [...ids], db.tenantId);
|
|
36
39
|
const rows = await selectMany(db, fileRefsTable, {
|
|
37
40
|
id: { in: [...ids] },
|
|
38
41
|
tenantId,
|
|
39
42
|
isDeleted: false,
|
|
40
43
|
});
|
|
41
|
-
const streamVersions = await getStreamVersions(tenantDbRunner(db), rows.map((row) => String(row["id"])), db.tenantId);
|
|
42
44
|
return new Map(rows.map((row) => {
|
|
43
45
|
const id = String(row["id"]);
|
|
44
46
|
const streamVersion = streamVersions.get(id);
|
|
@@ -5,7 +5,7 @@ import { createTenantDb, createUncheckedSystemDb, hasTenantColumn, runInOwnTrans
|
|
|
5
5
|
import { createDerivativesContext } from "../derivatives/derivatives-context.js";
|
|
6
6
|
import { mayOverrideTenant } from "../engine/cross-tenant.js";
|
|
7
7
|
import { createSystemUser } from "../engine/system-user.js";
|
|
8
|
-
import { isRateLimitDisabled, resolveAgentExposure } from "../engine/types/index.js";
|
|
8
|
+
import { declaredRateLimitOptions, isRateLimitDisabled, resolveAgentExposure, } from "../engine/types/index.js";
|
|
9
9
|
import { AccessDeniedError, FeatureDisabledError, FrameworkReasons, InternalError, memberResolutionReadOnlyDenied, RateLimitError, RateLimitUnavailableError, VersionConflictError, writeFailure, } from "../errors/index.js";
|
|
10
10
|
import { archiveStream as archiveStreamHelper, isStreamArchived, restoreStream as restoreStreamHelper, } from "../event-store/archive.js";
|
|
11
11
|
import { IdempotentAppendConflictError as EventStoreIdempotentAppendConflictError, VersionConflictError as EventStoreVersionConflictError, } from "../event-store/errors.js";
|
|
@@ -1107,16 +1107,28 @@ const DEFAULT_SYSTEM_SCOPE_RATE_LIMIT = {
|
|
|
1107
1107
|
limit: 600,
|
|
1108
1108
|
windowSeconds: 60,
|
|
1109
1109
|
};
|
|
1110
|
-
function
|
|
1111
|
-
|
|
1112
|
-
|
|
1110
|
+
function resolveEffectiveRateLimits(rateLimit, isSystemScope, user, resolver) {
|
|
1111
|
+
const declared = declaredRateLimitOptions(rateLimit);
|
|
1112
|
+
if (declared.length > 0)
|
|
1113
|
+
return declared;
|
|
1113
1114
|
if (!isSystemScope)
|
|
1114
|
-
return
|
|
1115
|
+
return [];
|
|
1115
1116
|
if (!resolver)
|
|
1116
|
-
return
|
|
1117
|
+
return [];
|
|
1117
1118
|
if (isSystemIdentity(user))
|
|
1118
|
-
return
|
|
1119
|
-
return DEFAULT_SYSTEM_SCOPE_RATE_LIMIT;
|
|
1119
|
+
return [];
|
|
1120
|
+
return [DEFAULT_SYSTEM_SCOPE_RATE_LIMIT];
|
|
1121
|
+
}
|
|
1122
|
+
// A single bucket keeps the plain enforce path; several go through one atomic
|
|
1123
|
+
// all-or-nothing call so a rejection by one bucket never charges the others.
|
|
1124
|
+
async function enforceBuckets(resolver, requests) {
|
|
1125
|
+
const [only, ...rest] = requests;
|
|
1126
|
+
// skip: no bucket to charge
|
|
1127
|
+
if (!only)
|
|
1128
|
+
return;
|
|
1129
|
+
await (rest.length === 0
|
|
1130
|
+
? resolver.enforce(only.bucket, only.config)
|
|
1131
|
+
: resolver.enforceAll(requests));
|
|
1120
1132
|
}
|
|
1121
1133
|
// L3 rate limit gate. Called by both query and write paths before
|
|
1122
1134
|
// access-check. Reasoning:
|
|
@@ -1135,42 +1147,48 @@ export async function enforceRateLimit(ctx, rateLimit, handlerName, user, isSyst
|
|
|
1135
1147
|
// skip: handler explicitly opts out via rateLimit: { disabled: true }
|
|
1136
1148
|
if (isRateLimitDisabled(rateLimit))
|
|
1137
1149
|
return;
|
|
1138
|
-
const effective =
|
|
1150
|
+
const effective = resolveEffectiveRateLimits(rateLimit, isSystemScope, user, context.rateLimit);
|
|
1139
1151
|
// skip: no rateLimit declared and not systemScope (or resolved to no-op) — nothing to enforce
|
|
1140
|
-
if (
|
|
1152
|
+
if (effective.length === 0)
|
|
1141
1153
|
return;
|
|
1142
1154
|
const reqCtx = requestContext.get();
|
|
1143
|
-
const bucket = buildBucketKey(effective, {
|
|
1144
|
-
handlerName,
|
|
1145
|
-
user,
|
|
1146
|
-
ip: reqCtx?.ip,
|
|
1147
|
-
});
|
|
1148
|
-
// skip: ip-bucket + no IP (non-HTTP entry point) — pass through before
|
|
1149
|
-
// requiring a resolver; HTTP path always has an IP + L1/L2 middleware.
|
|
1150
|
-
if (bucket.kind === "skip")
|
|
1151
|
-
return;
|
|
1152
|
-
if (!context.rateLimit) {
|
|
1153
|
-
throw new InternalError({
|
|
1154
|
-
message: `Handler "${handlerName}" declares rateLimit but no RateLimitResolver is configured. Load the rate-limiting feature or remove the option.`,
|
|
1155
|
-
});
|
|
1156
|
-
}
|
|
1157
1155
|
// A nested dispatch re-hitting a bucket its entry dispatch already charged
|
|
1158
1156
|
// (e.g. a per:"ip" handler delegating via queryAs) must not cost a second token.
|
|
1159
1157
|
// The mark is set only after a successful enforce and includes the limit
|
|
1160
1158
|
// parameters: a denied or failed check never lets a later nested call through,
|
|
1161
1159
|
// and a nested handler with a stricter limit on the same bucket is still checked.
|
|
1162
1160
|
const charged = reqCtx?.chargedRateLimitBuckets;
|
|
1163
|
-
const
|
|
1164
|
-
|
|
1165
|
-
|
|
1161
|
+
const pending = [];
|
|
1162
|
+
for (const option of effective) {
|
|
1163
|
+
const bucket = buildBucketKey(option, { handlerName, user, ip: reqCtx?.ip });
|
|
1164
|
+
// skip: ip-bucket + no IP (non-HTTP entry point) — pass through before
|
|
1165
|
+
// requiring a resolver; HTTP path always has an IP + L1/L2 middleware.
|
|
1166
|
+
if (bucket.kind === "skip")
|
|
1167
|
+
continue;
|
|
1168
|
+
const chargedKey = `${bucket.key}|${option.limit}|${option.windowSeconds}|${option.cost}`;
|
|
1169
|
+
// skip: this exact bucket, limit and window was already charged by the entry dispatch
|
|
1170
|
+
if (charged?.has(chargedKey))
|
|
1171
|
+
continue;
|
|
1172
|
+
pending.push({
|
|
1173
|
+
request: {
|
|
1174
|
+
bucket: bucket.key,
|
|
1175
|
+
config: { limit: option.limit, windowSeconds: option.windowSeconds, cost: option.cost },
|
|
1176
|
+
},
|
|
1177
|
+
chargedKey,
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
// skip: every bucket was skipped or already charged — nothing left to enforce
|
|
1181
|
+
if (pending.length === 0)
|
|
1166
1182
|
return;
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
windowSeconds: effective.windowSeconds,
|
|
1171
|
-
cost: effective.cost,
|
|
1183
|
+
if (!context.rateLimit) {
|
|
1184
|
+
throw new InternalError({
|
|
1185
|
+
message: `Handler "${handlerName}" declares rateLimit but no RateLimitResolver is configured. Load the rate-limiting feature or remove the option.`,
|
|
1172
1186
|
});
|
|
1173
|
-
|
|
1187
|
+
}
|
|
1188
|
+
try {
|
|
1189
|
+
await enforceBuckets(context.rateLimit, pending.map(({ request }) => request));
|
|
1190
|
+
for (const { chargedKey } of pending)
|
|
1191
|
+
charged?.add(chargedKey);
|
|
1174
1192
|
}
|
|
1175
1193
|
catch (error) {
|
|
1176
1194
|
// Backend failure (Redis down): fail closed as 503, same as L1/L2.
|
|
@@ -1203,6 +1221,7 @@ export async function enforcePayloadRateLimits(ctx, limits, handlerName, user, d
|
|
|
1203
1221
|
message: `Handler "${handlerName}" declares additionalRateLimits but its payload is not an object.`,
|
|
1204
1222
|
});
|
|
1205
1223
|
}
|
|
1224
|
+
const requests = [];
|
|
1206
1225
|
for (const { per, limit, windowSeconds, cost, normalize } of limits) {
|
|
1207
1226
|
const value = data[per.payloadField];
|
|
1208
1227
|
// skip: optional field not sent — nothing to bucket on
|
|
@@ -1213,9 +1232,10 @@ export async function enforcePayloadRateLimits(ctx, limits, handlerName, user, d
|
|
|
1213
1232
|
message: `Handler "${handlerName}" additionalRateLimits payloadField "${per.payloadField}" is not a string.`,
|
|
1214
1233
|
});
|
|
1215
1234
|
}
|
|
1216
|
-
const
|
|
1217
|
-
|
|
1235
|
+
const bucket = buildPayloadBucketKey(user.tenantId, handlerName, per.payloadField, digest(normalizePayloadBucketValue(value, normalize)));
|
|
1236
|
+
requests.push({ bucket, config: { limit, windowSeconds, cost } });
|
|
1218
1237
|
}
|
|
1238
|
+
await enforceBuckets(context.rateLimit, requests);
|
|
1219
1239
|
}
|
|
1220
1240
|
// Build the per-hook context every auth-claims invocation gets. Claims
|
|
1221
1241
|
// hooks run OUTSIDE any request transaction (login is itself the root
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { type BucketContext, type BucketResult, buildBucketKey, buildPayloadBucketKey, createPayloadDigest, normalizePayloadBucketValue, } from "./bucket.ts";
|
|
2
2
|
export { type AuthEndpointRateLimitOptions, authEndpointRateLimit, type GlobalIpRateLimitOptions, globalIpRateLimit, type HttpRouteRateLimitOptions, httpRouteRateLimit, } from "./middleware.ts";
|
|
3
|
-
export { createRateLimitResolver, type RateLimitConfig, type RateLimitDecision, type RateLimitResolver, type RateLimitResolverOptions, } from "./resolver.ts";
|
|
3
|
+
export { createRateLimitResolver, type RateLimitBucketRequest, type RateLimitConfig, type RateLimitDecision, type RateLimitResolver, type RateLimitResolverOptions, } from "./resolver.ts";
|
|
4
4
|
export { createUnlimitedRateLimitResolver } from "./unlimited-resolver.ts";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { RateLimitResolver } from "@cosmicdrift/kumiko-types/rate-limit-types";
|
|
2
2
|
import type { Redis } from "ioredis";
|
|
3
|
-
export type { RateLimitConfig, RateLimitDecision, RateLimitResolver, } from "@cosmicdrift/kumiko-types/rate-limit-types";
|
|
3
|
+
export type { RateLimitBucketRequest, RateLimitConfig, RateLimitDecision, RateLimitResolver, } from "@cosmicdrift/kumiko-types/rate-limit-types";
|
|
4
4
|
export type RateLimitResolverOptions = {
|
|
5
5
|
readonly redis: Redis;
|
|
6
6
|
readonly keyPrefix?: string;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
2
|
-
import { RateLimitError } from "../errors/index.js";
|
|
2
|
+
import { InternalError, RateLimitError } from "../errors/index.js";
|
|
3
3
|
import { RedisKeys } from "../pipeline/redis-keys.js";
|
|
4
4
|
import { toPublicBucketName } from "./bucket.js";
|
|
5
5
|
// Token-Bucket rate limiter, atomic via Redis Lua. One round-trip per
|
|
@@ -105,6 +105,64 @@ end
|
|
|
105
105
|
|
|
106
106
|
return { math.floor(tokens), retryAfterMs }
|
|
107
107
|
`;
|
|
108
|
+
// All-or-nothing variant for several buckets. Every bucket is refilled and
|
|
109
|
+
// checked first; tokens are deducted only when each one has enough, so a
|
|
110
|
+
// rejected request charges no bucket. Multi-key EVAL needs all keys in one
|
|
111
|
+
// hash slot on Redis Cluster; the framework uses standalone Redis.
|
|
112
|
+
//
|
|
113
|
+
// KEYS[i] — bucket keys
|
|
114
|
+
// ARGV[1] — nowMs
|
|
115
|
+
// ARGV[2 + 4(i-1) .. 5 + 4(i-1)] — limit, refillRatePerMs, cost, ttlSeconds of KEYS[i]
|
|
116
|
+
//
|
|
117
|
+
// Returns the flat list { allAllowed (1|0), remaining_1, retryAfterMs_1, remaining_2, ... }.
|
|
118
|
+
// retryAfterMs_i is 0 for buckets that had enough tokens.
|
|
119
|
+
const TOKEN_BUCKET_ALL_LUA = `
|
|
120
|
+
local nowMs = tonumber(ARGV[1])
|
|
121
|
+
local count = #KEYS
|
|
122
|
+
local tokens = {}
|
|
123
|
+
local costs = {}
|
|
124
|
+
local retries = {}
|
|
125
|
+
local allAllowed = 1
|
|
126
|
+
|
|
127
|
+
for i = 1, count do
|
|
128
|
+
local base = 2 + (i - 1) * 4
|
|
129
|
+
local limit = tonumber(ARGV[base])
|
|
130
|
+
local refillRatePerMs = tonumber(ARGV[base + 1])
|
|
131
|
+
costs[i] = tonumber(ARGV[base + 2])
|
|
132
|
+
|
|
133
|
+
local data = redis.call('HMGET', KEYS[i], 'tokens', 'ts')
|
|
134
|
+
local current = tonumber(data[1])
|
|
135
|
+
local ts = tonumber(data[2])
|
|
136
|
+
if current == nil then
|
|
137
|
+
current = limit
|
|
138
|
+
else
|
|
139
|
+
local elapsed = math.max(0, nowMs - ts)
|
|
140
|
+
current = math.min(limit, current + elapsed * refillRatePerMs)
|
|
141
|
+
end
|
|
142
|
+
tokens[i] = current
|
|
143
|
+
|
|
144
|
+
if current >= costs[i] then
|
|
145
|
+
retries[i] = 0
|
|
146
|
+
else
|
|
147
|
+
retries[i] = math.ceil((costs[i] - current) / refillRatePerMs)
|
|
148
|
+
allAllowed = 0
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
local result = { allAllowed }
|
|
153
|
+
for i = 1, count do
|
|
154
|
+
if allAllowed == 1 then
|
|
155
|
+
tokens[i] = tokens[i] - costs[i]
|
|
156
|
+
end
|
|
157
|
+
local base = 2 + (i - 1) * 4
|
|
158
|
+
redis.call('HMSET', KEYS[i], 'tokens', tokens[i], 'ts', nowMs)
|
|
159
|
+
redis.call('EXPIRE', KEYS[i], tonumber(ARGV[base + 3]))
|
|
160
|
+
result[#result + 1] = math.floor(tokens[i])
|
|
161
|
+
result[#result + 1] = retries[i]
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
return result
|
|
165
|
+
`;
|
|
108
166
|
const REGISTERED = new WeakSet();
|
|
109
167
|
function ensureCommand(redis) {
|
|
110
168
|
if (!REGISTERED.has(redis)) {
|
|
@@ -116,6 +174,7 @@ function ensureCommand(redis) {
|
|
|
116
174
|
numberOfKeys: 1,
|
|
117
175
|
lua: TOKEN_BUCKET_PEEK_LUA,
|
|
118
176
|
});
|
|
177
|
+
redis.defineCommand("kumikoRateLimitAll", { lua: TOKEN_BUCKET_ALL_LUA });
|
|
119
178
|
REGISTERED.add(redis);
|
|
120
179
|
}
|
|
121
180
|
return redis; // @cast-boundary engine-bridge
|
|
@@ -154,6 +213,49 @@ export function createRateLimitResolver(opts) {
|
|
|
154
213
|
resetAt: decision.resetAt.toString(),
|
|
155
214
|
});
|
|
156
215
|
}
|
|
216
|
+
async function enforceAll(requests) {
|
|
217
|
+
if (requests.length === 0)
|
|
218
|
+
return [];
|
|
219
|
+
const nowMs = now();
|
|
220
|
+
const keys = requests.map(({ bucket }) => `${prefix}${bucket}`);
|
|
221
|
+
// The script writes each key back once, so a repeated key would deduct only one of its costs.
|
|
222
|
+
const duplicate = keys.find((key, index) => keys.indexOf(key) !== index);
|
|
223
|
+
if (duplicate !== undefined) {
|
|
224
|
+
throw new InternalError({
|
|
225
|
+
message: `enforceAll received the bucket "${duplicate.slice(prefix.length)}" more than once — merge the requests into one.`,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
const perBucketArgs = requests.flatMap(({ config }) => [
|
|
229
|
+
String(config.limit),
|
|
230
|
+
String(config.limit / (config.windowSeconds * 1000)),
|
|
231
|
+
String(config.cost ?? 1),
|
|
232
|
+
String(Math.max(1, config.windowSeconds * 2)),
|
|
233
|
+
]);
|
|
234
|
+
const [allAllowedFlag, ...flat] = await client.kumikoRateLimitAll(keys.length, ...keys, String(nowMs), ...perBucketArgs);
|
|
235
|
+
const outcomes = requests.map(({ bucket, config }, index) => {
|
|
236
|
+
const retryAfterMs = flat[index * 2 + 1] ?? 0;
|
|
237
|
+
const decision = {
|
|
238
|
+
allowed: retryAfterMs === 0,
|
|
239
|
+
limit: config.limit,
|
|
240
|
+
remaining: flat[index * 2] ?? 0,
|
|
241
|
+
retryAfterSeconds: Math.ceil(retryAfterMs / 1000),
|
|
242
|
+
windowSeconds: config.windowSeconds,
|
|
243
|
+
resetAt: Temporal.Instant.fromEpochMilliseconds(nowMs + retryAfterMs),
|
|
244
|
+
};
|
|
245
|
+
return { bucket, retryAfterMs, decision };
|
|
246
|
+
});
|
|
247
|
+
if (allAllowedFlag === 1)
|
|
248
|
+
return outcomes.map(({ decision }) => decision);
|
|
249
|
+
const rejected = outcomes.reduce((worst, outcome) => outcome.retryAfterMs > worst.retryAfterMs ? outcome : worst);
|
|
250
|
+
throw new RateLimitError({
|
|
251
|
+
bucket: toPublicBucketName(rejected.bucket),
|
|
252
|
+
limit: rejected.decision.limit,
|
|
253
|
+
windowSeconds: rejected.decision.windowSeconds,
|
|
254
|
+
remaining: rejected.decision.remaining,
|
|
255
|
+
retryAfterSeconds: rejected.decision.retryAfterSeconds,
|
|
256
|
+
resetAt: rejected.decision.resetAt.toString(),
|
|
257
|
+
});
|
|
258
|
+
}
|
|
157
259
|
async function peek(bucket, config) {
|
|
158
260
|
const refillRatePerMs = config.limit / (config.windowSeconds * 1000);
|
|
159
261
|
const nowMs = now();
|
|
@@ -171,5 +273,5 @@ export function createRateLimitResolver(opts) {
|
|
|
171
273
|
resetAt,
|
|
172
274
|
};
|
|
173
275
|
}
|
|
174
|
-
return { check, enforce, peek };
|
|
276
|
+
return { check, enforce, enforceAll, peek };
|
|
175
277
|
}
|
|
@@ -16,6 +16,7 @@ export function createUnlimitedRateLimitResolver() {
|
|
|
16
16
|
return {
|
|
17
17
|
check: async (_bucket, config) => fullBucket(config),
|
|
18
18
|
enforce: async (_bucket, config) => fullBucket(config),
|
|
19
|
+
enforceAll: async (requests) => requests.map(({ config }) => fullBucket(config)),
|
|
19
20
|
peek: async (_bucket, config) => fullBucket(config),
|
|
20
21
|
};
|
|
21
22
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.373.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -199,8 +199,8 @@
|
|
|
199
199
|
"./package.json": "./package.json"
|
|
200
200
|
},
|
|
201
201
|
"dependencies": {
|
|
202
|
-
"@cosmicdrift/kumiko-http": "0.
|
|
203
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
202
|
+
"@cosmicdrift/kumiko-http": "0.373.0",
|
|
203
|
+
"@cosmicdrift/kumiko-types": "0.373.0",
|
|
204
204
|
"bullmq": "^5.76.7",
|
|
205
205
|
"bun-types": "^1.3.13",
|
|
206
206
|
"hono": "^4.13.1",
|
|
@@ -216,8 +216,8 @@
|
|
|
216
216
|
"zod": "^4.4.3"
|
|
217
217
|
},
|
|
218
218
|
"devDependencies": {
|
|
219
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
220
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
219
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.373.0",
|
|
220
|
+
"@cosmicdrift/kumiko-testing": "0.373.0",
|
|
221
221
|
"bun-types": "^1.3.13",
|
|
222
222
|
"pino-pretty": "^13.1.3"
|
|
223
223
|
},
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.373.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "loadPendingFileRef and bindPendingFileRef bind a caller's own pending upload from a custom handler",
|
|
6
|
+
"detail": "Additive. fileName is not part of PendingFileRef because it is personal data; read it via the fileRef executor's detail. Unexpected update errors throw; version_conflict and unique_violation map to \"version-conflict\"."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.373.0",
|
|
10
|
+
"type": "breaking",
|
|
11
|
+
"title": "rateLimit accepts a list of buckets that are enforced all-or-nothing",
|
|
12
|
+
"detail": "RateLimitDeclaration is now a single option, a non-empty list of options or the disabled form. RateLimitResolver gains the required method enforceAll(requests: readonly RateLimitBucketRequest[]); createRateLimitResolver and createUnlimitedRateLimitResolver implement it. Payload buckets in additionalRateLimits are charged through the same call.",
|
|
13
|
+
"migration": "Custom RateLimitResolver implementations and test fakes need an enforceAll method (check every bucket, then deduct from all or none, and throw RateLimitError for the rejected bucket with the largest retryAfter). Existing rateLimit declarations stay valid. A duplicate payloadField in additionalRateLimits is now a boot error; merge the entries into one."
|
|
14
|
+
},
|
|
2
15
|
{
|
|
3
16
|
"version": "0.372.0",
|
|
4
17
|
"type": "improvement",
|