@flaredev/core 0.10.0 → 0.12.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/dist/chunk-7NQIVWGG.js +68 -0
- package/dist/{chunk-TB53VHXY.js → chunk-BTVEH5AU.js} +383 -12
- package/dist/chunk-I2HSIPCF.js +65 -0
- package/dist/index.d.ts +121 -5
- package/dist/index.js +75 -217
- package/dist/{policy-DFimkxdx.d.ts → policy-b4FXN3ZJ.d.ts} +196 -2
- package/dist/rate-limit/server.d.ts +44 -0
- package/dist/rate-limit/server.js +45 -0
- package/dist/rate-limit-ClbdZioJ.d.ts +97 -0
- package/dist/security/worker.js +13 -5
- package/dist/server.d.ts +69 -62
- package/dist/server.js +116 -37
- package/package.json +5 -1
|
@@ -1,3 +1,160 @@
|
|
|
1
|
+
import { b as RateLimitConfig } from './rate-limit-ClbdZioJ.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One shape for every error the API returns.
|
|
5
|
+
*
|
|
6
|
+
* There used to be four: `{ error }`, `{ error, field }`, `{ error, issues }` with a
|
|
7
|
+
* `path` in each issue, and `{ error, issues }` with a `param` in each issue instead —
|
|
8
|
+
* plus a plain-text `Access denied.` from the security layer. A client had to handle all
|
|
9
|
+
* of them, and which one it got depended on which guard happened to refuse the request.
|
|
10
|
+
*
|
|
11
|
+
* Now there is one:
|
|
12
|
+
*
|
|
13
|
+
* ```json
|
|
14
|
+
* {
|
|
15
|
+
* "error": "Number is already taken.",
|
|
16
|
+
* "code": "unique_violation",
|
|
17
|
+
* "issues": [{ "path": "number", "message": "Number is already taken.", "code": "unique" }]
|
|
18
|
+
* }
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* `error` is for a person — it is the sentence a form or a log shows, and it is the only
|
|
22
|
+
* field whose wording may change between releases. `code` is for a program: it is stable,
|
|
23
|
+
* so a client can translate it, branch on it, or decide whether retrying is worth it.
|
|
24
|
+
* `issues` says which fields, and is present whenever the answer is about fields at all —
|
|
25
|
+
* one issue for a unique conflict, one per failed rule for a validation error, one per
|
|
26
|
+
* bad parameter for a query. A client that only wants to put messages under inputs can
|
|
27
|
+
* read `issues` and ignore everything else.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* Why a request failed, as a value a program can depend on.
|
|
31
|
+
*
|
|
32
|
+
* Add to this rather than inventing a string at a call site: the point of a code is that
|
|
33
|
+
* it is a closed set a client can switch over.
|
|
34
|
+
*/
|
|
35
|
+
declare const PROBLEM_CODES: {
|
|
36
|
+
/** The body broke a field's rules. `issues` says which fields. */
|
|
37
|
+
readonly validationFailed: "validation_failed";
|
|
38
|
+
/** A unique column already holds this value. `issues` names the column. */
|
|
39
|
+
readonly uniqueViolation: "unique_violation";
|
|
40
|
+
/** Deleting this would orphan rows that point at it. */
|
|
41
|
+
readonly stillReferenced: "still_referenced";
|
|
42
|
+
/** A relation field names a record that does not exist. */
|
|
43
|
+
readonly relatedRecordMissing: "related_record_missing";
|
|
44
|
+
/** A value outside what its column accepts — an enum option that is not one. */
|
|
45
|
+
readonly valueNotAllowed: "value_not_allowed";
|
|
46
|
+
/** No such record, or none this request may see. */
|
|
47
|
+
readonly notFound: "not_found";
|
|
48
|
+
/** A query parameter is wrong. `issues` names the parameters. */
|
|
49
|
+
readonly invalidQuery: "invalid_query";
|
|
50
|
+
/** The body was not JSON. */
|
|
51
|
+
readonly invalidJson: "invalid_json";
|
|
52
|
+
/** The request did not say `Content-Type: application/json`. */
|
|
53
|
+
readonly unsupportedMediaType: "unsupported_media_type";
|
|
54
|
+
/** A write from another origin: the CSRF guard. */
|
|
55
|
+
readonly crossOriginBlocked: "cross_origin_blocked";
|
|
56
|
+
/** No session and no API key. */
|
|
57
|
+
readonly unauthenticated: "unauthenticated";
|
|
58
|
+
/** Signed in, but this role may not do this. */
|
|
59
|
+
readonly forbidden: "forbidden";
|
|
60
|
+
/** Refused by the security layer before the app saw it. */
|
|
61
|
+
readonly accessDenied: "access_denied";
|
|
62
|
+
/** Too many requests. */
|
|
63
|
+
readonly rateLimited: "rate_limited";
|
|
64
|
+
/** A signed storage URL that is missing, malformed, expired, or not for this operation. */
|
|
65
|
+
readonly invalidToken: "invalid_token";
|
|
66
|
+
/** An upload past the limit the signed URL allows. */
|
|
67
|
+
readonly payloadTooLarge: "payload_too_large";
|
|
68
|
+
/** An upload with no Content-Length, which cannot be checked against that limit. */
|
|
69
|
+
readonly lengthRequired: "length_required";
|
|
70
|
+
/** A request that is wrong in a way none of the others describes. */
|
|
71
|
+
readonly badRequest: "bad_request";
|
|
72
|
+
/** Something went wrong that the server does not explain. */
|
|
73
|
+
readonly internal: "internal";
|
|
74
|
+
};
|
|
75
|
+
type ProblemCode = (typeof PROBLEM_CODES)[keyof typeof PROBLEM_CODES];
|
|
76
|
+
/**
|
|
77
|
+
* Why one field is wrong.
|
|
78
|
+
*
|
|
79
|
+
* `path` is the field key, dotted for a nested one (`address.city`), or the parameter
|
|
80
|
+
* name for a query issue. `code` is optional only because a rule that has no better
|
|
81
|
+
* answer than "invalid" should not be forced to invent one.
|
|
82
|
+
*/
|
|
83
|
+
interface ProblemIssue {
|
|
84
|
+
path: string;
|
|
85
|
+
message: string;
|
|
86
|
+
code?: IssueCode;
|
|
87
|
+
}
|
|
88
|
+
/** Why one field is wrong, as a value a program can depend on. */
|
|
89
|
+
declare const ISSUE_CODES: {
|
|
90
|
+
/** Broke the field's own rules: a format, a length, a required value. */
|
|
91
|
+
readonly invalid: "invalid";
|
|
92
|
+
/** A unique column already holds this. */
|
|
93
|
+
readonly unique: "unique";
|
|
94
|
+
/** Set once and not changeable — an ownership column, for instance. */
|
|
95
|
+
readonly immutable: "immutable";
|
|
96
|
+
/** A query parameter rather than a body field. */
|
|
97
|
+
readonly invalidParameter: "invalid_parameter";
|
|
98
|
+
};
|
|
99
|
+
type IssueCode = (typeof ISSUE_CODES)[keyof typeof ISSUE_CODES];
|
|
100
|
+
/** The body of every error response. */
|
|
101
|
+
interface ProblemBody {
|
|
102
|
+
error: string;
|
|
103
|
+
code: ProblemCode;
|
|
104
|
+
issues?: ProblemIssue[];
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Whether this caller wants JSON.
|
|
108
|
+
*
|
|
109
|
+
* Used where a guard answers both browsers and programs — the security layer sits in
|
|
110
|
+
* front of pages as well as the API — because a browser navigation that renders a page of
|
|
111
|
+
* JSON is worse than one that renders a sentence. An `Accept` header asking for JSON, or
|
|
112
|
+
* a path under `/api/`, means a program is asking.
|
|
113
|
+
*/
|
|
114
|
+
declare function wantsJson(request: Request): boolean;
|
|
115
|
+
/** An error response in the one shape. */
|
|
116
|
+
declare function problemResponse(status: number, error: string, code: ProblemCode, issues?: ProblemIssue[], init?: ResponseInit): Response;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Fields that only apply sometimes.
|
|
120
|
+
*
|
|
121
|
+
* A billing cycle is required on a recurring project and meaningless on a one-off one.
|
|
122
|
+
* The usual way that gets built is an effect in the admin form that hides the input, and
|
|
123
|
+
* then the API happily accepts a cycle on a one-off project because nothing told it not
|
|
124
|
+
* to. The rule ends up in two or three places that disagree the first time somebody
|
|
125
|
+
* changes it.
|
|
126
|
+
*
|
|
127
|
+
* So the condition is declared on the field instead, once:
|
|
128
|
+
*
|
|
129
|
+
* billingCycle: field.enum(["monthly", "annually"], {
|
|
130
|
+
* showWhen: { field: "kind", op: "eq", value: "recurring" },
|
|
131
|
+
* })
|
|
132
|
+
*
|
|
133
|
+
* and everything that needs it reads the same object: the validator makes it required
|
|
134
|
+
* only when the condition holds, the store clears it when it doesn't, and the form shows
|
|
135
|
+
* the input only while it applies.
|
|
136
|
+
*/
|
|
137
|
+
interface FieldCondition {
|
|
138
|
+
/** The field this one depends on. Must be a field of the same resource. */
|
|
139
|
+
field: string;
|
|
140
|
+
op: "eq" | "ne" | "in";
|
|
141
|
+
/** A single value for eq/ne, a list for `in`. */
|
|
142
|
+
value: string | string[];
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Whether a conditional field applies, given the record it sits in.
|
|
146
|
+
*
|
|
147
|
+
* Compared as text, because the controlling field is an enum, a boolean or a number
|
|
148
|
+
* depending on who wrote the descriptor, and a form sends every one of them as a string.
|
|
149
|
+
* `true` and `"true"` have to answer the same here or the form and the API disagree.
|
|
150
|
+
*
|
|
151
|
+
* A field with no condition always applies, which is what makes this safe to call on
|
|
152
|
+
* every field without asking first.
|
|
153
|
+
*/
|
|
154
|
+
declare function conditionHolds(condition: FieldCondition | undefined, values: Record<string, unknown>): boolean;
|
|
155
|
+
/** "kind == recurring" — for an error message, a form hint or a generated comment. */
|
|
156
|
+
declare function describeCondition(condition: FieldCondition): string;
|
|
157
|
+
|
|
1
158
|
/**
|
|
2
159
|
* Field builders for resource descriptors.
|
|
3
160
|
*
|
|
@@ -6,6 +163,7 @@
|
|
|
6
163
|
* rule in a descriptor takes effect without re-running the generator. Only
|
|
7
164
|
* storage-level changes (kind, nullability, uniqueness, relations) need a migration.
|
|
8
165
|
*/
|
|
166
|
+
|
|
9
167
|
type FieldKind = "string" | "text" | "int" | "float" | "boolean" | "date" | "datetime" | "enum" | "multiselect" | "tags" | "json" | "file" | "belongsTo" | "hasMany";
|
|
10
168
|
/** Options shared by every stored (non-hasMany) field. */
|
|
11
169
|
interface CommonOptions<T> {
|
|
@@ -23,6 +181,28 @@ interface CommonOptions<T> {
|
|
|
23
181
|
filterable?: boolean;
|
|
24
182
|
/** Included in free-text search. Default true for string fields. */
|
|
25
183
|
searchable?: boolean;
|
|
184
|
+
/**
|
|
185
|
+
* Only applies when another field says so, e.g. a billing cycle on a recurring project:
|
|
186
|
+
* `showWhen: { field: "kind", op: "eq", value: "recurring" }`.
|
|
187
|
+
*
|
|
188
|
+
* The column is always nullable, because the field is genuinely absent the rest of the
|
|
189
|
+
* time. `required` then means "required *when it applies*" rather than "never null".
|
|
190
|
+
*/
|
|
191
|
+
showWhen?: FieldCondition;
|
|
192
|
+
/**
|
|
193
|
+
* This field used to be called something else, and the column holds data worth keeping:
|
|
194
|
+
*
|
|
195
|
+
* fullName: field.string({ rename: { from: "name" } })
|
|
196
|
+
*
|
|
197
|
+
* Without it a rename is a drop and an add, which empties the column. The descriptor
|
|
198
|
+
* knows the two names are the same column; the migration should too.
|
|
199
|
+
*
|
|
200
|
+
* It applies once. Once the column has been renamed the declaration does nothing, so it
|
|
201
|
+
* is safe to leave in place and safe to delete on the next edit.
|
|
202
|
+
*/
|
|
203
|
+
rename?: {
|
|
204
|
+
from: string;
|
|
205
|
+
};
|
|
26
206
|
}
|
|
27
207
|
/**
|
|
28
208
|
* What a string holds. Each format is still a text column; the format decides
|
|
@@ -302,7 +482,7 @@ interface ResourceConfig<Fields extends Record<string, Field>> {
|
|
|
302
482
|
/** Field used to represent a record in lists and relation pickers. Default: first string field. */
|
|
303
483
|
titleField?: FieldKey<Fields>;
|
|
304
484
|
defaultSort?: {
|
|
305
|
-
field: FieldKey<Fields> | "createdAt" | "updatedAt";
|
|
485
|
+
field: FieldKey<Fields> | "id" | "createdAt" | "updatedAt";
|
|
306
486
|
direction: "asc" | "desc";
|
|
307
487
|
};
|
|
308
488
|
/** Page size for list endpoints and tables. Default 25. */
|
|
@@ -321,6 +501,18 @@ interface ResourceConfig<Fields extends Record<string, Field>> {
|
|
|
321
501
|
* decision rather than an accident.
|
|
322
502
|
*/
|
|
323
503
|
softDelete?: boolean;
|
|
504
|
+
/**
|
|
505
|
+
* How often each action may be called, per signed-in account (or per IP when there is
|
|
506
|
+
* nobody signed in):
|
|
507
|
+
*
|
|
508
|
+
* rateLimit: { create: "10/min", "*": "120/min" }
|
|
509
|
+
*
|
|
510
|
+
* The named action wins over `*`. Enforced in the generated route, before the store is
|
|
511
|
+
* touched, so a refused call costs nothing but the count. Worth setting on the actions
|
|
512
|
+
* that cost something — a create that sends an email, an export that reads the whole
|
|
513
|
+
* table — rather than on everything.
|
|
514
|
+
*/
|
|
515
|
+
rateLimit?: RateLimitConfig;
|
|
324
516
|
/**
|
|
325
517
|
* Your logic, beside the fields it belongs to. Every write — the REST API, the
|
|
326
518
|
* dashboard's forms, a seed, an import — goes through the same store, so a hook here
|
|
@@ -387,6 +579,8 @@ interface Resource<Fields extends Record<string, Field> = Record<string, Field>>
|
|
|
387
579
|
perPage: number;
|
|
388
580
|
/** Deleted records are kept and hidden, not removed. See ResourceConfig.softDelete. */
|
|
389
581
|
softDelete: boolean;
|
|
582
|
+
/** How often each action may be called. See ResourceConfig.rateLimit. */
|
|
583
|
+
rateLimit?: RateLimitConfig;
|
|
390
584
|
/** Fields with labels filled in. */
|
|
391
585
|
fields: Fields & Record<string, Field & {
|
|
392
586
|
label: string;
|
|
@@ -508,4 +702,4 @@ declare function ownershipFilter(policy: Policy | undefined | null, user: {
|
|
|
508
702
|
/** Actions `role` may perform, for hiding UI it can't use. */
|
|
509
703
|
declare function allowedActions(policy: Policy | undefined | null, role: string | null | undefined): Record<Exclude<PolicyAction, "list">, boolean>;
|
|
510
704
|
|
|
511
|
-
export {
|
|
705
|
+
export { clientResource as $, type RecordOf as A, type BelongsToField as B, type CreateInput as C, DEFAULT_FILE_MAX_BYTES as D, type EnumField as E, type Field as F, type ResourceConfig as G, type HasManyField as H, ISSUE_CODES as I, type JsonField as J, ResourceDefinitionError as K, STRING_FORMATS as L, type MultiSelectField as M, NUMBER_FORMATS as N, type StringField as O, type ProblemCode as P, type StringFormat as Q, type Resource as R, type StoredField as S, TEXT_FORMATS as T, type UpdateInput as U, type TagsField as V, type TagsOptions as W, type TextField as X, type TextFormat as Y, allowedActions as Z, can as _, type ProblemIssue as a, columnName as a0, conditionHolds as a1, defaultFieldLabel as a2, definePolicy as a3, defineResource as a4, describeCondition as a5, field as a6, fileMaxBytes as a7, mimeTypesFor as a8, ownershipFilter as a9, storedFields as aa, type Policy as b, type IssueCode as c, PROBLEM_CODES as d, type ProblemBody as e, type BooleanField as f, type ClientResource as g, type DateField as h, type DateTimeField as i, FILE_CATEGORIES as j, type FieldCondition as k, type FieldKind as l, type FieldValue as m, type FileCategory as n, type FileField as o, problemResponse as p, type FloatField as q, type IntField as r, type JsonOptions as s, type NumberFormat as t, type PolicyAction as u, type PolicyConfig as v, wantsJson as w, PolicyError as x, type PolicyOwnership as y, RESERVED_FIELD_NAMES as z };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { DurableObject } from 'cloudflare:workers';
|
|
2
|
+
import { R as Rate, a as RateLimitDecision } from '../rate-limit-ClbdZioJ.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The Cloudflare half of rate limiting: a Durable Object that holds the counters.
|
|
6
|
+
*
|
|
7
|
+
* **One instance per resource and action, not per caller.** A Durable Object is a billed
|
|
8
|
+
* request, so a counter per signed-in user per endpoint would be an invoice rather than a
|
|
9
|
+
* feature. Instead one object holds a map of caller → count for a single
|
|
10
|
+
* resource-and-action pair, which is one request per limited call and a handful of
|
|
11
|
+
* objects for the whole app.
|
|
12
|
+
*
|
|
13
|
+
* **Counters live in memory.** If the object is evicted the window restarts, which lets a
|
|
14
|
+
* caller through earlier than the limit says. That is the right trade here: this is for
|
|
15
|
+
* stopping one account hammering one endpoint, and the alternative is a storage write on
|
|
16
|
+
* every request to make the boundary exact. A limit that must hold across an eviction —
|
|
17
|
+
* a paid quota, say — belongs in the database with the thing it is metering.
|
|
18
|
+
*
|
|
19
|
+
* The alarm is what keeps memory flat: every window, whatever has expired is dropped.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
declare class RateLimitCounter extends DurableObject {
|
|
23
|
+
private counters;
|
|
24
|
+
/**
|
|
25
|
+
* Count one call against `key` and say whether it was within `rate`.
|
|
26
|
+
*
|
|
27
|
+
* Called through the stub, so the arguments cross a serialization boundary: plain data
|
|
28
|
+
* only, which is why the rate is passed in rather than configured on the object.
|
|
29
|
+
*/
|
|
30
|
+
hit(key: string, rate: Rate): Promise<RateLimitDecision>;
|
|
31
|
+
/** Drop what has expired, and keep sweeping only while something is left. */
|
|
32
|
+
alarm(): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
/** What the generated `AppEnv` calls this binding. */
|
|
35
|
+
declare const RATE_LIMIT_BINDING = "FLARE_RATE_LIMIT";
|
|
36
|
+
/** The shape of the binding, so an app can type it without importing the Workers types. */
|
|
37
|
+
interface RateLimitNamespace {
|
|
38
|
+
idFromName(name: string): unknown;
|
|
39
|
+
get(id: unknown): {
|
|
40
|
+
hit(key: string, rate: Rate): Promise<RateLimitDecision>;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export { RATE_LIMIT_BINDING, RateLimitCounter, type RateLimitNamespace };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {
|
|
2
|
+
fixedWindow
|
|
3
|
+
} from "../chunk-I2HSIPCF.js";
|
|
4
|
+
|
|
5
|
+
// src/rate-limit/server.ts
|
|
6
|
+
import { DurableObject } from "cloudflare:workers";
|
|
7
|
+
var SWEEP_INTERVAL = 6e4;
|
|
8
|
+
var RateLimitCounter = class extends DurableObject {
|
|
9
|
+
counters = /* @__PURE__ */ new Map();
|
|
10
|
+
/**
|
|
11
|
+
* Count one call against `key` and say whether it was within `rate`.
|
|
12
|
+
*
|
|
13
|
+
* Called through the stub, so the arguments cross a serialization boundary: plain data
|
|
14
|
+
* only, which is why the rate is passed in rather than configured on the object.
|
|
15
|
+
*/
|
|
16
|
+
async hit(key, rate) {
|
|
17
|
+
const decision = await fixedWindow(rate, async (windowSeconds) => {
|
|
18
|
+
const now = Date.now();
|
|
19
|
+
const existing = this.counters.get(key);
|
|
20
|
+
if (!existing || existing.expires <= now) {
|
|
21
|
+
const counter = { count: 1, expires: now + windowSeconds * 1e3 };
|
|
22
|
+
this.counters.set(key, counter);
|
|
23
|
+
return { count: 1, ttl: windowSeconds };
|
|
24
|
+
}
|
|
25
|
+
existing.count += 1;
|
|
26
|
+
return { count: existing.count, ttl: (existing.expires - now) / 1e3 };
|
|
27
|
+
});
|
|
28
|
+
const alarm = await this.ctx.storage.getAlarm();
|
|
29
|
+
if (alarm === null) await this.ctx.storage.setAlarm(Date.now() + SWEEP_INTERVAL);
|
|
30
|
+
return decision;
|
|
31
|
+
}
|
|
32
|
+
/** Drop what has expired, and keep sweeping only while something is left. */
|
|
33
|
+
async alarm() {
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
for (const [key, counter] of this.counters) {
|
|
36
|
+
if (counter.expires <= now) this.counters.delete(key);
|
|
37
|
+
}
|
|
38
|
+
if (this.counters.size > 0) await this.ctx.storage.setAlarm(now + SWEEP_INTERVAL);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
var RATE_LIMIT_BINDING = "FLARE_RATE_LIMIT";
|
|
42
|
+
export {
|
|
43
|
+
RATE_LIMIT_BINDING,
|
|
44
|
+
RateLimitCounter
|
|
45
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate limiting per resource and per action.
|
|
3
|
+
*
|
|
4
|
+
* A descriptor says how often each action may be called:
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* rateLimit: { create: "10/min", "*": "120/min" }
|
|
8
|
+
* ```
|
|
9
|
+
*
|
|
10
|
+
* and the generated route enforces it before the store is touched. The point is the
|
|
11
|
+
* actions that cost something — a create that sends an email, an export that reads the
|
|
12
|
+
* whole table — not a blanket limit on everything, which is what the edge already does.
|
|
13
|
+
*
|
|
14
|
+
* **Why not the platform's own limiter.** Cloudflare has a rate-limiting binding and it
|
|
15
|
+
* is cheaper than anything here, but its window may only be 10 or 60 seconds and its
|
|
16
|
+
* limit is fixed in the Worker's configuration rather than per call. A descriptor that
|
|
17
|
+
* said `"500/hour"` would work on one stack and not the other, and a descriptor that
|
|
18
|
+
* behaves differently per stack is the one thing Flare does not do. So the counter is
|
|
19
|
+
* Flare's: a Durable Object on Cloudflare, Redis on Next.js, the same arithmetic on both.
|
|
20
|
+
*
|
|
21
|
+
* The edge limiter is still worth having and is unaffected — `flare gen security`
|
|
22
|
+
* configures it. It stops floods; this stops one signed-in account from calling one
|
|
23
|
+
* endpoint too often, which is a different question.
|
|
24
|
+
*/
|
|
25
|
+
/** A limit, once parsed: so many calls per so many seconds. */
|
|
26
|
+
interface Rate {
|
|
27
|
+
limit: number;
|
|
28
|
+
seconds: number;
|
|
29
|
+
}
|
|
30
|
+
declare class RateSyntaxError extends Error {
|
|
31
|
+
constructor(message: string);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* `"10/min"` → `{ limit: 10, seconds: 60 }`.
|
|
35
|
+
*
|
|
36
|
+
* Also takes a count, as in `"30/5min"`, for the windows between the named ones. Throws
|
|
37
|
+
* rather than guessing: a rate limit that silently parsed as something else is worse than
|
|
38
|
+
* one that refuses to generate.
|
|
39
|
+
*/
|
|
40
|
+
declare function parseRate(spec: string): Rate;
|
|
41
|
+
/** Actions a rate limit can be set on, plus `*` for the rest. */
|
|
42
|
+
type RateLimitedAction = "list" | "read" | "create" | "update" | "delete" | "*";
|
|
43
|
+
/** What a descriptor may say about how often it can be called. */
|
|
44
|
+
type RateLimitConfig = Partial<Record<RateLimitedAction, string>>;
|
|
45
|
+
/**
|
|
46
|
+
* The rate that applies to one action, or undefined when none does.
|
|
47
|
+
*
|
|
48
|
+
* The named action wins over `*`, so `{ create: "10/min", "*": "120/min" }` reads as "ten
|
|
49
|
+
* creates a minute, a hundred and twenty of anything else" rather than both at once.
|
|
50
|
+
*/
|
|
51
|
+
declare function rateFor(config: RateLimitConfig | undefined, action: RateLimitedAction): Rate | undefined;
|
|
52
|
+
/** Every rate a config declares, by action, with its spec — for validation and for docs. */
|
|
53
|
+
declare function ratesIn(config: RateLimitConfig | undefined): {
|
|
54
|
+
action: RateLimitedAction;
|
|
55
|
+
spec: string;
|
|
56
|
+
rate: Rate;
|
|
57
|
+
}[];
|
|
58
|
+
/** What a counter says when it is asked. */
|
|
59
|
+
interface RateLimitDecision {
|
|
60
|
+
allowed: boolean;
|
|
61
|
+
/** Calls left in this window, never below zero. */
|
|
62
|
+
remaining: number;
|
|
63
|
+
/** Seconds until the window resets — what goes in `Retry-After`. */
|
|
64
|
+
retryAfter: number;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Somewhere to keep the counters.
|
|
68
|
+
*
|
|
69
|
+
* One method, because that is all a fixed window needs: count this call, and say whether
|
|
70
|
+
* it was within the limit. Implemented over a Durable Object on Cloudflare and over Redis
|
|
71
|
+
* on Next.js; both are in the app, in `lib/rate-limit.ts`, and both are yours to replace.
|
|
72
|
+
*/
|
|
73
|
+
interface RateLimitStore {
|
|
74
|
+
hit(key: string, rate: Rate): Promise<RateLimitDecision>;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The key a limit is counted against: one bucket per resource, action and caller.
|
|
78
|
+
*
|
|
79
|
+
* The caller is the signed-in account where there is one and the IP otherwise, so a limit
|
|
80
|
+
* on `create` is a limit on that person rather than on everyone sharing an office
|
|
81
|
+
* connection. Anonymous callers behind one address do share a bucket, which is the right
|
|
82
|
+
* trade for an endpoint that does not know who is calling.
|
|
83
|
+
*/
|
|
84
|
+
declare const rateLimitKey: (resource: string, action: string, caller: string) => string;
|
|
85
|
+
/**
|
|
86
|
+
* A fixed-window count, given somewhere to keep a number.
|
|
87
|
+
*
|
|
88
|
+
* Shared by both backends so the arithmetic — and the off-by-one at the boundary — is
|
|
89
|
+
* written once. `increment` returns the count *after* this call and the seconds left in
|
|
90
|
+
* the window it belongs to.
|
|
91
|
+
*/
|
|
92
|
+
declare function fixedWindow(rate: Rate, increment: (windowSeconds: number) => Promise<{
|
|
93
|
+
count: number;
|
|
94
|
+
ttl: number;
|
|
95
|
+
}>): Promise<RateLimitDecision>;
|
|
96
|
+
|
|
97
|
+
export { type Rate as R, type RateLimitDecision as a, type RateLimitConfig as b, type RateLimitStore as c, type RateLimitedAction as d, RateSyntaxError as e, fixedWindow as f, rateLimitKey as g, ratesIn as h, parseRate as p, rateFor as r };
|
package/dist/security/worker.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PROBLEM_CODES,
|
|
3
|
+
problemResponse,
|
|
4
|
+
wantsJson
|
|
5
|
+
} from "../chunk-7NQIVWGG.js";
|
|
1
6
|
import {
|
|
2
7
|
clientIp,
|
|
3
8
|
detectorMatches,
|
|
@@ -31,6 +36,12 @@ var SecurityMonitor = class extends DurableObject {
|
|
|
31
36
|
await this.ctx.storage.deleteAll();
|
|
32
37
|
}
|
|
33
38
|
};
|
|
39
|
+
function refuse(request, status, message, code, extraHeaders = {}) {
|
|
40
|
+
const headers = { "cache-control": "no-store", ...extraHeaders };
|
|
41
|
+
if (wantsJson(request)) return problemResponse(status, message, code, void 0, { headers });
|
|
42
|
+
return new Response(`${message}
|
|
43
|
+
`, { status, headers: { "content-type": "text/plain; charset=utf-8", ...headers } });
|
|
44
|
+
}
|
|
34
45
|
function createSecurity(options) {
|
|
35
46
|
const { config, bans } = options;
|
|
36
47
|
const allowed = new Set(config.allow ?? []);
|
|
@@ -93,15 +104,12 @@ function createSecurity(options) {
|
|
|
93
104
|
if (remembered !== void 0 && remembered <= Date.now()) recentBans.delete(ip);
|
|
94
105
|
if (remembered !== void 0 && remembered > Date.now() || await bans.get(`${BAN_PREFIX}${ip}`)) {
|
|
95
106
|
ctx.waitUntil(monitor(ip).blocked());
|
|
96
|
-
return
|
|
107
|
+
return refuse(request, 403, "Access denied.", PROBLEM_CODES.accessDenied);
|
|
97
108
|
}
|
|
98
109
|
if (options.rateLimiter && config.rateLimit?.paths.test(new URL(request.url).pathname)) {
|
|
99
110
|
const { success } = await options.rateLimiter.limit({ key: ip });
|
|
100
111
|
if (!success) {
|
|
101
|
-
return
|
|
102
|
-
status: 429,
|
|
103
|
-
headers: { "content-type": "text/plain; charset=utf-8", "retry-after": "60", "cache-control": "no-store" }
|
|
104
|
-
});
|
|
112
|
+
return refuse(request, 429, "Too many requests. Slow down and try again shortly.", PROBLEM_CODES.rateLimited, { "retry-after": "60" });
|
|
105
113
|
}
|
|
106
114
|
}
|
|
107
115
|
const response = await next();
|
package/dist/server.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import * as drizzle_orm_sqlite_core from 'drizzle-orm/sqlite-core';
|
|
2
2
|
import { BaseSQLiteDatabase, SQLiteTable, SQLiteColumn } from 'drizzle-orm/sqlite-core';
|
|
3
|
-
import {
|
|
3
|
+
import { P as ProblemCode, a as ProblemIssue, R as Resource, F as Field, S as StoredField, b as Policy } from './policy-b4FXN3ZJ.js';
|
|
4
|
+
export { I as ISSUE_CODES, c as IssueCode, d as PROBLEM_CODES, e as ProblemBody, p as problemResponse, w as wantsJson } from './policy-b4FXN3ZJ.js';
|
|
5
|
+
import './rate-limit-ClbdZioJ.js';
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* The handful of things a resource store needs a database to do.
|
|
@@ -86,59 +88,6 @@ declare function drizzleRows(table: SQLiteTable, getDb: () => AnyDatabase): Reso
|
|
|
86
88
|
columns: Record<string, SQLiteColumn>;
|
|
87
89
|
};
|
|
88
90
|
|
|
89
|
-
interface ListQuery {
|
|
90
|
-
page: number;
|
|
91
|
-
perPage: number;
|
|
92
|
-
sort: {
|
|
93
|
-
field: string;
|
|
94
|
-
direction: "asc" | "desc";
|
|
95
|
-
};
|
|
96
|
-
q?: string;
|
|
97
|
-
/** Field key → coerced value; `null` means IS NULL. */
|
|
98
|
-
filters: Record<string, string | number | boolean | null>;
|
|
99
|
-
/**
|
|
100
|
-
* Where to carry on from, instead of counting rows to skip. `OFFSET 500000` makes
|
|
101
|
-
* SQLite walk half a million index entries before it reads anything (8s at a million
|
|
102
|
-
* rows); a cursor turns that into a range scan (~100ms).
|
|
103
|
-
*/
|
|
104
|
-
cursor?: Cursor;
|
|
105
|
-
}
|
|
106
|
-
/** The last row of the page you came from, and which way you're going. */
|
|
107
|
-
interface Cursor {
|
|
108
|
-
/** The sort field's value on that row. */
|
|
109
|
-
value: string | number | boolean | null;
|
|
110
|
-
id: string;
|
|
111
|
-
direction: "after" | "before";
|
|
112
|
-
}
|
|
113
|
-
interface QueryIssue {
|
|
114
|
-
param: string;
|
|
115
|
-
message: string;
|
|
116
|
-
}
|
|
117
|
-
declare function isSortable(def: StoredField): boolean;
|
|
118
|
-
declare function isFilterable(def: StoredField): boolean;
|
|
119
|
-
declare function isSearchable(def: StoredField): boolean;
|
|
120
|
-
/**
|
|
121
|
-
* Parse `?page=2&perPage=50&sort=-createdAt&q=ada&filter[status]=lead` against a
|
|
122
|
-
* resource. Only fields the descriptor marks sortable/filterable/searchable are
|
|
123
|
-
* accepted, so clients can't probe arbitrary columns.
|
|
124
|
-
*/
|
|
125
|
-
declare function parseListQuery(resource: Resource, params: URLSearchParams,
|
|
126
|
-
/**
|
|
127
|
-
* The largest page this caller may ask for.
|
|
128
|
-
*
|
|
129
|
-
* The cap exists to stop a request from the outside asking for ten thousand rows at
|
|
130
|
-
* once. Server-side callers that page through everything deliberately — the CSV
|
|
131
|
-
* export — pass their own, which is why this is an argument rather than a constant
|
|
132
|
-
* everything obeys.
|
|
133
|
-
*/
|
|
134
|
-
options?: {
|
|
135
|
-
maxPerPage?: number;
|
|
136
|
-
}): {
|
|
137
|
-
query: ListQuery;
|
|
138
|
-
} | {
|
|
139
|
-
issues: QueryIssue[];
|
|
140
|
-
};
|
|
141
|
-
|
|
142
91
|
type ResourceAction = "list" | "read" | "create" | "update" | "delete";
|
|
143
92
|
interface FieldIssue {
|
|
144
93
|
/** Dotted field path; "" for the whole body (e.g. unknown keys). */
|
|
@@ -148,13 +97,18 @@ interface FieldIssue {
|
|
|
148
97
|
type Failure = {
|
|
149
98
|
ok: false;
|
|
150
99
|
status: 400 | 404 | 409 | 422;
|
|
100
|
+
/** The sentence a person reads. Its wording is not part of the contract. */
|
|
151
101
|
error: string;
|
|
152
|
-
/**
|
|
153
|
-
|
|
154
|
-
/**
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
102
|
+
/** Why, as a value a program can switch on. See PROBLEM_CODES. */
|
|
103
|
+
code: ProblemCode;
|
|
104
|
+
/**
|
|
105
|
+
* Which fields, whenever the answer is about fields at all: one issue for a unique
|
|
106
|
+
* conflict, one per failed rule for a validation error, one per bad parameter for a
|
|
107
|
+
* query. There used to be three different ways of saying this — `issues` with a
|
|
108
|
+
* `path`, `issues` with a `param`, and a bare `field` — and a client had to handle all
|
|
109
|
+
* of them depending on which guard refused it.
|
|
110
|
+
*/
|
|
111
|
+
issues?: ProblemIssue[];
|
|
158
112
|
};
|
|
159
113
|
type Result<T> = {
|
|
160
114
|
ok: true;
|
|
@@ -298,6 +252,59 @@ declare function createResourceHandlers(options: ResourceHandlerOptions): {
|
|
|
298
252
|
};
|
|
299
253
|
};
|
|
300
254
|
|
|
255
|
+
interface ListQuery {
|
|
256
|
+
page: number;
|
|
257
|
+
perPage: number;
|
|
258
|
+
sort: {
|
|
259
|
+
field: string;
|
|
260
|
+
direction: "asc" | "desc";
|
|
261
|
+
};
|
|
262
|
+
q?: string;
|
|
263
|
+
/** Field key → coerced value; `null` means IS NULL. */
|
|
264
|
+
filters: Record<string, string | number | boolean | null>;
|
|
265
|
+
/**
|
|
266
|
+
* Where to carry on from, instead of counting rows to skip. `OFFSET 500000` makes
|
|
267
|
+
* SQLite walk half a million index entries before it reads anything (8s at a million
|
|
268
|
+
* rows); a cursor turns that into a range scan (~100ms).
|
|
269
|
+
*/
|
|
270
|
+
cursor?: Cursor;
|
|
271
|
+
}
|
|
272
|
+
/** The last row of the page you came from, and which way you're going. */
|
|
273
|
+
interface Cursor {
|
|
274
|
+
/** The sort field's value on that row. */
|
|
275
|
+
value: string | number | boolean | null;
|
|
276
|
+
id: string;
|
|
277
|
+
direction: "after" | "before";
|
|
278
|
+
}
|
|
279
|
+
interface QueryIssue {
|
|
280
|
+
param: string;
|
|
281
|
+
message: string;
|
|
282
|
+
}
|
|
283
|
+
declare function isSortable(def: StoredField): boolean;
|
|
284
|
+
declare function isFilterable(def: StoredField): boolean;
|
|
285
|
+
declare function isSearchable(def: StoredField): boolean;
|
|
286
|
+
/**
|
|
287
|
+
* Parse `?page=2&perPage=50&sort=-createdAt&q=ada&filter[status]=lead` against a
|
|
288
|
+
* resource. Only fields the descriptor marks sortable/filterable/searchable are
|
|
289
|
+
* accepted, so clients can't probe arbitrary columns.
|
|
290
|
+
*/
|
|
291
|
+
declare function parseListQuery(resource: Resource, params: URLSearchParams,
|
|
292
|
+
/**
|
|
293
|
+
* The largest page this caller may ask for.
|
|
294
|
+
*
|
|
295
|
+
* The cap exists to stop a request from the outside asking for ten thousand rows at
|
|
296
|
+
* once. Server-side callers that page through everything deliberately — the CSV
|
|
297
|
+
* export — pass their own, which is why this is an argument rather than a constant
|
|
298
|
+
* everything obeys.
|
|
299
|
+
*/
|
|
300
|
+
options?: {
|
|
301
|
+
maxPerPage?: number;
|
|
302
|
+
}): {
|
|
303
|
+
query: ListQuery;
|
|
304
|
+
} | {
|
|
305
|
+
issues: QueryIssue[];
|
|
306
|
+
};
|
|
307
|
+
|
|
301
308
|
/**
|
|
302
309
|
* {@link ResourceRows} over Prisma and Postgres — the Next.js stack's half.
|
|
303
310
|
*
|
|
@@ -327,7 +334,7 @@ interface PrismaDelegate {
|
|
|
327
334
|
update(args: any): Promise<any>;
|
|
328
335
|
delete(args: any): Promise<any>;
|
|
329
336
|
}
|
|
330
|
-
declare function prismaRows(delegate: PrismaDelegate, client: unknown): ResourceRows;
|
|
337
|
+
declare function prismaRows(delegate: PrismaDelegate, client: unknown, resource?: Resource): ResourceRows;
|
|
331
338
|
|
|
332
339
|
/**
|
|
333
340
|
* An OpenAPI 3.1 document for an app's generated REST API, built at request time
|
|
@@ -356,4 +363,4 @@ interface OpenApiOptions {
|
|
|
356
363
|
declare function fieldJsonSchema(def: StoredField): Schema;
|
|
357
364
|
declare function openApiDocument(options: OpenApiOptions): Schema;
|
|
358
365
|
|
|
359
|
-
export { type AnyDatabase, type Authorize, type AuthorizeContext, type ChangeEvent, type ConstraintHit, type Failure, type FieldIssue, type ListQuery, type ListResult, type OpenApiOptions, type PrismaDelegate, type QueryIssue, type ResourceAction, type ResourceHandlerOptions, type ResourceRows, type ResourceStore, type ResourceStoreOptions, type Result, type Row, type RowsQuery, createResourceHandlers, createResourceStore, drizzleRows, fieldJsonSchema, isFilterable, isSearchable, isSortable, openApiDocument, parseListQuery, prismaRows };
|
|
366
|
+
export { type AnyDatabase, type Authorize, type AuthorizeContext, type ChangeEvent, type ConstraintHit, type Failure, type FieldIssue, type ListQuery, type ListResult, type OpenApiOptions, type PrismaDelegate, ProblemCode, ProblemIssue, type QueryIssue, type ResourceAction, type ResourceHandlerOptions, type ResourceRows, type ResourceStore, type ResourceStoreOptions, type Result, type Row, type RowsQuery, createResourceHandlers, createResourceStore, drizzleRows, fieldJsonSchema, isFilterable, isSearchable, isSortable, openApiDocument, parseListQuery, prismaRows };
|