@flaredev/core 0.11.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-62JI5DCR.js → chunk-BTVEH5AU.js} +342 -8
- package/dist/chunk-I2HSIPCF.js +65 -0
- package/dist/index.d.ts +121 -5
- package/dist/index.js +71 -217
- package/dist/{policy-DaK3dWQ_.d.ts → policy-b4FXN3ZJ.d.ts} +147 -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
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// src/problem.ts
|
|
2
|
+
var PROBLEM_CODES = {
|
|
3
|
+
/** The body broke a field's rules. `issues` says which fields. */
|
|
4
|
+
validationFailed: "validation_failed",
|
|
5
|
+
/** A unique column already holds this value. `issues` names the column. */
|
|
6
|
+
uniqueViolation: "unique_violation",
|
|
7
|
+
/** Deleting this would orphan rows that point at it. */
|
|
8
|
+
stillReferenced: "still_referenced",
|
|
9
|
+
/** A relation field names a record that does not exist. */
|
|
10
|
+
relatedRecordMissing: "related_record_missing",
|
|
11
|
+
/** A value outside what its column accepts — an enum option that is not one. */
|
|
12
|
+
valueNotAllowed: "value_not_allowed",
|
|
13
|
+
/** No such record, or none this request may see. */
|
|
14
|
+
notFound: "not_found",
|
|
15
|
+
/** A query parameter is wrong. `issues` names the parameters. */
|
|
16
|
+
invalidQuery: "invalid_query",
|
|
17
|
+
/** The body was not JSON. */
|
|
18
|
+
invalidJson: "invalid_json",
|
|
19
|
+
/** The request did not say `Content-Type: application/json`. */
|
|
20
|
+
unsupportedMediaType: "unsupported_media_type",
|
|
21
|
+
/** A write from another origin: the CSRF guard. */
|
|
22
|
+
crossOriginBlocked: "cross_origin_blocked",
|
|
23
|
+
/** No session and no API key. */
|
|
24
|
+
unauthenticated: "unauthenticated",
|
|
25
|
+
/** Signed in, but this role may not do this. */
|
|
26
|
+
forbidden: "forbidden",
|
|
27
|
+
/** Refused by the security layer before the app saw it. */
|
|
28
|
+
accessDenied: "access_denied",
|
|
29
|
+
/** Too many requests. */
|
|
30
|
+
rateLimited: "rate_limited",
|
|
31
|
+
/** A signed storage URL that is missing, malformed, expired, or not for this operation. */
|
|
32
|
+
invalidToken: "invalid_token",
|
|
33
|
+
/** An upload past the limit the signed URL allows. */
|
|
34
|
+
payloadTooLarge: "payload_too_large",
|
|
35
|
+
/** An upload with no Content-Length, which cannot be checked against that limit. */
|
|
36
|
+
lengthRequired: "length_required",
|
|
37
|
+
/** A request that is wrong in a way none of the others describes. */
|
|
38
|
+
badRequest: "bad_request",
|
|
39
|
+
/** Something went wrong that the server does not explain. */
|
|
40
|
+
internal: "internal"
|
|
41
|
+
};
|
|
42
|
+
var ISSUE_CODES = {
|
|
43
|
+
/** Broke the field's own rules: a format, a length, a required value. */
|
|
44
|
+
invalid: "invalid",
|
|
45
|
+
/** A unique column already holds this. */
|
|
46
|
+
unique: "unique",
|
|
47
|
+
/** Set once and not changeable — an ownership column, for instance. */
|
|
48
|
+
immutable: "immutable",
|
|
49
|
+
/** A query parameter rather than a body field. */
|
|
50
|
+
invalidParameter: "invalid_parameter"
|
|
51
|
+
};
|
|
52
|
+
function wantsJson(request) {
|
|
53
|
+
const accept = request.headers.get("accept") ?? "";
|
|
54
|
+
if (/\bapplication\/json\b/i.test(accept)) return true;
|
|
55
|
+
if (/\btext\/html\b/i.test(accept)) return false;
|
|
56
|
+
return new URL(request.url).pathname.startsWith("/api/");
|
|
57
|
+
}
|
|
58
|
+
function problemResponse(status, error, code, issues, init) {
|
|
59
|
+
const body = issues?.length ? { error, code, issues } : { error, code };
|
|
60
|
+
return Response.json(body, { ...init, status });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export {
|
|
64
|
+
PROBLEM_CODES,
|
|
65
|
+
ISSUE_CODES,
|
|
66
|
+
wantsJson,
|
|
67
|
+
problemResponse
|
|
68
|
+
};
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parseRate
|
|
3
|
+
} from "./chunk-I2HSIPCF.js";
|
|
4
|
+
|
|
1
5
|
// src/resource/fields.ts
|
|
2
6
|
var STRING_FORMATS = [
|
|
3
7
|
"email",
|
|
@@ -160,13 +164,50 @@ var humanize = (value) => {
|
|
|
160
164
|
const text = words(value).join(" ");
|
|
161
165
|
return text ? text[0].toUpperCase() + text.slice(1) : text;
|
|
162
166
|
};
|
|
163
|
-
var UNCOUNTABLE = /* @__PURE__ */ new Set(["equipment", "information", "rice", "money", "species", "series", "fish", "sheep", "data", "news"]);
|
|
164
|
-
var IRREGULAR = {
|
|
167
|
+
var UNCOUNTABLE = /* @__PURE__ */ new Set(["equipment", "information", "rice", "money", "species", "series", "fish", "sheep", "data", "news", "staff"]);
|
|
168
|
+
var IRREGULAR = {
|
|
169
|
+
person: "people",
|
|
170
|
+
man: "men",
|
|
171
|
+
woman: "women",
|
|
172
|
+
child: "children",
|
|
173
|
+
mouse: "mice",
|
|
174
|
+
// -f / -fe → -ves
|
|
175
|
+
shelf: "shelves",
|
|
176
|
+
half: "halves",
|
|
177
|
+
knife: "knives",
|
|
178
|
+
life: "lives",
|
|
179
|
+
leaf: "leaves",
|
|
180
|
+
loaf: "loaves",
|
|
181
|
+
wolf: "wolves",
|
|
182
|
+
wife: "wives",
|
|
183
|
+
calf: "calves",
|
|
184
|
+
self: "selves",
|
|
185
|
+
thief: "thieves",
|
|
186
|
+
elf: "elves",
|
|
187
|
+
// Latin endings an app meets in a catalogue or a lab
|
|
188
|
+
index: "indexes",
|
|
189
|
+
matrix: "matrices",
|
|
190
|
+
vertex: "vertices",
|
|
191
|
+
appendix: "appendices",
|
|
192
|
+
analysis: "analyses",
|
|
193
|
+
diagnosis: "diagnoses",
|
|
194
|
+
thesis: "theses",
|
|
195
|
+
basis: "bases",
|
|
196
|
+
crisis: "crises",
|
|
197
|
+
criterion: "criteria",
|
|
198
|
+
curriculum: "curricula",
|
|
199
|
+
syllabus: "syllabuses",
|
|
200
|
+
alumnus: "alumni",
|
|
201
|
+
campus: "campuses",
|
|
202
|
+
status: "statuses",
|
|
203
|
+
quiz: "quizzes"
|
|
204
|
+
};
|
|
165
205
|
function pluralize(value) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
206
|
+
if (!/[A-Za-z]$/.test(value)) return value;
|
|
207
|
+
const parts = words(value);
|
|
208
|
+
const lower = parts[parts.length - 1];
|
|
209
|
+
if (!lower) return value;
|
|
210
|
+
const word = value.slice(value.length - lower.length);
|
|
170
211
|
const head = value.slice(0, value.length - word.length);
|
|
171
212
|
const keepCase = (plural) => word[0] === word[0].toUpperCase() ? plural[0].toUpperCase() + plural.slice(1) : plural;
|
|
172
213
|
if (UNCOUNTABLE.has(lower)) return value;
|
|
@@ -192,6 +233,17 @@ var ResourceDefinitionError = class extends Error {
|
|
|
192
233
|
}
|
|
193
234
|
};
|
|
194
235
|
var IDENTIFIER = /^[a-z][A-Za-z0-9]*$/;
|
|
236
|
+
function validateRateLimit(name, config) {
|
|
237
|
+
for (const [action, spec] of Object.entries(config)) {
|
|
238
|
+
if (typeof spec !== "string") continue;
|
|
239
|
+
try {
|
|
240
|
+
parseRate(spec);
|
|
241
|
+
} catch (error) {
|
|
242
|
+
throw new ResourceDefinitionError(name, `rateLimit.${action}: ${error instanceof Error ? error.message : String(error)}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return config;
|
|
246
|
+
}
|
|
195
247
|
function defaultFieldLabel(key, field2) {
|
|
196
248
|
return humanize(field2.kind === "belongsTo" ? key.replace(/Id$/, "") : key);
|
|
197
249
|
}
|
|
@@ -237,7 +289,7 @@ function defineResource(config) {
|
|
|
237
289
|
fail(`titleField "${config.titleField}" isn't a stored field.`);
|
|
238
290
|
}
|
|
239
291
|
const defaultSort = config.defaultSort ?? { field: "createdAt", direction: "desc" };
|
|
240
|
-
if (!["createdAt", "updatedAt"].includes(defaultSort.field) && !stored.some(([key]) => key === defaultSort.field)) {
|
|
292
|
+
if (!["id", "createdAt", "updatedAt"].includes(defaultSort.field) && !stored.some(([key]) => key === defaultSort.field)) {
|
|
241
293
|
fail(`defaultSort field "${defaultSort.field}" isn't a stored field.`);
|
|
242
294
|
}
|
|
243
295
|
const perPage = config.perPage ?? 25;
|
|
@@ -257,6 +309,9 @@ function defineResource(config) {
|
|
|
257
309
|
defaultSort,
|
|
258
310
|
perPage,
|
|
259
311
|
softDelete: config.softDelete === true,
|
|
312
|
+
// Parsed here rather than in the route, so a rate that cannot be read is an error at
|
|
313
|
+
// `flare gen` with the descriptor in front of you, not a surprise in production.
|
|
314
|
+
...config.rateLimit ? { rateLimit: validateRateLimit(config.name, config.rateLimit) } : {},
|
|
260
315
|
fields,
|
|
261
316
|
$types: void 0
|
|
262
317
|
};
|
|
@@ -286,6 +341,45 @@ function describeCondition(condition) {
|
|
|
286
341
|
return `${condition.field} ${condition.op === "eq" ? "is" : "is not"} ${values[0]}`;
|
|
287
342
|
}
|
|
288
343
|
|
|
344
|
+
// src/resource/enum-member.ts
|
|
345
|
+
var IDENTIFIER_CHAR = /[A-Za-z0-9_]/;
|
|
346
|
+
function prismaEnumMember(option) {
|
|
347
|
+
let member = "";
|
|
348
|
+
for (const char of option) {
|
|
349
|
+
if (IDENTIFIER_CHAR.test(char)) member += char;
|
|
350
|
+
else if (char === "-" || char === " ") member += "_";
|
|
351
|
+
else member += `_${char.codePointAt(0).toString(16)}`;
|
|
352
|
+
}
|
|
353
|
+
return /^\d/.test(member) ? `v_${member}` : member;
|
|
354
|
+
}
|
|
355
|
+
function enumMemberCollision(options) {
|
|
356
|
+
const seen = /* @__PURE__ */ new Map();
|
|
357
|
+
for (const option of options) {
|
|
358
|
+
const member = prismaEnumMember(option);
|
|
359
|
+
const first = seen.get(member);
|
|
360
|
+
if (first !== void 0) return { a: first, b: option, member };
|
|
361
|
+
seen.set(member, option);
|
|
362
|
+
}
|
|
363
|
+
return void 0;
|
|
364
|
+
}
|
|
365
|
+
function enumTranslations(fields) {
|
|
366
|
+
const out = {};
|
|
367
|
+
for (const [key, def] of fields) {
|
|
368
|
+
if (def.kind !== "enum") continue;
|
|
369
|
+
const options = def.options ?? [];
|
|
370
|
+
if (options.every((option) => prismaEnumMember(option) === option)) continue;
|
|
371
|
+
const toMember = {};
|
|
372
|
+
const toValue = {};
|
|
373
|
+
for (const option of options) {
|
|
374
|
+
const member = prismaEnumMember(option);
|
|
375
|
+
toMember[option] = member;
|
|
376
|
+
toValue[member] = option;
|
|
377
|
+
}
|
|
378
|
+
out[key] = { toMember, toValue };
|
|
379
|
+
}
|
|
380
|
+
return out;
|
|
381
|
+
}
|
|
382
|
+
|
|
289
383
|
// src/resource/formats.ts
|
|
290
384
|
import {
|
|
291
385
|
getCountries,
|
|
@@ -357,7 +451,27 @@ var isSlug = (value) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
|
|
|
357
451
|
function slugify(value) {
|
|
358
452
|
return value.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
359
453
|
}
|
|
360
|
-
|
|
454
|
+
function isIpv4(value) {
|
|
455
|
+
if (!/^(?:\d{1,3}\.){3}\d{1,3}$/.test(value)) return false;
|
|
456
|
+
return value.split(".").every((part) => {
|
|
457
|
+
const num = Number(part);
|
|
458
|
+
return num >= 0 && num <= 255;
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
function isIpv6(value) {
|
|
462
|
+
if (!value || value.includes(":::")) return false;
|
|
463
|
+
const doubleColonParts = value.split("::");
|
|
464
|
+
if (doubleColonParts.length > 2) return false;
|
|
465
|
+
if (doubleColonParts.length === 2) {
|
|
466
|
+
const left = doubleColonParts[0] ? doubleColonParts[0].split(":") : [];
|
|
467
|
+
const right = doubleColonParts[1] ? doubleColonParts[1].split(":") : [];
|
|
468
|
+
if (left.length + right.length >= 8) return false;
|
|
469
|
+
return [...left, ...right].every((p) => /^[0-9a-f]{1,4}$/i.test(p));
|
|
470
|
+
}
|
|
471
|
+
const parts = value.split(":");
|
|
472
|
+
return parts.length === 8 && parts.every((p) => /^[0-9a-f]{1,4}$/i.test(p));
|
|
473
|
+
}
|
|
474
|
+
var isIpAddress = (value) => isIpv4(value) || isIpv6(value);
|
|
361
475
|
var isUuid = (value) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
|
|
362
476
|
function isTimezone(value) {
|
|
363
477
|
try {
|
|
@@ -527,6 +641,211 @@ function createValidators(resource) {
|
|
|
527
641
|
};
|
|
528
642
|
}
|
|
529
643
|
|
|
644
|
+
// src/resource/relations.ts
|
|
645
|
+
var RelationError = class extends Error {
|
|
646
|
+
constructor(message) {
|
|
647
|
+
super(message);
|
|
648
|
+
this.name = "RelationError";
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
var relationNameFor = (table, key) => `${table}_${columnName(key)}`;
|
|
652
|
+
var AUTH_USER = "User";
|
|
653
|
+
var isAuthUserTarget = (target) => target === AUTH_USER;
|
|
654
|
+
var defaultAuthUserOnDelete = (required) => required ? "restrict" : "set null";
|
|
655
|
+
function relationGraph(resources) {
|
|
656
|
+
const byName = new Map(resources.map((resource) => [resource.name, resource]));
|
|
657
|
+
const graph = { byResource: {}, pending: [] };
|
|
658
|
+
for (const resource of resources) {
|
|
659
|
+
const relations = { belongsTo: [], hasMany: [] };
|
|
660
|
+
for (const [key, def] of storedFields(resource)) {
|
|
661
|
+
if (def.kind !== "belongsTo") continue;
|
|
662
|
+
const authUser = isAuthUserTarget(def.target);
|
|
663
|
+
if (!authUser && !byName.has(def.target)) {
|
|
664
|
+
throw new RelationError(`${resource.name}.${key} belongs to "${def.target}", but there is no ${def.target} resource.`);
|
|
665
|
+
}
|
|
666
|
+
relations.belongsTo.push({
|
|
667
|
+
key,
|
|
668
|
+
name: key.replace(/Id$/, ""),
|
|
669
|
+
target: def.target,
|
|
670
|
+
required: def.required,
|
|
671
|
+
onDelete: def.onDelete ?? (authUser ? defaultAuthUserOnDelete(def.required) : "restrict"),
|
|
672
|
+
relationName: relationNameFor(resource.table, key)
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
graph.byResource[resource.name] = relations;
|
|
676
|
+
}
|
|
677
|
+
for (const resource of resources) {
|
|
678
|
+
for (const [key, def] of Object.entries(resource.fields)) {
|
|
679
|
+
if (def.kind !== "hasMany") continue;
|
|
680
|
+
const target = byName.get(def.target);
|
|
681
|
+
if (!target) {
|
|
682
|
+
graph.pending.push({ resource: resource.name, key, target: def.target });
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
const foreignKey = def.foreignKey ?? `${camelCase(resource.name)}Id`;
|
|
686
|
+
const back = graph.byResource[target.name].belongsTo.find((relation) => relation.key === foreignKey);
|
|
687
|
+
if (!back || back.target !== resource.name) {
|
|
688
|
+
throw new RelationError(
|
|
689
|
+
`${resource.name}.${key} is hasMany(${target.name}), but ${target.name} has no ${foreignKey}: belongsTo(${resource.name}) field. Add it to ${target.name}, or set foreignKey to the field that points back.`
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
graph.byResource[resource.name].hasMany.push({ key, target: target.name, foreignKey, relationName: back.relationName });
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
for (const resource of resources) {
|
|
696
|
+
const { belongsTo, hasMany } = graph.byResource[resource.name];
|
|
697
|
+
const taken = new Set(Object.keys(resource.fields));
|
|
698
|
+
for (const { key, name } of belongsTo) {
|
|
699
|
+
if (taken.has(name)) {
|
|
700
|
+
throw new RelationError(`${resource.name}.${key} exposes relation "${name}", which collides with the ${resource.name}.${name} field. Rename one of them.`);
|
|
701
|
+
}
|
|
702
|
+
taken.add(name);
|
|
703
|
+
}
|
|
704
|
+
for (const { key } of hasMany) {
|
|
705
|
+
if (belongsTo.some((relation) => relation.name === key)) {
|
|
706
|
+
throw new RelationError(`${resource.name}.${key} collides with a belongsTo relation of the same name.`);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return graph;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// src/resource/display.ts
|
|
714
|
+
var TONES = [
|
|
715
|
+
["success", /^(paid|fulfilled|active|approved|completed?|delivered|done|published|resolved|shipped|succeeded|success|verified|won)$/],
|
|
716
|
+
["danger", /^(banned|blocked|cancel+ed|churned|declined|error|expired|failed|lost|overdue|refunded|rejected|suspended)$/],
|
|
717
|
+
["warning", /^(awaiting|draft|in[-_ ]?review|on[-_ ]?hold|pending|processing|review|trial|unpaid|unverified)$/]
|
|
718
|
+
];
|
|
719
|
+
function statusTone(value) {
|
|
720
|
+
if (typeof value !== "string") return "neutral";
|
|
721
|
+
const normalized = value.trim().toLowerCase();
|
|
722
|
+
return TONES.find(([, pattern]) => pattern.test(normalized))?.[0] ?? "neutral";
|
|
723
|
+
}
|
|
724
|
+
function optionLabel(def, value) {
|
|
725
|
+
if ((def.kind === "enum" || def.kind === "multiselect") && def.optionLabels?.[value]) return def.optionLabels[value];
|
|
726
|
+
const text = value.replace(/[_-]+/g, " ").trim();
|
|
727
|
+
return text ? text[0].toUpperCase() + text.slice(1) : value;
|
|
728
|
+
}
|
|
729
|
+
function formatValue(def, value, options = {}) {
|
|
730
|
+
if (value === null || value === void 0 || value === "") return "\u2014";
|
|
731
|
+
const locale = options.locale ?? "en-US";
|
|
732
|
+
switch (def.kind) {
|
|
733
|
+
case "boolean":
|
|
734
|
+
return value ? "Yes" : "No";
|
|
735
|
+
case "int":
|
|
736
|
+
return new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(Number(value));
|
|
737
|
+
case "float":
|
|
738
|
+
return new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(Number(value));
|
|
739
|
+
case "date": {
|
|
740
|
+
const date = /* @__PURE__ */ new Date(`${String(value)}T00:00:00Z`);
|
|
741
|
+
return Number.isNaN(date.getTime()) ? String(value) : new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeZone: "UTC" }).format(date);
|
|
742
|
+
}
|
|
743
|
+
case "datetime":
|
|
744
|
+
case "timestamp": {
|
|
745
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
746
|
+
return Number.isNaN(date.getTime()) ? String(value) : new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short", timeZone: options.timeZone ?? "UTC" }).format(date);
|
|
747
|
+
}
|
|
748
|
+
case "enum":
|
|
749
|
+
return optionLabel(def, String(value));
|
|
750
|
+
case "multiselect":
|
|
751
|
+
return Array.isArray(value) && value.length ? value.map((item) => optionLabel(def, String(item))).join(", ") : "\u2014";
|
|
752
|
+
case "tags":
|
|
753
|
+
return Array.isArray(value) && value.length ? value.map(String).join(", ") : "\u2014";
|
|
754
|
+
case "json": {
|
|
755
|
+
if (value === null || value === void 0) return "\u2014";
|
|
756
|
+
const text = typeof value === "string" ? value : JSON.stringify(value);
|
|
757
|
+
return text.length > 60 ? `${text.slice(0, 59)}\u2026` : text;
|
|
758
|
+
}
|
|
759
|
+
case "string":
|
|
760
|
+
if (def.format === "tel") return formatPhone(String(value));
|
|
761
|
+
if (def.format === "country") return `${flagEmoji(String(value))} ${countryName(String(value), locale.split("-")[0])}`.trim();
|
|
762
|
+
return String(value);
|
|
763
|
+
case "file":
|
|
764
|
+
return String(value).split("/").pop().replace(/^[0-9a-f-]{36}-/, "");
|
|
765
|
+
default:
|
|
766
|
+
return String(value);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// src/resource/form.ts
|
|
771
|
+
function initialFormValues(resource, record) {
|
|
772
|
+
const values = {};
|
|
773
|
+
for (const [key, def] of storedFields(resource)) {
|
|
774
|
+
const source = record ? record[key] : "default" in def ? def.default : void 0;
|
|
775
|
+
if (def.kind === "boolean") values[key] = source === true || source === 1;
|
|
776
|
+
else if (def.kind === "multiselect" || def.kind === "tags") values[key] = JSON.stringify(Array.isArray(source) ? source : []);
|
|
777
|
+
else if (def.kind === "json") values[key] = source === null || source === void 0 ? "" : JSON.stringify(source, null, 2);
|
|
778
|
+
else if (source === null || source === void 0) values[key] = "";
|
|
779
|
+
else if (source instanceof Date) values[key] = source.toISOString();
|
|
780
|
+
else values[key] = String(source);
|
|
781
|
+
}
|
|
782
|
+
return values;
|
|
783
|
+
}
|
|
784
|
+
function formValuesToInput(resource, values, mode) {
|
|
785
|
+
const input = {};
|
|
786
|
+
for (const [key, def] of storedFields(resource)) {
|
|
787
|
+
const raw = values[key];
|
|
788
|
+
if (def.kind === "boolean") {
|
|
789
|
+
input[key] = raw === true;
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
if (def.kind === "multiselect" || def.kind === "tags") {
|
|
793
|
+
const picked = parseMultiValue(raw);
|
|
794
|
+
if (picked.length === 0 && mode === "create" && "default" in def && def.default !== void 0) continue;
|
|
795
|
+
input[key] = picked.length === 0 && !def.required ? null : picked;
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
if (def.kind === "json") {
|
|
799
|
+
const source = typeof raw === "string" ? raw.trim() : "";
|
|
800
|
+
if (source === "") {
|
|
801
|
+
if (mode === "create" && "default" in def && def.default !== void 0) continue;
|
|
802
|
+
if (!def.required) input[key] = null;
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
try {
|
|
806
|
+
input[key] = JSON.parse(source);
|
|
807
|
+
} catch {
|
|
808
|
+
input[key] = Number.NaN;
|
|
809
|
+
}
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
const text = typeof raw === "string" ? raw : "";
|
|
813
|
+
const empty = text.trim() === "";
|
|
814
|
+
if (empty) {
|
|
815
|
+
const hasDefault = "default" in def && def.default !== void 0;
|
|
816
|
+
if (mode === "create" && hasDefault) continue;
|
|
817
|
+
if (!def.required) input[key] = null;
|
|
818
|
+
else if (def.kind === "string" || def.kind === "text") input[key] = text;
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
821
|
+
if (def.kind === "int" || def.kind === "float") {
|
|
822
|
+
const number = Number(text.trim());
|
|
823
|
+
input[key] = Number.isFinite(number) ? number : text;
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
input[key] = text;
|
|
827
|
+
}
|
|
828
|
+
return input;
|
|
829
|
+
}
|
|
830
|
+
function parseMultiValue(raw) {
|
|
831
|
+
if (typeof raw !== "string" || raw === "") return [];
|
|
832
|
+
try {
|
|
833
|
+
const parsed = JSON.parse(raw);
|
|
834
|
+
return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
|
|
835
|
+
} catch {
|
|
836
|
+
return [];
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
function issuesByField(issues) {
|
|
840
|
+
const errors = {};
|
|
841
|
+
for (const issue of issues) {
|
|
842
|
+
const path = Array.isArray(issue.path) ? issue.path.map(String).join(".") : issue.path;
|
|
843
|
+
const key = path.split(".")[0] || "_form";
|
|
844
|
+
errors[key] ??= issue.message;
|
|
845
|
+
}
|
|
846
|
+
return errors;
|
|
847
|
+
}
|
|
848
|
+
|
|
530
849
|
// src/resource/policy.ts
|
|
531
850
|
var ROLE = /^(\*|[a-z][a-z0-9_-]{0,31})$/;
|
|
532
851
|
var FIELD = /^[a-z][A-Za-z0-9]*$/;
|
|
@@ -618,6 +937,9 @@ export {
|
|
|
618
937
|
columnName,
|
|
619
938
|
conditionHolds,
|
|
620
939
|
describeCondition,
|
|
940
|
+
prismaEnumMember,
|
|
941
|
+
enumMemberCollision,
|
|
942
|
+
enumTranslations,
|
|
621
943
|
flagEmoji,
|
|
622
944
|
countryName,
|
|
623
945
|
countries,
|
|
@@ -634,6 +956,18 @@ export {
|
|
|
634
956
|
fieldSchema,
|
|
635
957
|
fileKeyPrefix,
|
|
636
958
|
createValidators,
|
|
959
|
+
RelationError,
|
|
960
|
+
AUTH_USER,
|
|
961
|
+
isAuthUserTarget,
|
|
962
|
+
defaultAuthUserOnDelete,
|
|
963
|
+
relationGraph,
|
|
964
|
+
statusTone,
|
|
965
|
+
optionLabel,
|
|
966
|
+
formatValue,
|
|
967
|
+
initialFormValues,
|
|
968
|
+
formValuesToInput,
|
|
969
|
+
parseMultiValue,
|
|
970
|
+
issuesByField,
|
|
637
971
|
PolicyError,
|
|
638
972
|
definePolicy,
|
|
639
973
|
can,
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// src/rate-limit.ts
|
|
2
|
+
var WINDOWS = {
|
|
3
|
+
s: 1,
|
|
4
|
+
sec: 1,
|
|
5
|
+
secs: 1,
|
|
6
|
+
second: 1,
|
|
7
|
+
seconds: 1,
|
|
8
|
+
m: 60,
|
|
9
|
+
min: 60,
|
|
10
|
+
mins: 60,
|
|
11
|
+
minute: 60,
|
|
12
|
+
minutes: 60,
|
|
13
|
+
h: 3600,
|
|
14
|
+
hr: 3600,
|
|
15
|
+
hour: 3600,
|
|
16
|
+
hours: 3600,
|
|
17
|
+
d: 86400,
|
|
18
|
+
day: 86400,
|
|
19
|
+
days: 86400
|
|
20
|
+
};
|
|
21
|
+
var RateSyntaxError = class extends Error {
|
|
22
|
+
constructor(message) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "RateSyntaxError";
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
function parseRate(spec) {
|
|
28
|
+
const match = /^\s*(\d+)\s*\/\s*(\d*)\s*([a-z]+)\s*$/i.exec(spec);
|
|
29
|
+
if (!match) {
|
|
30
|
+
throw new RateSyntaxError(`"${spec}" is not a rate. Write it as calls/window, e.g. "10/min", "5/s" or "30/5min".`);
|
|
31
|
+
}
|
|
32
|
+
const limit = Number(match[1]);
|
|
33
|
+
const count = match[2] === "" ? 1 : Number(match[2]);
|
|
34
|
+
const unit = WINDOWS[match[3].toLowerCase()];
|
|
35
|
+
if (unit === void 0) {
|
|
36
|
+
throw new RateSyntaxError(`"${spec}" uses an unknown window "${match[3]}". Use s, min, hour or day.`);
|
|
37
|
+
}
|
|
38
|
+
if (limit < 1) throw new RateSyntaxError(`"${spec}" allows no calls at all. To forbid an action, use a policy instead of a rate limit.`);
|
|
39
|
+
if (count < 1) throw new RateSyntaxError(`"${spec}" has a window of zero.`);
|
|
40
|
+
return { limit, seconds: count * unit };
|
|
41
|
+
}
|
|
42
|
+
function rateFor(config, action) {
|
|
43
|
+
if (!config) return void 0;
|
|
44
|
+
const spec = config[action] ?? config["*"];
|
|
45
|
+
return spec === void 0 ? void 0 : parseRate(spec);
|
|
46
|
+
}
|
|
47
|
+
function ratesIn(config) {
|
|
48
|
+
if (!config) return [];
|
|
49
|
+
return Object.entries(config).filter(([, spec]) => typeof spec === "string").map(([action, spec]) => ({ action, spec, rate: parseRate(spec) }));
|
|
50
|
+
}
|
|
51
|
+
var rateLimitKey = (resource, action, caller) => `${resource}:${action}:${caller}`;
|
|
52
|
+
async function fixedWindow(rate, increment) {
|
|
53
|
+
const { count, ttl } = await increment(rate.seconds);
|
|
54
|
+
const remaining = Math.max(0, rate.limit - count);
|
|
55
|
+
return { allowed: count <= rate.limit, remaining, retryAfter: Math.max(1, Math.ceil(ttl)) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export {
|
|
59
|
+
RateSyntaxError,
|
|
60
|
+
parseRate,
|
|
61
|
+
rateFor,
|
|
62
|
+
ratesIn,
|
|
63
|
+
rateLimitKey,
|
|
64
|
+
fixedWindow
|
|
65
|
+
};
|