@flaredev/core 0.1.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/LICENSE +21 -0
- package/README.md +18 -0
- package/dist/chunk-DARDHZPX.js +192 -0
- package/dist/chunk-KSVLQLJD.js +91 -0
- package/dist/chunk-LYQT4S47.js +248 -0
- package/dist/chunk-ZJPJGME5.js +40 -0
- package/dist/client.d.ts +66 -0
- package/dist/client.js +52 -0
- package/dist/define-D5CtCkOm.d.ts +232 -0
- package/dist/index.d.ts +438 -0
- package/dist/index.js +770 -0
- package/dist/react.d.ts +34 -0
- package/dist/react.js +95 -0
- package/dist/realtime/client.d.ts +82 -0
- package/dist/realtime/client.js +7 -0
- package/dist/realtime/server.d.ts +109 -0
- package/dist/realtime/server.js +200 -0
- package/dist/security/index.d.ts +159 -0
- package/dist/security/index.js +147 -0
- package/dist/security/worker.d.ts +104 -0
- package/dist/security/worker.js +116 -0
- package/dist/server.d.ts +156 -0
- package/dist/server.js +319 -0
- package/dist/types-B6Y73BgH.d.ts +54 -0
- package/package.json +100 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_DETECTORS,
|
|
3
|
+
clientIp,
|
|
4
|
+
defineSecurity,
|
|
5
|
+
detectorMatches,
|
|
6
|
+
isFlareRule,
|
|
7
|
+
isIp,
|
|
8
|
+
recordHit,
|
|
9
|
+
zoneRuleTag
|
|
10
|
+
} from "../chunk-KSVLQLJD.js";
|
|
11
|
+
|
|
12
|
+
// src/security/zone.ts
|
|
13
|
+
var API = "https://api.cloudflare.com/client/v4";
|
|
14
|
+
var CloudflareApiError = class extends Error {
|
|
15
|
+
constructor(status, message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.status = status;
|
|
18
|
+
this.name = "CloudflareApiError";
|
|
19
|
+
}
|
|
20
|
+
status;
|
|
21
|
+
};
|
|
22
|
+
function createZoneClient(options) {
|
|
23
|
+
const doFetch = options.fetch ?? fetch;
|
|
24
|
+
const zone = `${API}/zones/${options.zoneId}`;
|
|
25
|
+
async function call(url, init = {}) {
|
|
26
|
+
const response = await doFetch(url, {
|
|
27
|
+
...init,
|
|
28
|
+
headers: { authorization: `Bearer ${options.apiToken}`, "content-type": "application/json", ...init.headers }
|
|
29
|
+
});
|
|
30
|
+
const body = await response.json().catch(() => null);
|
|
31
|
+
if (!response.ok || body?.success === false) {
|
|
32
|
+
const message = body?.errors?.map((error) => error.message).join("; ") || `Cloudflare API ${response.status}`;
|
|
33
|
+
throw new CloudflareApiError(response.status, message);
|
|
34
|
+
}
|
|
35
|
+
return body?.result;
|
|
36
|
+
}
|
|
37
|
+
async function replacePhase(phase, ours) {
|
|
38
|
+
let existing = [];
|
|
39
|
+
try {
|
|
40
|
+
existing = (await call(`${zone}/rulesets/phases/${phase}/entrypoint`)).rules ?? [];
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (!(error instanceof CloudflareApiError && error.status === 404)) throw error;
|
|
43
|
+
}
|
|
44
|
+
const kept = existing.filter((rule) => !isFlareRule(options.app, rule.description)).map(({ id, ref, action, expression, description, enabled, action_parameters, ratelimit, logging }) => ({
|
|
45
|
+
id,
|
|
46
|
+
ref,
|
|
47
|
+
action,
|
|
48
|
+
expression,
|
|
49
|
+
description,
|
|
50
|
+
enabled,
|
|
51
|
+
action_parameters,
|
|
52
|
+
ratelimit,
|
|
53
|
+
logging
|
|
54
|
+
}));
|
|
55
|
+
await call(`${zone}/rulesets/phases/${phase}/entrypoint`, { method: "PUT", body: JSON.stringify({ rules: [...kept, ...ours] }) });
|
|
56
|
+
return { kept: kept.length, written: ours.length };
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
/** Provision this app's rate-limiting rules, leaving the zone's other rules alone. */
|
|
60
|
+
syncRateLimits(rules) {
|
|
61
|
+
return replacePhase(
|
|
62
|
+
"http_ratelimit",
|
|
63
|
+
rules.map((rule) => ({
|
|
64
|
+
action: "block",
|
|
65
|
+
expression: rule.expression,
|
|
66
|
+
description: zoneRuleTag(options.app, rule.name),
|
|
67
|
+
enabled: true,
|
|
68
|
+
ratelimit: {
|
|
69
|
+
characteristics: ["cf.colo.id", "ip.src"],
|
|
70
|
+
period: rule.periodSeconds,
|
|
71
|
+
requests_per_period: rule.requests,
|
|
72
|
+
mitigation_timeout: rule.mitigationSeconds
|
|
73
|
+
}
|
|
74
|
+
}))
|
|
75
|
+
);
|
|
76
|
+
},
|
|
77
|
+
/** Provision this app's WAF custom rules, leaving the zone's other rules alone. */
|
|
78
|
+
syncCustomRules(rules) {
|
|
79
|
+
return replacePhase(
|
|
80
|
+
"http_request_firewall_custom",
|
|
81
|
+
rules.map((rule) => ({ action: rule.action, expression: rule.expression, description: zoneRuleTag(options.app, rule.name), enabled: true }))
|
|
82
|
+
);
|
|
83
|
+
},
|
|
84
|
+
/** Block an IP at the edge for the whole zone. Idempotent. */
|
|
85
|
+
async ban(ip, reason) {
|
|
86
|
+
const existing = (await this.bans()).find((ban) => ban.ip === ip);
|
|
87
|
+
if (existing) return existing;
|
|
88
|
+
const rule = await call(`${zone}/firewall/access_rules/rules`, {
|
|
89
|
+
method: "POST",
|
|
90
|
+
body: JSON.stringify({ mode: "block", configuration: { target: ip.includes(":") ? "ip6" : "ip", value: ip }, notes: `${zoneRuleTag(options.app, "ban")} ${reason}`.slice(0, 250) })
|
|
91
|
+
});
|
|
92
|
+
return { id: rule.id, ip, notes: rule.notes, createdAt: rule.created_on };
|
|
93
|
+
},
|
|
94
|
+
/** Remove this app's ban on an IP (never a ban someone else added). */
|
|
95
|
+
async unban(ip) {
|
|
96
|
+
const ban = (await this.bans()).find((entry) => entry.ip === ip);
|
|
97
|
+
if (!ban) return false;
|
|
98
|
+
await call(`${zone}/firewall/access_rules/rules/${ban.id}`, { method: "DELETE" });
|
|
99
|
+
return true;
|
|
100
|
+
},
|
|
101
|
+
/** This app's active zone bans. */
|
|
102
|
+
async bans() {
|
|
103
|
+
const rules = await call(
|
|
104
|
+
`${zone}/firewall/access_rules/rules?mode=block&per_page=500`
|
|
105
|
+
);
|
|
106
|
+
return rules.filter((rule) => isFlareRule(options.app, rule.notes)).map((rule) => ({ id: rule.id, ip: rule.configuration.value, notes: rule.notes, createdAt: rule.created_on }));
|
|
107
|
+
},
|
|
108
|
+
/** Firewall events on the zone since `since`, grouped by action and source (for the dashboard). */
|
|
109
|
+
async firewallEvents(since) {
|
|
110
|
+
const query = `query FlareFirewall($zoneTag: string, $since: Time) {
|
|
111
|
+
viewer { zones(filter: { zoneTag: $zoneTag }) {
|
|
112
|
+
firewallEventsAdaptiveGroups(limit: 50, filter: { datetime_geq: $since }, orderBy: [count_DESC]) {
|
|
113
|
+
count
|
|
114
|
+
dimensions { action source description }
|
|
115
|
+
}
|
|
116
|
+
} }
|
|
117
|
+
}`;
|
|
118
|
+
const response = await doFetch(`${API}/graphql`, {
|
|
119
|
+
method: "POST",
|
|
120
|
+
headers: { authorization: `Bearer ${options.apiToken}`, "content-type": "application/json" },
|
|
121
|
+
body: JSON.stringify({ query, variables: { zoneTag: options.zoneId, since: since.toISOString() } })
|
|
122
|
+
});
|
|
123
|
+
const body = await response.json().catch(() => null);
|
|
124
|
+
if (!response.ok || body?.errors?.length) {
|
|
125
|
+
throw new CloudflareApiError(response.status, body?.errors?.map((error) => error.message).join("; ") || `GraphQL ${response.status}`);
|
|
126
|
+
}
|
|
127
|
+
return (body?.data?.viewer?.zones?.[0]?.firewallEventsAdaptiveGroups ?? []).map((group) => ({
|
|
128
|
+
count: group.count,
|
|
129
|
+
action: group.dimensions.action,
|
|
130
|
+
source: group.dimensions.source,
|
|
131
|
+
description: group.dimensions.description ?? null
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
export {
|
|
137
|
+
CloudflareApiError,
|
|
138
|
+
DEFAULT_DETECTORS,
|
|
139
|
+
clientIp,
|
|
140
|
+
createZoneClient,
|
|
141
|
+
defineSecurity,
|
|
142
|
+
detectorMatches,
|
|
143
|
+
isFlareRule,
|
|
144
|
+
isIp,
|
|
145
|
+
recordHit,
|
|
146
|
+
zoneRuleTag
|
|
147
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { DurableObject } from 'cloudflare:workers';
|
|
2
|
+
import { SecurityConfig, ZoneClient, Detector } from './index.js';
|
|
3
|
+
export { Severity } from './index.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The Worker layer of Flare security: runs in the app's `worker/index.ts` around
|
|
7
|
+
* every request, before any app code.
|
|
8
|
+
*
|
|
9
|
+
* - Refuses IPs on the ban list (KV, so a ban is a cheap edge read and expires by TTL).
|
|
10
|
+
* - Applies the Workers rate-limit binding to the configured paths.
|
|
11
|
+
* - Feeds responses to the detectors. Counting lives in `SecurityMonitor`, one Durable
|
|
12
|
+
* Object per IP, so counts are exact rather than eventually consistent; the hit that
|
|
13
|
+
* crosses a threshold bans the IP, logs a SecurityEvent, and (with a zone configured)
|
|
14
|
+
* adds a zone IP Access Rule.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The KV subset Flare uses for the ban list. */
|
|
18
|
+
interface BanStore {
|
|
19
|
+
get(key: string): Promise<string | null>;
|
|
20
|
+
put(key: string, value: string, options?: {
|
|
21
|
+
expirationTtl?: number;
|
|
22
|
+
metadata?: unknown;
|
|
23
|
+
}): Promise<void>;
|
|
24
|
+
delete(key: string): Promise<void>;
|
|
25
|
+
list(options: {
|
|
26
|
+
prefix: string;
|
|
27
|
+
cursor?: string;
|
|
28
|
+
}): Promise<{
|
|
29
|
+
keys: Array<{
|
|
30
|
+
name: string;
|
|
31
|
+
metadata?: unknown;
|
|
32
|
+
expiration?: number;
|
|
33
|
+
}>;
|
|
34
|
+
list_complete: boolean;
|
|
35
|
+
cursor?: string;
|
|
36
|
+
}>;
|
|
37
|
+
}
|
|
38
|
+
/** The Workers rate-limit binding. */
|
|
39
|
+
interface RateLimiter {
|
|
40
|
+
limit(options: {
|
|
41
|
+
key: string;
|
|
42
|
+
}): Promise<{
|
|
43
|
+
success: boolean;
|
|
44
|
+
}>;
|
|
45
|
+
}
|
|
46
|
+
/** The DurableObjectNamespace subset used to reach SecurityMonitor instances. */
|
|
47
|
+
interface MonitorNamespace {
|
|
48
|
+
idFromName(name: string): unknown;
|
|
49
|
+
get(id: never): unknown;
|
|
50
|
+
}
|
|
51
|
+
interface Ban {
|
|
52
|
+
ip: string;
|
|
53
|
+
reason: string;
|
|
54
|
+
detector: string | null;
|
|
55
|
+
createdAt: string;
|
|
56
|
+
/** When the ban lifts; null for a manual ban without an expiry. */
|
|
57
|
+
until: string | null;
|
|
58
|
+
}
|
|
59
|
+
interface SecurityTrip {
|
|
60
|
+
ip: string;
|
|
61
|
+
detector: Detector;
|
|
62
|
+
count: number;
|
|
63
|
+
path: string;
|
|
64
|
+
userAgent: string | null;
|
|
65
|
+
banned: boolean;
|
|
66
|
+
}
|
|
67
|
+
/** Per-IP counters. One instance per IP (idFromName(ip)), so increments are exact. */
|
|
68
|
+
declare class SecurityMonitor extends DurableObject {
|
|
69
|
+
/** Record one hit for a detector and say whether it crossed the threshold. */
|
|
70
|
+
hit(detector: string, windowSeconds: number, threshold: number): Promise<{
|
|
71
|
+
count: number;
|
|
72
|
+
tripped: boolean;
|
|
73
|
+
}>;
|
|
74
|
+
/** Count a request refused because this IP is banned. */
|
|
75
|
+
blocked(): Promise<number>;
|
|
76
|
+
/** Requests refused since the IP was banned. */
|
|
77
|
+
blockedCount(): Promise<number>;
|
|
78
|
+
/** Forget this IP's counters (on unban). */
|
|
79
|
+
reset(): Promise<void>;
|
|
80
|
+
}
|
|
81
|
+
interface GuardOptions {
|
|
82
|
+
config: SecurityConfig;
|
|
83
|
+
bans: BanStore;
|
|
84
|
+
monitors: MonitorNamespace;
|
|
85
|
+
rateLimiter?: RateLimiter;
|
|
86
|
+
/** The zone layer, when this app has a zone and token configured. */
|
|
87
|
+
zone?: ZoneClient | null;
|
|
88
|
+
/** Log a tripped detector (the app writes a SecurityEvent row). */
|
|
89
|
+
onTrip?: (trip: SecurityTrip) => Promise<void>;
|
|
90
|
+
}
|
|
91
|
+
interface WaitUntil {
|
|
92
|
+
waitUntil(promise: Promise<unknown>): void;
|
|
93
|
+
}
|
|
94
|
+
declare function createSecurity(options: GuardOptions): {
|
|
95
|
+
protect: (request: Request, ctx: WaitUntil, next: () => Promise<Response>) => Promise<Response>;
|
|
96
|
+
ban: (ip: string, reason: string, seconds: number | null, detector: string | null) => Promise<Ban>;
|
|
97
|
+
unban: (ip: string) => Promise<void>;
|
|
98
|
+
bans: () => Promise<Array<Ban & {
|
|
99
|
+
blocked: number;
|
|
100
|
+
}>>;
|
|
101
|
+
};
|
|
102
|
+
type Security = ReturnType<typeof createSecurity>;
|
|
103
|
+
|
|
104
|
+
export { type Ban, type BanStore, Detector, type GuardOptions, type MonitorNamespace, type RateLimiter, type Security, SecurityConfig, SecurityMonitor, type SecurityTrip, type WaitUntil, createSecurity };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clientIp,
|
|
3
|
+
detectorMatches,
|
|
4
|
+
recordHit
|
|
5
|
+
} from "../chunk-KSVLQLJD.js";
|
|
6
|
+
|
|
7
|
+
// src/security/worker.ts
|
|
8
|
+
import { DurableObject } from "cloudflare:workers";
|
|
9
|
+
var BAN_PREFIX = "ban:";
|
|
10
|
+
var SecurityMonitor = class extends DurableObject {
|
|
11
|
+
/** Record one hit for a detector and say whether it crossed the threshold. */
|
|
12
|
+
async hit(detector, windowSeconds, threshold) {
|
|
13
|
+
const key = `hits:${detector}`;
|
|
14
|
+
const previous = await this.ctx.storage.get(key) ?? [];
|
|
15
|
+
const { hits, tripped } = recordHit(previous, Date.now(), windowSeconds, threshold);
|
|
16
|
+
await this.ctx.storage.put(key, hits);
|
|
17
|
+
return { count: hits.length, tripped };
|
|
18
|
+
}
|
|
19
|
+
/** Count a request refused because this IP is banned. */
|
|
20
|
+
async blocked() {
|
|
21
|
+
const count = (await this.ctx.storage.get("blocked") ?? 0) + 1;
|
|
22
|
+
await this.ctx.storage.put("blocked", count);
|
|
23
|
+
return count;
|
|
24
|
+
}
|
|
25
|
+
/** Requests refused since the IP was banned. */
|
|
26
|
+
async blockedCount() {
|
|
27
|
+
return await this.ctx.storage.get("blocked") ?? 0;
|
|
28
|
+
}
|
|
29
|
+
/** Forget this IP's counters (on unban). */
|
|
30
|
+
async reset() {
|
|
31
|
+
await this.ctx.storage.deleteAll();
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
function createSecurity(options) {
|
|
35
|
+
const { config, bans } = options;
|
|
36
|
+
const allowed = new Set(config.allow ?? []);
|
|
37
|
+
const monitor = (ip) => options.monitors.get(options.monitors.idFromName(ip));
|
|
38
|
+
const detectors = config.detectors ?? [];
|
|
39
|
+
const recentBans = /* @__PURE__ */ new Map();
|
|
40
|
+
const RECENT_MS = 6e4;
|
|
41
|
+
async function ban(ip, reason, seconds, detector) {
|
|
42
|
+
const record = {
|
|
43
|
+
ip,
|
|
44
|
+
reason,
|
|
45
|
+
detector,
|
|
46
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
47
|
+
until: seconds ? new Date(Date.now() + seconds * 1e3).toISOString() : null
|
|
48
|
+
};
|
|
49
|
+
await bans.put(`${BAN_PREFIX}${ip}`, JSON.stringify(record), { ...seconds ? { expirationTtl: seconds } : {}, metadata: record });
|
|
50
|
+
recentBans.set(ip, Date.now() + Math.min((seconds ?? Infinity) * 1e3, RECENT_MS));
|
|
51
|
+
if (options.zone) await options.zone.ban(ip, reason);
|
|
52
|
+
return record;
|
|
53
|
+
}
|
|
54
|
+
async function unban(ip) {
|
|
55
|
+
await bans.delete(`${BAN_PREFIX}${ip}`);
|
|
56
|
+
recentBans.delete(ip);
|
|
57
|
+
await monitor(ip).reset();
|
|
58
|
+
if (options.zone) await options.zone.unban(ip);
|
|
59
|
+
}
|
|
60
|
+
async function listBans() {
|
|
61
|
+
const found = [];
|
|
62
|
+
let cursor;
|
|
63
|
+
do {
|
|
64
|
+
const page = await bans.list({ prefix: BAN_PREFIX, cursor });
|
|
65
|
+
for (const key of page.keys) if (key.metadata) found.push(key.metadata);
|
|
66
|
+
cursor = page.list_complete ? void 0 : page.cursor;
|
|
67
|
+
} while (cursor);
|
|
68
|
+
return Promise.all(found.map(async (entry) => ({ ...entry, blocked: await monitor(entry.ip).blockedCount() })));
|
|
69
|
+
}
|
|
70
|
+
async function inspect(request, response, ip) {
|
|
71
|
+
for (const detector of detectors) {
|
|
72
|
+
if (!detectorMatches(detector, request, response.status)) continue;
|
|
73
|
+
const { count, tripped } = await monitor(ip).hit(detector.name, detector.windowSeconds, detector.threshold);
|
|
74
|
+
if (!tripped) continue;
|
|
75
|
+
const reason = `${detector.description ?? detector.name}: ${count} in ${detector.windowSeconds}s`;
|
|
76
|
+
if (detector.ban) {
|
|
77
|
+
await ban(ip, reason, detector.ban.seconds, detector.name).catch((error) => console.error("[flare] zone ban failed:", error));
|
|
78
|
+
}
|
|
79
|
+
await options.onTrip?.({
|
|
80
|
+
ip,
|
|
81
|
+
detector,
|
|
82
|
+
count,
|
|
83
|
+
path: new URL(request.url).pathname,
|
|
84
|
+
userAgent: request.headers.get("user-agent"),
|
|
85
|
+
banned: Boolean(detector.ban)
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function protect(request, ctx, next) {
|
|
90
|
+
const ip = clientIp(request);
|
|
91
|
+
if (!ip || allowed.has(ip)) return next();
|
|
92
|
+
const remembered = recentBans.get(ip);
|
|
93
|
+
if (remembered !== void 0 && remembered <= Date.now()) recentBans.delete(ip);
|
|
94
|
+
if (remembered !== void 0 && remembered > Date.now() || await bans.get(`${BAN_PREFIX}${ip}`)) {
|
|
95
|
+
ctx.waitUntil(monitor(ip).blocked());
|
|
96
|
+
return new Response("Access denied.", { status: 403, headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" } });
|
|
97
|
+
}
|
|
98
|
+
if (options.rateLimiter && config.rateLimit?.paths.test(new URL(request.url).pathname)) {
|
|
99
|
+
const { success } = await options.rateLimiter.limit({ key: ip });
|
|
100
|
+
if (!success) {
|
|
101
|
+
return new Response("Too many requests. Slow down and try again shortly.", {
|
|
102
|
+
status: 429,
|
|
103
|
+
headers: { "content-type": "text/plain; charset=utf-8", "retry-after": "60", "cache-control": "no-store" }
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const response = await next();
|
|
108
|
+
if (detectors.length) ctx.waitUntil(inspect(request, response, ip).catch((error) => console.error("[flare] security detector failed:", error)));
|
|
109
|
+
return response;
|
|
110
|
+
}
|
|
111
|
+
return { protect, ban, unban, bans: listBans };
|
|
112
|
+
}
|
|
113
|
+
export {
|
|
114
|
+
SecurityMonitor,
|
|
115
|
+
createSecurity
|
|
116
|
+
};
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import * as drizzle_orm_sqlite_core from 'drizzle-orm/sqlite-core';
|
|
2
|
+
import { BaseSQLiteDatabase, SQLiteTable } from 'drizzle-orm/sqlite-core';
|
|
3
|
+
import { S as StoredField, R as Resource, F as Field } from './define-D5CtCkOm.js';
|
|
4
|
+
|
|
5
|
+
interface ListQuery {
|
|
6
|
+
page: number;
|
|
7
|
+
perPage: number;
|
|
8
|
+
sort: {
|
|
9
|
+
field: string;
|
|
10
|
+
direction: "asc" | "desc";
|
|
11
|
+
};
|
|
12
|
+
q?: string;
|
|
13
|
+
/** Field key → coerced value; `null` means IS NULL. */
|
|
14
|
+
filters: Record<string, string | number | boolean | null>;
|
|
15
|
+
}
|
|
16
|
+
interface QueryIssue {
|
|
17
|
+
param: string;
|
|
18
|
+
message: string;
|
|
19
|
+
}
|
|
20
|
+
declare function isSortable(def: StoredField): boolean;
|
|
21
|
+
declare function isFilterable(def: StoredField): boolean;
|
|
22
|
+
declare function isSearchable(def: StoredField): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Parse `?page=2&perPage=50&sort=-createdAt&q=ada&filter[status]=lead` against a
|
|
25
|
+
* resource. Only fields the descriptor marks sortable/filterable/searchable are
|
|
26
|
+
* accepted, so clients can't probe arbitrary columns.
|
|
27
|
+
*/
|
|
28
|
+
declare function parseListQuery(resource: Resource, params: URLSearchParams): {
|
|
29
|
+
query: ListQuery;
|
|
30
|
+
} | {
|
|
31
|
+
issues: QueryIssue[];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type AnyDatabase = BaseSQLiteDatabase<"sync" | "async", any, any>;
|
|
35
|
+
type ResourceAction = "list" | "read" | "create" | "update" | "delete";
|
|
36
|
+
interface FieldIssue {
|
|
37
|
+
/** Dotted field path; "" for the whole body (e.g. unknown keys). */
|
|
38
|
+
path: string;
|
|
39
|
+
message: string;
|
|
40
|
+
}
|
|
41
|
+
type Failure = {
|
|
42
|
+
ok: false;
|
|
43
|
+
status: 400 | 404 | 409 | 422;
|
|
44
|
+
error: string;
|
|
45
|
+
/** Validation issues per field. */
|
|
46
|
+
issues?: FieldIssue[];
|
|
47
|
+
/** Invalid query parameters. */
|
|
48
|
+
queryIssues?: QueryIssue[];
|
|
49
|
+
/** Field that caused a conflict (e.g. a unique violation). */
|
|
50
|
+
field?: string;
|
|
51
|
+
};
|
|
52
|
+
type Result<T> = {
|
|
53
|
+
ok: true;
|
|
54
|
+
data: T;
|
|
55
|
+
} | Failure;
|
|
56
|
+
interface ListResult<T = Record<string, unknown>> {
|
|
57
|
+
data: T[];
|
|
58
|
+
meta: {
|
|
59
|
+
page: number;
|
|
60
|
+
perPage: number;
|
|
61
|
+
total: number;
|
|
62
|
+
totalPages: number;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
interface ChangeEvent {
|
|
66
|
+
/** Resource name, e.g. "Deal". */
|
|
67
|
+
resource: string;
|
|
68
|
+
action: "create" | "update" | "delete";
|
|
69
|
+
id: string;
|
|
70
|
+
}
|
|
71
|
+
interface ResourceStoreOptions {
|
|
72
|
+
resource: Resource;
|
|
73
|
+
table: SQLiteTable;
|
|
74
|
+
getDb: () => AnyDatabase;
|
|
75
|
+
/**
|
|
76
|
+
* Called after a write succeeds, for cache invalidation. Runs before the
|
|
77
|
+
* operation returns, so a caller that revalidates tags can't hand back a
|
|
78
|
+
* response the cache would then contradict. Failures are logged, not thrown:
|
|
79
|
+
* the write already happened.
|
|
80
|
+
*/
|
|
81
|
+
onChange?: (event: ChangeEvent) => void | Promise<void>;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* CRUD operations for one resource, driven by its descriptor. Shared by the REST
|
|
85
|
+
* handlers and the admin's server actions, so validation, constraint handling, and
|
|
86
|
+
* search behave identically everywhere. Operations never throw for expected failures;
|
|
87
|
+
* they return `{ ok: false, status, error }`.
|
|
88
|
+
*/
|
|
89
|
+
declare function createResourceStore(options: ResourceStoreOptions): {
|
|
90
|
+
resource: Resource<Record<string, Field>>;
|
|
91
|
+
table: SQLiteTable<drizzle_orm_sqlite_core.TableConfig>;
|
|
92
|
+
/** `params`: page, perPage, sort, q, filter[field] (as parsed by parseListQuery). */
|
|
93
|
+
list(params: URLSearchParams): Promise<Result<ListResult>>;
|
|
94
|
+
get(id: string): Promise<Result<Record<string, unknown>>>;
|
|
95
|
+
/** Title-field values for a set of ids (for showing relations). Missing ids are omitted. */
|
|
96
|
+
titles(ids: string[]): Promise<Record<string, string>>;
|
|
97
|
+
create(input: unknown): Promise<Result<Record<string, unknown>>>;
|
|
98
|
+
/** Partial update (PATCH semantics). */
|
|
99
|
+
update(id: string, input: unknown): Promise<Result<Record<string, unknown>>>;
|
|
100
|
+
/** Full replacement (PUT semantics): omitted fields go back to their default, or null when optional. */
|
|
101
|
+
replace(id: string, input: unknown): Promise<Result<Record<string, unknown>>>;
|
|
102
|
+
delete(id: string): Promise<Result<{
|
|
103
|
+
id: string;
|
|
104
|
+
}>>;
|
|
105
|
+
};
|
|
106
|
+
type ResourceStore = ReturnType<typeof createResourceStore>;
|
|
107
|
+
|
|
108
|
+
interface AuthorizeContext {
|
|
109
|
+
request: Request;
|
|
110
|
+
resource: Resource;
|
|
111
|
+
action: ResourceAction;
|
|
112
|
+
id?: string;
|
|
113
|
+
}
|
|
114
|
+
/** Return a Response (e.g. 401/403) to deny the request; return nothing to allow it. */
|
|
115
|
+
type Authorize = (context: AuthorizeContext) => Response | void | Promise<Response | void>;
|
|
116
|
+
interface ResourceHandlerOptions {
|
|
117
|
+
resource: Resource;
|
|
118
|
+
table: SQLiteTable;
|
|
119
|
+
getDb: () => AnyDatabase;
|
|
120
|
+
authorize: Authorize;
|
|
121
|
+
/** Called after a write succeeds, for cache invalidation. */
|
|
122
|
+
onChange?: (event: ChangeEvent) => void | Promise<void>;
|
|
123
|
+
}
|
|
124
|
+
type RouteContext = {
|
|
125
|
+
params: Promise<{
|
|
126
|
+
id: string;
|
|
127
|
+
}>;
|
|
128
|
+
};
|
|
129
|
+
/** REST route handlers for a resource: thin HTTP wrappers over `createResourceStore`. */
|
|
130
|
+
declare function createResourceHandlers(options: ResourceHandlerOptions): {
|
|
131
|
+
collection: {
|
|
132
|
+
GET: (request: Request) => Promise<Response>;
|
|
133
|
+
POST: (request: Request) => Promise<Response>;
|
|
134
|
+
};
|
|
135
|
+
item: {
|
|
136
|
+
GET: (request: Request, context: RouteContext) => Promise<Response>;
|
|
137
|
+
PATCH: (request: Request, context: RouteContext) => Promise<Response>;
|
|
138
|
+
PUT: (request: Request, context: RouteContext) => Promise<Response>;
|
|
139
|
+
DELETE: (request: Request, context: RouteContext) => Promise<Response>;
|
|
140
|
+
};
|
|
141
|
+
store: {
|
|
142
|
+
resource: Resource<Record<string, Field>>;
|
|
143
|
+
table: SQLiteTable<drizzle_orm_sqlite_core.TableConfig>;
|
|
144
|
+
list(params: URLSearchParams): Promise<Result<ListResult>>;
|
|
145
|
+
get(id: string): Promise<Result<Record<string, unknown>>>;
|
|
146
|
+
titles(ids: string[]): Promise<Record<string, string>>;
|
|
147
|
+
create(input: unknown): Promise<Result<Record<string, unknown>>>;
|
|
148
|
+
update(id: string, input: unknown): Promise<Result<Record<string, unknown>>>;
|
|
149
|
+
replace(id: string, input: unknown): Promise<Result<Record<string, unknown>>>;
|
|
150
|
+
delete(id: string): Promise<Result<{
|
|
151
|
+
id: string;
|
|
152
|
+
}>>;
|
|
153
|
+
};
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export { type AnyDatabase, type Authorize, type AuthorizeContext, type ChangeEvent, type Failure, type FieldIssue, type ListQuery, type ListResult, type QueryIssue, type ResourceAction, type ResourceHandlerOptions, type ResourceStore, type ResourceStoreOptions, type Result, createResourceHandlers, createResourceStore, isFilterable, isSearchable, isSortable, parseListQuery };
|