@agilesyndrome/cf-genai-base 4.1.2 → 5.0.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/CONTRACT.md +24 -7
- package/README.md +59 -25
- package/changelog.md +15 -0
- package/migrations/0006_jobs.sql +38 -0
- package/package.json +15 -5
- package/src/admin/index.js +1 -0
- package/src/admin/routes.js +64 -0
- package/src/api/client.js +8 -0
- package/src/api/contracts.js +24 -0
- package/src/api/index.js +7 -0
- package/src/api/jobs.js +37 -0
- package/src/api/testing.js +13 -0
- package/src/app.js +5 -0
- package/src/auth/constants.js +9 -0
- package/src/auth/encoding.js +6 -0
- package/src/auth/groups.js +5 -0
- package/src/auth/impersonation.js +15 -0
- package/src/auth/index.js +7 -0
- package/src/auth/scopes.js +32 -0
- package/src/auth/subscriptions.js +25 -0
- package/src/auth/tenants.js +34 -0
- package/src/auth/users.js +31 -0
- package/src/{core.js → core/circuits.js} +51 -98
- package/src/core/constants.js +5 -0
- package/src/core/d1.js +54 -0
- package/src/core/event-hub.js +29 -0
- package/src/core/events.js +63 -0
- package/src/core/identity.js +39 -0
- package/src/core/index.js +7 -0
- package/src/core/jobs.js +185 -0
- package/src/core/security.js +71 -0
- package/src/data/context.js +16 -0
- package/src/data/index.js +3 -0
- package/src/data/reader.js +63 -0
- package/src/data/resources.js +42 -0
- package/src/index.js +10 -289
- package/src/repository.js +63 -0
- package/src/runtime/features.js +17 -0
- package/src/runtime/health.js +17 -0
- package/src/runtime/worker.js +45 -0
- package/src/ui/index.js +1 -87
- package/src/ui/react/admin-access.jsx +29 -0
- package/src/ui/react/admin-catalogs.jsx +39 -0
- package/src/ui/react/admin-shell.jsx +17 -0
- package/src/ui/react/foundation.jsx +40 -0
- package/src/ui/react/index.jsx +6 -0
- package/src/ui/react/jobs.jsx +63 -0
- package/src/ui/react/live-events.jsx +55 -0
- package/src/ui/styles.css +34 -0
- package/src/authorization.js +0 -188
- package/src/data.js +0 -239
- package/src/ui/groups.js +0 -2
package/src/core/jobs.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { Event, emitEvent } from "./events.js";
|
|
2
|
+
import { createD1 } from "./d1.js";
|
|
3
|
+
|
|
4
|
+
export const JOB_STATUSES = Object.freeze(["queued", "running", "succeeded", "failed", "cancelled"]);
|
|
5
|
+
const JOB_STATUS_SET = new Set(JOB_STATUSES);
|
|
6
|
+
const TERMINAL_STATUSES = new Set(["succeeded", "failed", "cancelled"]);
|
|
7
|
+
|
|
8
|
+
export async function createJob(env, definition = {}, { who = "system:update", ctx } = {}) {
|
|
9
|
+
const id = String(definition.id || crypto.randomUUID());
|
|
10
|
+
const type = required(definition.type, "Job type");
|
|
11
|
+
const status = normalizeStatus(definition.status || "queued");
|
|
12
|
+
const now = new Date().toISOString();
|
|
13
|
+
const db = createD1(env, { who });
|
|
14
|
+
await db.prepare(`
|
|
15
|
+
INSERT INTO core_jobs
|
|
16
|
+
(id, type, status, owner_id, tenant_id, resource_type, resource_id,
|
|
17
|
+
input_json, result_json, error_json, progress_json, created_at, updated_at, expires_at)
|
|
18
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, '{}', NULL, ?, ?, ?, ?)
|
|
19
|
+
`).bind(
|
|
20
|
+
id,
|
|
21
|
+
type,
|
|
22
|
+
status,
|
|
23
|
+
nullable(definition.ownerId),
|
|
24
|
+
nullable(definition.tenantId),
|
|
25
|
+
nullable(definition.resourceType),
|
|
26
|
+
nullable(definition.resourceId),
|
|
27
|
+
encode(definition.input),
|
|
28
|
+
encode(definition.progress),
|
|
29
|
+
now,
|
|
30
|
+
now,
|
|
31
|
+
nullable(definition.expiresAt),
|
|
32
|
+
).run();
|
|
33
|
+
const job = await getJob(env, id, { who });
|
|
34
|
+
await publishJobEvent(env, job, "job.created", who, ctx);
|
|
35
|
+
return job;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function getJob(env, id, { who = "system:read" } = {}) {
|
|
39
|
+
const row = await createD1(env, { who }).prepare("SELECT * FROM core_jobs WHERE id = ?").bind(String(id)).first();
|
|
40
|
+
return row ? normalizeJob(row) : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function listJobs(env, filters = {}, { who = "system:read" } = {}) {
|
|
44
|
+
const clauses = [];
|
|
45
|
+
const values = [];
|
|
46
|
+
if (filters.ownerId !== undefined) { clauses.push("owner_id = ?"); values.push(nullable(filters.ownerId)); }
|
|
47
|
+
if (filters.tenantId !== undefined) { clauses.push("tenant_id = ?"); values.push(nullable(filters.tenantId)); }
|
|
48
|
+
if (filters.type) { clauses.push("type = ?"); values.push(String(filters.type)); }
|
|
49
|
+
if (filters.status) { clauses.push("status = ?"); values.push(normalizeStatus(filters.status)); }
|
|
50
|
+
if (filters.resourceType) { clauses.push("resource_type = ?"); values.push(String(filters.resourceType)); }
|
|
51
|
+
if (filters.resourceId) { clauses.push("resource_id = ?"); values.push(String(filters.resourceId)); }
|
|
52
|
+
const limit = boundedLimit(filters.limit);
|
|
53
|
+
const sql = `SELECT * FROM core_jobs${clauses.length ? ` WHERE ${clauses.join(" AND ")}` : ""} ORDER BY updated_at DESC LIMIT ?`;
|
|
54
|
+
const result = await createD1(env, { who }).prepare(sql).bind(...values, limit).all();
|
|
55
|
+
return (result?.results || []).map(normalizeJob);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function listJobEvents(env, jobId, { who = "system:read", limit = 100 } = {}) {
|
|
59
|
+
const result = await createD1(env, { who }).prepare("SELECT * FROM core_job_events WHERE job_id = ? ORDER BY created_at ASC LIMIT ?").bind(String(jobId), boundedLimit(limit, 500)).all();
|
|
60
|
+
return (result?.results || []).map(normalizeJobEvent);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function startJob(env, id, options = {}) {
|
|
64
|
+
return updateJob(env, id, { status: "running", startedAt: new Date().toISOString() }, options);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function updateJobProgress(env, id, progress, options = {}) {
|
|
68
|
+
return updateJob(env, id, { progress }, options);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function completeJob(env, id, result = {}, options = {}) {
|
|
72
|
+
return updateJob(env, id, { status: "succeeded", result, finishedAt: new Date().toISOString() }, options);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function failJob(env, id, error, options = {}) {
|
|
76
|
+
const details = error instanceof Error ? { name: error.name, message: error.message, code: error.code || null } : error;
|
|
77
|
+
return updateJob(env, id, { status: "failed", error: details, finishedAt: new Date().toISOString() }, options);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function cancelJob(env, id, options = {}) {
|
|
81
|
+
return updateJob(env, id, { status: "cancelled", finishedAt: new Date().toISOString() }, options);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function dispatchJob(env, definition, { workflow, params = {}, retention, who = "system:update", ctx } = {}) {
|
|
85
|
+
if (!workflow || typeof workflow.create !== "function") throw new TypeError("A Workflow binding is required to dispatch a job");
|
|
86
|
+
const job = await createJob(env, definition, { who, ctx });
|
|
87
|
+
try {
|
|
88
|
+
await workflow.create({
|
|
89
|
+
id: job.id,
|
|
90
|
+
params: { ...params, jobId: job.id },
|
|
91
|
+
...(retention ? { retention } : {}),
|
|
92
|
+
});
|
|
93
|
+
return job;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
await failJob(env, job.id, error, { who, ctx });
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function executeJob(env, id, execute, { who = "system:update", ctx, toJobResult = identity } = {}) {
|
|
101
|
+
if (typeof execute !== "function") throw new TypeError("Job executor must be a function");
|
|
102
|
+
const running = await startJob(env, id, { who, ctx });
|
|
103
|
+
if (!running) throw new TypeError(`Unknown job: ${id}`);
|
|
104
|
+
const report = (progress) => updateJobProgress(env, id, progress, { who, ctx });
|
|
105
|
+
try {
|
|
106
|
+
const value = await execute({ job: running, report });
|
|
107
|
+
const result = await toJobResult(value);
|
|
108
|
+
const job = await completeJob(env, id, result === undefined ? {} : result, { who, ctx });
|
|
109
|
+
return { job, value };
|
|
110
|
+
} catch (error) {
|
|
111
|
+
await failJob(env, id, error, { who, ctx });
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function runJob(env, definition, execute, options = {}) {
|
|
117
|
+
const job = await createJob(env, definition, options);
|
|
118
|
+
return executeJob(env, job.id, execute, options);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function updateJob(env, id, patch = {}, { who = "system:update", ctx } = {}) {
|
|
122
|
+
const current = await getJob(env, id, { who });
|
|
123
|
+
if (!current) return null;
|
|
124
|
+
const nextStatus = patch.status === undefined ? current.status : normalizeStatus(patch.status);
|
|
125
|
+
const values = [];
|
|
126
|
+
const assignments = [];
|
|
127
|
+
const fields = [
|
|
128
|
+
["status", nextStatus],
|
|
129
|
+
["result_json", patch.result === undefined ? undefined : encode(patch.result)],
|
|
130
|
+
["error_json", patch.error === undefined ? undefined : (patch.error == null ? null : encode(patch.error))],
|
|
131
|
+
["progress_json", patch.progress === undefined ? undefined : encode(patch.progress)],
|
|
132
|
+
["started_at", patch.startedAt === undefined ? undefined : nullable(patch.startedAt)],
|
|
133
|
+
["finished_at", patch.finishedAt === undefined ? undefined : nullable(patch.finishedAt)],
|
|
134
|
+
["expires_at", patch.expiresAt === undefined ? undefined : nullable(patch.expiresAt)],
|
|
135
|
+
];
|
|
136
|
+
if (nextStatus === "running" && !current.startedAt && patch.startedAt === undefined) fields[4] = ["started_at", new Date().toISOString()];
|
|
137
|
+
if (TERMINAL_STATUSES.has(nextStatus) && !current.finishedAt && patch.finishedAt === undefined) fields[5] = ["finished_at", new Date().toISOString()];
|
|
138
|
+
for (const [column, value] of fields) { if (value !== undefined) { assignments.push(`${column} = ?`); values.push(value); } }
|
|
139
|
+
assignments.push("updated_at = ?"); values.push(new Date().toISOString(), String(id));
|
|
140
|
+
await createD1(env, { who }).prepare(`UPDATE core_jobs SET ${assignments.join(", ")} WHERE id = ?`).bind(...values).run();
|
|
141
|
+
const job = await getJob(env, id, { who });
|
|
142
|
+
await publishJobEvent(env, job, patch.progress !== undefined && patch.status === undefined ? "job.progress" : `job.${nextStatus}`, who, ctx);
|
|
143
|
+
return job;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function normalizeJob(row) {
|
|
147
|
+
return {
|
|
148
|
+
id: String(row.id),
|
|
149
|
+
type: String(row.type),
|
|
150
|
+
status: String(row.status),
|
|
151
|
+
ownerId: row.owner_id || null,
|
|
152
|
+
tenantId: row.tenant_id || null,
|
|
153
|
+
resourceType: row.resource_type || null,
|
|
154
|
+
resourceId: row.resource_id || null,
|
|
155
|
+
input: decode(row.input_json),
|
|
156
|
+
result: decode(row.result_json),
|
|
157
|
+
error: decode(row.error_json),
|
|
158
|
+
progress: decode(row.progress_json),
|
|
159
|
+
createdAt: row.created_at || null,
|
|
160
|
+
startedAt: row.started_at || null,
|
|
161
|
+
finishedAt: row.finished_at || null,
|
|
162
|
+
updatedAt: row.updated_at || null,
|
|
163
|
+
expiresAt: row.expires_at || null,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function normalizeJobEvent(row) {
|
|
168
|
+
return { id: String(row.id), jobId: String(row.job_id), type: String(row.type), payload: decode(row.payload_json), createdAt: row.created_at || null };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function publishJobEvent(env, job, type, who, ctx) {
|
|
172
|
+
const payload = { jobId: job.id, type: job.type, status: job.status, ownerId: job.ownerId, tenantId: job.tenantId, resource: job.resourceType && job.resourceId ? { type: job.resourceType, id: job.resourceId } : null, progress: job.progress, result: type === "job.succeeded" ? job.result : undefined, error: type === "job.failed" ? job.error : undefined };
|
|
173
|
+
const event = Event(who, type, "job", new Date(), { ...payload, audience: { userId: job.ownerId, tenantId: job.tenantId } });
|
|
174
|
+
const db = createD1(env, { who });
|
|
175
|
+
await db.prepare("INSERT INTO core_job_events (id, job_id, type, payload_json) VALUES (?, ?, ?, ?)").bind(event.id, job.id, type, encode(payload)).run();
|
|
176
|
+
await emitEvent(env, event, ctx);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function normalizeStatus(status) { const value = String(status).toLowerCase(); if (!JOB_STATUS_SET.has(value)) throw new TypeError(`Unsupported job status: ${status}`); return value; }
|
|
180
|
+
function required(value, label) { const normalized = String(value || "").trim(); if (!normalized) throw new TypeError(`${label} is required`); return normalized; }
|
|
181
|
+
function nullable(value) { return value === undefined || value === null || value === "" ? null : String(value); }
|
|
182
|
+
function encode(value) { return JSON.stringify(value === undefined ? {} : value); }
|
|
183
|
+
function decode(value) { if (value == null || value === "") return null; try { return JSON.parse(value); } catch { return null; } }
|
|
184
|
+
function boundedLimit(value, maximum = 100) { const normalized = Number(value); return Number.isFinite(normalized) && normalized > 0 ? Math.min(Math.floor(normalized), maximum) : Math.min(50, maximum); }
|
|
185
|
+
function identity(value) { return value; }
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export function sameOrigin(request, allowedOrigins = []) {
|
|
2
|
+
const origin = request?.headers?.get("Origin");
|
|
3
|
+
if (!origin) return false;
|
|
4
|
+
const requestOrigin = (() => { try { return new URL(request.url).origin; } catch { return ""; } })();
|
|
5
|
+
const allowed = new Set((Array.isArray(allowedOrigins) ? allowedOrigins : [allowedOrigins]).filter(Boolean).map(String));
|
|
6
|
+
if (!allowed.size && requestOrigin) allowed.add(requestOrigin);
|
|
7
|
+
const fetchSite = request.headers.get("Sec-Fetch-Site");
|
|
8
|
+
return allowed.has(origin) && (!fetchSite || fetchSite === "same-origin");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function readJsonClone(request, maxBytes = 64 * 1024) {
|
|
12
|
+
if (!(request.headers.get("Content-Type") || "").toLowerCase().startsWith("application/json")) return { error: secureJson({ error: "JSON request required" }, 415) };
|
|
13
|
+
const declared = Number(request.headers.get("Content-Length"));
|
|
14
|
+
if (Number.isFinite(declared) && declared > maxBytes) return { error: secureJson({ error: "Request body is too large" }, 413) };
|
|
15
|
+
try {
|
|
16
|
+
const value = await request.clone().json();
|
|
17
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Object required");
|
|
18
|
+
if (new TextEncoder().encode(JSON.stringify(value)).byteLength > maxBytes) return { error: secureJson({ error: "Request body is too large" }, 413) };
|
|
19
|
+
return { value };
|
|
20
|
+
} catch {
|
|
21
|
+
return { error: secureJson({ error: "Invalid JSON object request" }, 400) };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function readJson(request, maxBytes = 64 * 1024) {
|
|
26
|
+
if (!(request.headers.get("Content-Type") || "").toLowerCase().startsWith("application/json")) throw Object.assign(new Error("JSON request required"), { status: 415 });
|
|
27
|
+
const declared = Number(request.headers.get("Content-Length"));
|
|
28
|
+
if (Number.isFinite(declared) && declared > maxBytes) throw Object.assign(new Error("Request body is too large"), { status: 413 });
|
|
29
|
+
if (!request.body) throw Object.assign(new Error("Request body required"), { status: 400 });
|
|
30
|
+
try {
|
|
31
|
+
const value = await request.clone().json();
|
|
32
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Object required");
|
|
33
|
+
if (new TextEncoder().encode(JSON.stringify(value)).byteLength > maxBytes) throw Object.assign(new Error("Request body is too large"), { status: 413 });
|
|
34
|
+
return value;
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (error?.status) throw error;
|
|
37
|
+
throw Object.assign(new Error("Invalid JSON object request"), { status: 400 });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function secureJson(payload, status = 200, headers = {}) {
|
|
42
|
+
return secureResponse(Response.json(payload, { status, headers: { "Cache-Control": "no-store", ...headers } }));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function json(payload, status = 200, cache = "no-store") {
|
|
46
|
+
return secureJson(payload, status, { "Cache-Control": cache });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function secureText(text, status = 200, cache = "no-store") {
|
|
50
|
+
return secureResponse(new Response(text, { status, headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": cache } }));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function secureResponse(response) {
|
|
54
|
+
const headers = new Headers(response.headers);
|
|
55
|
+
headers.set("Content-Security-Policy", "default-src 'self'; script-src 'self'; worker-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests");
|
|
56
|
+
headers.set("X-Content-Type-Options", "nosniff");
|
|
57
|
+
headers.set("X-Frame-Options", "DENY");
|
|
58
|
+
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
59
|
+
headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()");
|
|
60
|
+
headers.set("Cross-Origin-Opener-Policy", "same-origin");
|
|
61
|
+
headers.set("Strict-Transport-Security", "max-age=31536000");
|
|
62
|
+
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function requireUser(identity, response = secureJson({ error: "Authentication is required." }, 401)) {
|
|
66
|
+
return identity ? null : response;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function requireAdmin(identity, response = secureJson({ error: "Administrator access is required." }, 403)) {
|
|
70
|
+
return identity?.is_admin || identity?.isAdmin ? null : response;
|
|
71
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ensureUser, getAuthorizationUser, listUserGrants, listUserTenants, verifyImpersonationToken } from "../auth/index.js";
|
|
2
|
+
|
|
3
|
+
export async function requestDataContext(env, { state = {}, request, publicTenantId = null } = {}) {
|
|
4
|
+
let authUser = state.authUser || (state.user ? await ensureUser(env, state.user, { who: `user:${state.user.sub || "unknown"}` }) : null);
|
|
5
|
+
const system = Boolean(state.user?.auth_strategy === "http_basic" || (authUser && authUser.is_admin));
|
|
6
|
+
if (!authUser) return { userId: null, tenantId: publicTenantId, publicTenantId, public: Boolean(publicTenantId), system: false };
|
|
7
|
+
const impersonation = system ? await verifyImpersonationToken(request?.headers?.get("X-CF-GenAI-Impersonation") || readCookie(request, "__Host-cfgenai_impersonation"), env) : null;
|
|
8
|
+
if (impersonation) { const targetUser = await getAuthorizationUser(env, impersonation.targetUserId, { who: `user:${impersonation.adminUserId}` }); if (targetUser) authUser = targetUser; }
|
|
9
|
+
const who = `user:${authUser.id}`;
|
|
10
|
+
const [tenants, grants] = await Promise.all([listUserTenants(env, authUser.id, { who }), listUserGrants(env, authUser.id, { who })]);
|
|
11
|
+
const requestedTenant = state.tenantId || request?.headers?.get("X-Tenant-ID") || null;
|
|
12
|
+
const tenant = requestedTenant ? tenants.find((item) => item.id === requestedTenant) : tenants.length === 1 ? tenants[0] : null;
|
|
13
|
+
return { userId: authUser.id, tenantId: tenant?.id || null, publicTenantId, public: false, system, scopes: grants.map((grant) => grant.scope_name), tenants, invalidTenant: Boolean(requestedTenant && !tenant), impersonated: Boolean(impersonation), impersonatedBy: impersonation?.adminUserId || null };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function readCookie(request, name) { const value = request?.headers?.get("Cookie") || ""; return value.split(";").map((part) => part.trim()).find((part) => part.startsWith(`${name}=`))?.slice(name.length + 1) || ""; }
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { auditLog, createD1 } from "../core/index.js";
|
|
2
|
+
import { DataScopeError, normalizeDataResources } from "./resources.js";
|
|
3
|
+
|
|
4
|
+
export function createDataReader(env, { resources = [], context } = {}) {
|
|
5
|
+
const registry = new Map(normalizeDataResources(resources).map((resource) => [resource.name, resource]));
|
|
6
|
+
const getContext = typeof context === "function" ? context : async () => context || {};
|
|
7
|
+
const scope = (requestedScope) => ({
|
|
8
|
+
list: (name, options) => readList(name, requestedScope, options), page: (name, options) => readPage(name, requestedScope, options), count: (name, options) => readCount(name, requestedScope, options),
|
|
9
|
+
get: async (name, id) => (await readList(name, requestedScope, { where: { [registry.get(name)?.idColumn || "id"]: id }, limit: 1 }))[0] || null,
|
|
10
|
+
insert: (name, values) => writeInsert(name, requestedScope, values), update: (name, id, changes) => writeUpdate(name, requestedScope, id, changes), updateWhere: (name, where, changes) => writeUpdateWhere(name, requestedScope, where, changes), delete: (name, id) => writeDelete(name, requestedScope, id), deleteWhere: (name, where) => writeDeleteWhere(name, requestedScope, where),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
async function readList(name, requestedScope, { where = {}, limit = 100, orderBy, offset = 0 } = {}) { return (await readPage(name, requestedScope, { where, limit, orderBy, offset })).rows; }
|
|
14
|
+
|
|
15
|
+
async function readPage(name, requestedScope, { where = {}, limit = 100, orderBy, cursor, offset = 0 } = {}) {
|
|
16
|
+
const resource = getResource(name); const actor = await getContext(); if (!isAllowed(resource, requestedScope, actor, "read")) return { rows: [], nextCursor: null };
|
|
17
|
+
const predicates = []; const bindings = []; addScopePredicate(resource, requestedScope, actor, predicates, bindings); addFilters(resource, where, predicates, bindings);
|
|
18
|
+
if (cursor !== undefined && cursor !== null) { predicates.push(`${quote(resource.idColumn)}>?`); bindings.push(cursor); }
|
|
19
|
+
const safeLimit = Math.min(Math.max(Number(limit) || 100, 1), 1000); const safeOffset = Math.max(Number(offset) || 0, 0);
|
|
20
|
+
let sql = `SELECT ${resource.readableColumns.map(quote).join(",")} FROM ${quote(resource.table)}${predicates.length ? ` WHERE ${predicates.join(" AND ")}` : ""} LIMIT ${safeLimit} OFFSET ${safeOffset}`;
|
|
21
|
+
if (orderBy) { const [column, direction = "ASC"] = String(orderBy).split(/\s+/, 2); if (!resource.orderableColumns.includes(column)) throw new TypeError(`Column ${column} cannot order ${resource.name}`); sql = sql.replace(` LIMIT ${safeLimit}`, ` ORDER BY ${quote(column)} ${direction.toUpperCase() === "DESC" ? "DESC" : "ASC"} LIMIT ${safeLimit}`); }
|
|
22
|
+
const result = await createD1(env, { who: actorLabel(actor) }).prepare(sql).bind(...bindings).all(); const rows = result.results || [];
|
|
23
|
+
return { rows, nextCursor: rows.length === safeLimit ? rows[rows.length - 1][resource.idColumn] : null };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function readCount(name, requestedScope, { where = {} } = {}) {
|
|
27
|
+
const resource = getResource(name); const actor = await getContext(); if (!isAllowed(resource, requestedScope, actor, "read")) return 0;
|
|
28
|
+
const predicates = []; const bindings = []; addScopePredicate(resource, requestedScope, actor, predicates, bindings); addFilters(resource, where, predicates, bindings);
|
|
29
|
+
const result = await createD1(env, { who: actorLabel(actor) }).prepare(`SELECT COUNT(*) AS count FROM ${quote(resource.table)}${predicates.length ? ` WHERE ${predicates.join(" AND ")}` : ""}`).bind(...bindings).first(); return Number(result?.count || 0);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function writeInsert(name, requestedScope, values = {}) {
|
|
33
|
+
const resource = getResource(name); const actor = await getContext(); assertWritable(resource, requestedScope, actor, "create"); const data = cleanWritableValues(resource, values, { includeId: true, includeOwnership: requestedScope === "system" }); assertColumnScopes(resource, actor, Object.keys(data)); addOwnedValue(resource, requestedScope, actor, data); const columns = Object.keys(data); if (!columns.length) throw new TypeError(`No writable values supplied for ${resource.name}`);
|
|
34
|
+
const result = await createD1(env, { who: actorLabel(actor) }).prepare(`INSERT INTO ${quote(resource.table)} (${columns.map(quote).join(",")}) VALUES (${columns.map(() => "?").join(",")})`).bind(...columns.map((column) => data[column])).run(); const insertedId = data[resource.idColumn] === undefined ? result?.meta?.last_row_id : data[resource.idColumn]; return insertedId === undefined ? data : (await readList(name, requestedScope, { where: { [resource.idColumn]: insertedId }, limit: 1 }))[0] || { ...data, [resource.idColumn]: insertedId };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function writeUpdate(name, requestedScope, id, changes = {}) {
|
|
38
|
+
const resource = getResource(name); const actor = await getContext(); assertWritable(resource, requestedScope, actor, "update"); const data = cleanWritableValues(resource, changes, { includeOwnership: requestedScope === "system" }); assertColumnScopes(resource, actor, Object.keys(data)); const columns = Object.keys(data); if (!columns.length) throw new TypeError(`No writable values supplied for ${resource.name}`);
|
|
39
|
+
const predicates = [`${quote(resource.idColumn)}=?`]; const bindings = [id]; addScopePredicate(resource, requestedScope, actor, predicates, bindings); await createD1(env, { who: actorLabel(actor) }).prepare(`UPDATE ${quote(resource.table)} SET ${columns.map((column) => `${quote(column)}=?`).join(",")} WHERE ${predicates.join(" AND ")}`).bind(...columns.map((column) => data[column]), ...bindings).run(); return (await readList(name, requestedScope, { where: { [resource.idColumn]: id }, limit: 1 }))[0] || null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function writeDelete(name, requestedScope, id) { const resource = getResource(name); const actor = await getContext(); assertWritable(resource, requestedScope, actor, "delete"); const predicates = [`${quote(resource.idColumn)}=?`]; const bindings = [id]; addScopePredicate(resource, requestedScope, actor, predicates, bindings); return createD1(env, { who: actorLabel(actor) }).prepare(`DELETE FROM ${quote(resource.table)} WHERE ${predicates.join(" AND ")}`).bind(...bindings).run(); }
|
|
43
|
+
|
|
44
|
+
async function writeUpdateWhere(name, requestedScope, where, changes) { const resource = getResource(name); const actor = await getContext(); assertWritable(resource, requestedScope, actor, "update"); const data = cleanWritableValues(resource, changes, { includeOwnership: requestedScope === "system" }); assertColumnScopes(resource, actor, Object.keys(data)); const columns = Object.keys(data); if (!columns.length) throw new TypeError(`No writable values supplied for ${resource.name}`); const predicates = []; const bindings = []; addScopePredicate(resource, requestedScope, actor, predicates, bindings); addFilters(resource, where, predicates, bindings); await createD1(env, { who: actorLabel(actor) }).prepare(`UPDATE ${quote(resource.table)} SET ${columns.map((column) => `${quote(column)}=?`).join(",")} WHERE ${predicates.join(" AND ")}`).bind(...columns.map((column) => data[column]), ...bindings).run(); return readCount(name, requestedScope, { where }); }
|
|
45
|
+
|
|
46
|
+
async function writeDeleteWhere(name, requestedScope, where) { const resource = getResource(name); const actor = await getContext(); assertWritable(resource, requestedScope, actor, "delete"); const predicates = []; const bindings = []; addScopePredicate(resource, requestedScope, actor, predicates, bindings); addFilters(resource, where, predicates, bindings); return createD1(env, { who: actorLabel(actor) }).prepare(`DELETE FROM ${quote(resource.table)} WHERE ${predicates.join(" AND ")}`).bind(...bindings).run(); }
|
|
47
|
+
function getResource(name) { const resource = registry.get(String(name)); if (!resource) throw new TypeError(`Unknown data resource: ${name}`); return resource; }
|
|
48
|
+
return { user: scope("user"), tenant: scope("tenant"), public: scope("public"), system: scope("system"), context: getContext, resources: [...registry.values()] };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isAllowed(resource, requestedScope, actor, operation) {
|
|
52
|
+
const allowed = requestedScope === "system" ? Boolean(actor.system) : requestedScope === resource.scope && (requestedScope === "user" ? Boolean(actor.userId) : requestedScope === "public" ? Boolean(actor.publicTenantId && operation === "read") : Boolean(actor.tenantId && (actor.userId || (actor.public && resource.publicRead))));
|
|
53
|
+
if (!allowed || !resource.operations.includes(operation)) { auditLog({ who: actorLabel(actor), operation: "deny", resource: `data:${resource.name}:${requestedScope}:${operation}` }); return false; }
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
function assertWritable(resource, requestedScope, actor, operation) { if (!isAllowed(resource, requestedScope, actor, operation)) throw new DataScopeError(`Data scope ${requestedScope} cannot ${operation} resource ${resource.name}`); }
|
|
57
|
+
function assertColumnScopes(resource, actor, columns) { if (actor.system) return; for (const column of columns) { const required = resource.columnScopes[column] || []; if (required.some((scope) => !actor.scopes?.includes(scope))) throw new DataScopeError(`Missing scope to write ${resource.name}.${column}`); } }
|
|
58
|
+
function addScopePredicate(resource, requestedScope, actor, predicates, bindings) { if (requestedScope === "user") { predicates.push(`${quote(resource.ownerColumn)}=?`); bindings.push(actor.userId); } if (requestedScope === "public") { predicates.push(`${quote(resource.tenantColumn)}=?`); bindings.push(actor.publicTenantId); } if (requestedScope === "tenant") { predicates.push(`${quote(resource.tenantColumn)}=?`); bindings.push(actor.tenantId); if (actor.public && resource.publicRead && typeof resource.publicRead === "object") { predicates.push(`${quote(resource.publicRead.column)}=?`); bindings.push(resource.publicRead.value); } } }
|
|
59
|
+
function addFilters(resource, where, predicates, bindings) { for (const [column, value] of Object.entries(where || {})) { if (!resource.filterableColumns.includes(column)) throw new TypeError(`Column ${column} cannot filter ${resource.name}`); if (value === null) predicates.push(`${quote(column)} IS NULL`); else { predicates.push(`${quote(column)}=?`); bindings.push(value); } } }
|
|
60
|
+
function cleanWritableValues(resource, values, { includeId = false, includeOwnership = false } = {}) { return Object.fromEntries(Object.entries(values || {}).filter(([column]) => (resource.writableColumns.includes(column) || (includeOwnership && [resource.ownerColumn, resource.tenantColumn].includes(column))) && (includeId || column !== resource.idColumn))); }
|
|
61
|
+
function addOwnedValue(resource, requestedScope, actor, data) { if (requestedScope === "user") data[resource.ownerColumn] = actor.userId; if (requestedScope === "tenant") data[resource.tenantColumn] = actor.tenantId; }
|
|
62
|
+
function actorLabel(actor) { return actor.system ? "system:data" : `user:${actor.userId || "unknown"}`; }
|
|
63
|
+
function quote(identifier) { return `"${identifier}"`; }
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export const DATA_SCOPES = ["user", "tenant", "public", "system"];
|
|
2
|
+
export const DATA_OPERATIONS = ["read", "create", "update", "delete"];
|
|
3
|
+
|
|
4
|
+
export class DataScopeError extends Error {
|
|
5
|
+
constructor(message) { super(message); this.name = "DataScopeError"; }
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function normalizeDataResources(resources = []) {
|
|
9
|
+
const names = new Set();
|
|
10
|
+
return resources.map((resource) => {
|
|
11
|
+
if (!resource || !/^[a-z][a-z0-9_-]*$/.test(String(resource.name || ""))) throw new TypeError("Data resources require a safe name");
|
|
12
|
+
if (!/^[a-z][a-z0-9_]*$/.test(String(resource.table || ""))) throw new TypeError(`Data resource ${resource.name} requires a safe table name`);
|
|
13
|
+
const name = String(resource.name);
|
|
14
|
+
if (names.has(name)) throw new TypeError(`Duplicate data resource: ${name}`);
|
|
15
|
+
names.add(name);
|
|
16
|
+
const scope = String(resource.scope || "").toLowerCase();
|
|
17
|
+
if (!DATA_SCOPES.includes(scope)) throw new TypeError(`Data resource ${resource.name} requires scope user, tenant, public, or system`);
|
|
18
|
+
const columns = [...new Set((resource.columns || []).map(String))];
|
|
19
|
+
if (!columns.length || columns.some((column) => !/^[a-z][a-z0-9_]*$/.test(column))) throw new TypeError(`Data resource ${resource.name} requires safe columns`);
|
|
20
|
+
const idColumn = String(resource.idColumn || "id");
|
|
21
|
+
if (!columns.includes(idColumn)) throw new TypeError(`Data resource ${resource.name} must include ${idColumn}`);
|
|
22
|
+
const ownerColumn = resource.ownerColumn ? String(resource.ownerColumn) : "user_id";
|
|
23
|
+
const tenantColumn = resource.tenantColumn ? String(resource.tenantColumn) : "tenant_id";
|
|
24
|
+
if (scope === "user" && !columns.includes(ownerColumn)) throw new TypeError(`User resource ${resource.name} must include ${ownerColumn}`);
|
|
25
|
+
if (["tenant", "public"].includes(scope) && !columns.includes(tenantColumn)) throw new TypeError(`${scope} resource ${resource.name} must include ${tenantColumn}`);
|
|
26
|
+
const filterableColumns = [...new Set((resource.filterableColumns || columns).map(String))];
|
|
27
|
+
const orderableColumns = [...new Set((resource.orderableColumns || columns).map(String))];
|
|
28
|
+
for (const column of [...filterableColumns, ...orderableColumns]) if (!columns.includes(column)) throw new TypeError(`Data resource ${resource.name} references an unselected column`);
|
|
29
|
+
const writableColumns = [...new Set((resource.writableColumns || []).map(String))].filter((column) => column !== ownerColumn && column !== tenantColumn);
|
|
30
|
+
if (writableColumns.some((column) => !columns.includes(column))) throw new TypeError(`Data resource ${resource.name} references an unwritable column`);
|
|
31
|
+
const columnScopes = Object.fromEntries(Object.entries(resource.columnScopes || {}).map(([column, scopes]) => [String(column), Array.isArray(scopes) ? scopes.map(String) : [String(scopes)]]));
|
|
32
|
+
if (Object.keys(columnScopes).some((column) => !columns.includes(column) || columnScopes[column].some((scope) => !/^[a-z0-9]+(?::[a-z0-9-]+)+$/.test(scope)))) throw new TypeError(`Data resource ${resource.name} has invalid column scopes`);
|
|
33
|
+
if (!Array.isArray(resource.readableColumns) || !resource.readableColumns.length) throw new TypeError(`Data resource ${resource.name} requires explicit readableColumns`);
|
|
34
|
+
const readableColumns = [...new Set(resource.readableColumns.map(String))];
|
|
35
|
+
if (readableColumns.some((column) => !columns.includes(column))) throw new TypeError(`Data resource ${resource.name} references an unreadable column`);
|
|
36
|
+
const operations = [...new Set((resource.operations || ["read", ...(writableColumns.length ? ["create", "update", "delete"] : [])]).map((operation) => String(operation).toLowerCase()))];
|
|
37
|
+
if (!operations.length || operations.some((operation) => !DATA_OPERATIONS.includes(operation)) || !operations.includes("read")) throw new TypeError(`Data resource ${name} has invalid operations`);
|
|
38
|
+
const publicRead = resource.publicRead && typeof resource.publicRead === "object" ? { column: String(resource.publicRead.column || ""), value: resource.publicRead.value } : Boolean(resource.publicRead);
|
|
39
|
+
if (publicRead && typeof publicRead === "object" && (!/^[a-z][a-z0-9_]*$/.test(publicRead.column) || !columns.includes(publicRead.column))) throw new TypeError(`Public resource ${resource.name} requires a selected visibility column`);
|
|
40
|
+
return { ...resource, name, table: String(resource.table), scope, columns, readableColumns, idColumn, ownerColumn, tenantColumn, filterableColumns, orderableColumns, writableColumns, columnScopes, operations, publicRead };
|
|
41
|
+
});
|
|
42
|
+
}
|