@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.
Files changed (52) hide show
  1. package/CONTRACT.md +24 -7
  2. package/README.md +59 -25
  3. package/changelog.md +15 -0
  4. package/migrations/0006_jobs.sql +38 -0
  5. package/package.json +15 -5
  6. package/src/admin/index.js +1 -0
  7. package/src/admin/routes.js +64 -0
  8. package/src/api/client.js +8 -0
  9. package/src/api/contracts.js +24 -0
  10. package/src/api/index.js +7 -0
  11. package/src/api/jobs.js +37 -0
  12. package/src/api/testing.js +13 -0
  13. package/src/app.js +5 -0
  14. package/src/auth/constants.js +9 -0
  15. package/src/auth/encoding.js +6 -0
  16. package/src/auth/groups.js +5 -0
  17. package/src/auth/impersonation.js +15 -0
  18. package/src/auth/index.js +7 -0
  19. package/src/auth/scopes.js +32 -0
  20. package/src/auth/subscriptions.js +25 -0
  21. package/src/auth/tenants.js +34 -0
  22. package/src/auth/users.js +31 -0
  23. package/src/{core.js → core/circuits.js} +51 -98
  24. package/src/core/constants.js +5 -0
  25. package/src/core/d1.js +54 -0
  26. package/src/core/event-hub.js +29 -0
  27. package/src/core/events.js +63 -0
  28. package/src/core/identity.js +39 -0
  29. package/src/core/index.js +7 -0
  30. package/src/core/jobs.js +185 -0
  31. package/src/core/security.js +71 -0
  32. package/src/data/context.js +16 -0
  33. package/src/data/index.js +3 -0
  34. package/src/data/reader.js +63 -0
  35. package/src/data/resources.js +42 -0
  36. package/src/index.js +10 -289
  37. package/src/repository.js +63 -0
  38. package/src/runtime/features.js +17 -0
  39. package/src/runtime/health.js +17 -0
  40. package/src/runtime/worker.js +45 -0
  41. package/src/ui/index.js +1 -87
  42. package/src/ui/react/admin-access.jsx +29 -0
  43. package/src/ui/react/admin-catalogs.jsx +39 -0
  44. package/src/ui/react/admin-shell.jsx +17 -0
  45. package/src/ui/react/foundation.jsx +40 -0
  46. package/src/ui/react/index.jsx +6 -0
  47. package/src/ui/react/jobs.jsx +63 -0
  48. package/src/ui/react/live-events.jsx +55 -0
  49. package/src/ui/styles.css +34 -0
  50. package/src/authorization.js +0 -188
  51. package/src/data.js +0 -239
  52. package/src/ui/groups.js +0 -2
package/src/data.js DELETED
@@ -1,239 +0,0 @@
1
- import { auditLog, createD1 } from "./core.js";
2
- import { ensureUser, getAuthorizationUser, listUserGrants, listUserTenants, verifyImpersonationToken } from "./authorization.js";
3
-
4
- export const DATA_SCOPES = ["user", "tenant", "system"];
5
- export const DATA_OPERATIONS = ["read", "create", "update", "delete"];
6
-
7
- export class DataScopeError extends Error {
8
- constructor(message) {
9
- super(message);
10
- this.name = "DataScopeError";
11
- }
12
- }
13
-
14
- export function normalizeDataResources(resources = []) {
15
- const names = new Set();
16
- return resources.map((resource) => {
17
- if (!resource || !/^[a-z][a-z0-9_-]*$/.test(String(resource.name || ""))) throw new TypeError("Data resources require a safe name");
18
- if (!/^[a-z][a-z0-9_]*$/.test(String(resource.table || ""))) throw new TypeError(`Data resource ${resource.name} requires a safe table name`);
19
- const name = String(resource.name);
20
- if (names.has(name)) throw new TypeError(`Duplicate data resource: ${name}`);
21
- names.add(name);
22
- const scope = String(resource.scope || "").toLowerCase();
23
- if (!DATA_SCOPES.includes(scope)) throw new TypeError(`Data resource ${resource.name} requires scope user, tenant, or system`);
24
- const columns = [...new Set((resource.columns || []).map(String))];
25
- if (!columns.length || columns.some((column) => !/^[a-z][a-z0-9_]*$/.test(column))) throw new TypeError(`Data resource ${resource.name} requires safe columns`);
26
- const idColumn = String(resource.idColumn || "id");
27
- if (!columns.includes(idColumn)) throw new TypeError(`Data resource ${resource.name} must include ${idColumn}`);
28
- const ownerColumn = resource.ownerColumn ? String(resource.ownerColumn) : "user_id";
29
- const tenantColumn = resource.tenantColumn ? String(resource.tenantColumn) : "tenant_id";
30
- if (scope === "user" && !columns.includes(ownerColumn)) throw new TypeError(`User resource ${resource.name} must include ${ownerColumn}`);
31
- if (scope === "tenant" && !columns.includes(tenantColumn)) throw new TypeError(`Tenant resource ${resource.name} must include ${tenantColumn}`);
32
- const filterableColumns = [...new Set((resource.filterableColumns || columns).map(String))];
33
- const orderableColumns = [...new Set((resource.orderableColumns || columns).map(String))];
34
- for (const column of [...filterableColumns, ...orderableColumns]) if (!columns.includes(column)) throw new TypeError(`Data resource ${resource.name} references an unselected column`);
35
- const writableColumns = [...new Set((resource.writableColumns || []).map(String))].filter((column) => column !== ownerColumn && column !== tenantColumn);
36
- if (writableColumns.some((column) => !columns.includes(column))) throw new TypeError(`Data resource ${resource.name} references an unwritable column`);
37
- const columnScopes = Object.fromEntries(Object.entries(resource.columnScopes || {}).map(([column, scopes]) => [String(column), Array.isArray(scopes) ? scopes.map(String) : [String(scopes)]]));
38
- 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`);
39
- if (!Array.isArray(resource.readableColumns) || !resource.readableColumns.length) throw new TypeError(`Data resource ${resource.name} requires explicit readableColumns`);
40
- const readableColumns = [...new Set(resource.readableColumns.map(String))];
41
- if (readableColumns.some((column) => !columns.includes(column))) throw new TypeError(`Data resource ${resource.name} references an unreadable column`);
42
- const operations = [...new Set((resource.operations || ["read", ...(writableColumns.length ? ["create", "update", "delete"] : [])]).map((operation) => String(operation).toLowerCase()))];
43
- if (!operations.length || operations.some((operation) => !DATA_OPERATIONS.includes(operation)) || !operations.includes("read")) throw new TypeError(`Data resource ${name} has invalid operations`);
44
- const publicRead = resource.publicRead && typeof resource.publicRead === "object" ? { column: String(resource.publicRead.column || ""), value: resource.publicRead.value } : Boolean(resource.publicRead);
45
- 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`);
46
- return { ...resource, name, table: String(resource.table), scope, columns, readableColumns, idColumn, ownerColumn, tenantColumn, filterableColumns, orderableColumns, writableColumns, columnScopes, operations, publicRead };
47
- });
48
- }
49
-
50
- export function createDataReader(env, { resources = [], context } = {}) {
51
- const registry = new Map(normalizeDataResources(resources).map((resource) => [resource.name, resource]));
52
- const getContext = typeof context === "function" ? context : async () => context || {};
53
- const scope = (requestedScope) => ({
54
- list: (name, options) => readList(name, requestedScope, options),
55
- page: (name, options) => readPage(name, requestedScope, options),
56
- count: (name, options) => readCount(name, requestedScope, options),
57
- get: async (name, id) => (await readList(name, requestedScope, { where: { [registry.get(name)?.idColumn || "id"]: id }, limit: 1 }))[0] || null,
58
- insert: (name, values) => writeInsert(name, requestedScope, values),
59
- update: (name, id, changes) => writeUpdate(name, requestedScope, id, changes),
60
- updateWhere: (name, where, changes) => writeUpdateWhere(name, requestedScope, where, changes),
61
- delete: (name, id) => writeDelete(name, requestedScope, id),
62
- deleteWhere: (name, where) => writeDeleteWhere(name, requestedScope, where),
63
- });
64
-
65
- async function readList(name, requestedScope, { where = {}, limit = 100, orderBy, offset = 0 } = {}) {
66
- return (await readPage(name, requestedScope, { where, limit, orderBy, offset })).rows;
67
- }
68
-
69
- async function readPage(name, requestedScope, { where = {}, limit = 100, orderBy, cursor, offset = 0 } = {}) {
70
- const resource = getResource(name);
71
- const actor = await getContext();
72
- if (!isAllowed(resource, requestedScope, actor, "read")) return { rows: [], nextCursor: null };
73
- const predicates = [];
74
- const bindings = [];
75
- addScopePredicate(resource, requestedScope, actor, predicates, bindings);
76
- addFilters(resource, where, predicates, bindings);
77
- if (cursor !== undefined && cursor !== null) { predicates.push(`${quote(resource.idColumn)}>?`); bindings.push(cursor); }
78
- const safeLimit = Math.min(Math.max(Number(limit) || 100, 1), 1000);
79
- const safeOffset = Math.max(Number(offset) || 0, 0);
80
- let sql = `SELECT ${resource.readableColumns.map(quote).join(",")} FROM ${quote(resource.table)}${predicates.length ? ` WHERE ${predicates.join(" AND ")}` : ""} LIMIT ${safeLimit} OFFSET ${safeOffset}`;
81
- if (orderBy) {
82
- const [column, direction = "ASC"] = String(orderBy).split(/\s+/, 2);
83
- if (!resource.orderableColumns.includes(column)) throw new TypeError(`Column ${column} cannot order ${resource.name}`);
84
- sql = sql.replace(` LIMIT ${safeLimit}`, ` ORDER BY ${quote(column)} ${direction.toUpperCase() === "DESC" ? "DESC" : "ASC"} LIMIT ${safeLimit}`);
85
- }
86
- const result = await createD1(env, { who: actorLabel(actor) }).prepare(sql).bind(...bindings).all();
87
- const rows = result.results || [];
88
- return { rows, nextCursor: rows.length === safeLimit ? rows[rows.length - 1][resource.idColumn] : null };
89
- }
90
-
91
- async function readCount(name, requestedScope, { where = {} } = {}) {
92
- const resource = getResource(name);
93
- const actor = await getContext();
94
- if (!isAllowed(resource, requestedScope, actor, "read")) return 0;
95
- const predicates = [];
96
- const bindings = [];
97
- addScopePredicate(resource, requestedScope, actor, predicates, bindings);
98
- addFilters(resource, where, predicates, bindings);
99
- 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();
100
- return Number(result?.count || 0);
101
- }
102
-
103
- async function writeInsert(name, requestedScope, values = {}) {
104
- const resource = getResource(name);
105
- const actor = await getContext();
106
- assertWritable(resource, requestedScope, actor, "create");
107
- const data = cleanWritableValues(resource, values, { includeId: true, includeOwnership: requestedScope === "system" });
108
- assertColumnScopes(resource, actor, Object.keys(data));
109
- addOwnedValue(resource, requestedScope, actor, data);
110
- const columns = Object.keys(data);
111
- if (!columns.length) throw new TypeError(`No writable values supplied for ${resource.name}`);
112
- 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();
113
- const insertedId = data[resource.idColumn] === undefined ? result?.meta?.last_row_id : data[resource.idColumn];
114
- return insertedId === undefined ? data : (await readList(name, requestedScope, { where: { [resource.idColumn]: insertedId }, limit: 1 }))[0] || { ...data, [resource.idColumn]: insertedId };
115
- }
116
-
117
- async function writeUpdate(name, requestedScope, id, changes = {}) {
118
- const resource = getResource(name);
119
- const actor = await getContext();
120
- assertWritable(resource, requestedScope, actor, "update");
121
- const data = cleanWritableValues(resource, changes, { includeOwnership: requestedScope === "system" });
122
- assertColumnScopes(resource, actor, Object.keys(data));
123
- const columns = Object.keys(data);
124
- if (!columns.length) throw new TypeError(`No writable values supplied for ${resource.name}`);
125
- const predicates = [`${quote(resource.idColumn)}=?`];
126
- const bindings = [id];
127
- addScopePredicate(resource, requestedScope, actor, predicates, bindings);
128
- 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();
129
- return (await readList(name, requestedScope, { where: { [resource.idColumn]: id }, limit: 1 }))[0] || null;
130
- }
131
-
132
- async function writeDelete(name, requestedScope, id) {
133
- const resource = getResource(name);
134
- const actor = await getContext();
135
- assertWritable(resource, requestedScope, actor, "delete");
136
- const predicates = [`${quote(resource.idColumn)}=?`];
137
- const bindings = [id];
138
- addScopePredicate(resource, requestedScope, actor, predicates, bindings);
139
- return createD1(env, { who: actorLabel(actor) }).prepare(`DELETE FROM ${quote(resource.table)} WHERE ${predicates.join(" AND ")}`).bind(...bindings).run();
140
- }
141
-
142
- async function writeUpdateWhere(name, requestedScope, where, changes) {
143
- const resource = getResource(name);
144
- const actor = await getContext();
145
- assertWritable(resource, requestedScope, actor, "update");
146
- const data = cleanWritableValues(resource, changes, { includeOwnership: requestedScope === "system" });
147
- assertColumnScopes(resource, actor, Object.keys(data));
148
- const columns = Object.keys(data);
149
- if (!columns.length) throw new TypeError(`No writable values supplied for ${resource.name}`);
150
- const predicates = [];
151
- const bindings = [];
152
- addScopePredicate(resource, requestedScope, actor, predicates, bindings);
153
- addFilters(resource, where, predicates, bindings);
154
- 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();
155
- return readCount(name, requestedScope, { where });
156
- }
157
-
158
- async function writeDeleteWhere(name, requestedScope, where) {
159
- const resource = getResource(name);
160
- const actor = await getContext();
161
- assertWritable(resource, requestedScope, actor, "delete");
162
- const predicates = [];
163
- const bindings = [];
164
- addScopePredicate(resource, requestedScope, actor, predicates, bindings);
165
- addFilters(resource, where, predicates, bindings);
166
- return createD1(env, { who: actorLabel(actor) }).prepare(`DELETE FROM ${quote(resource.table)} WHERE ${predicates.join(" AND ")}`).bind(...bindings).run();
167
- }
168
-
169
- function getResource(name) {
170
- const resource = registry.get(String(name));
171
- if (!resource) throw new TypeError(`Unknown data resource: ${name}`);
172
- return resource;
173
- }
174
-
175
- return { user: scope("user"), tenant: scope("tenant"), system: scope("system"), context: getContext, resources: [...registry.values()] };
176
- }
177
-
178
- export async function requestDataContext(env, { state = {}, request, publicTenantId = null } = {}) {
179
- let authUser = state.authUser || (state.user ? await ensureUser(env, state.user, { who: `user:${state.user.sub || "unknown"}` }) : null);
180
- const system = Boolean(state.user?.auth_strategy === "http_basic" || (authUser && authUser.is_admin));
181
- if (!authUser) return { userId: null, tenantId: publicTenantId, public: Boolean(publicTenantId), system: false };
182
- const impersonation = system ? await verifyImpersonationToken(request?.headers?.get("X-CF-GenAI-Impersonation") || readCookie(request, "__Host-cfgenai_impersonation"), env) : null;
183
- if (impersonation) {
184
- const targetUser = await getAuthorizationUser(env, impersonation.targetUserId, { who: `user:${impersonation.adminUserId}` });
185
- if (targetUser) authUser = targetUser;
186
- }
187
- const who = `user:${authUser.id}`;
188
- const [tenants, grants] = await Promise.all([listUserTenants(env, authUser.id, { who }), listUserGrants(env, authUser.id, { who })]);
189
- const requestedTenant = state.tenantId || request?.headers?.get("X-Tenant-ID") || null;
190
- const tenant = requestedTenant ? tenants.find((item) => item.id === requestedTenant) : tenants.length === 1 ? tenants[0] : null;
191
- return { userId: authUser.id, tenantId: tenant?.id || null, public: false, system, scopes: grants.map((grant) => grant.scope_name), tenants, invalidTenant: Boolean(requestedTenant && !tenant), impersonated: Boolean(impersonation), impersonatedBy: impersonation?.adminUserId || null };
192
- }
193
-
194
- function isAllowed(resource, requestedScope, actor, operation) {
195
- const allowed = requestedScope === "system" ? Boolean(actor.system) : requestedScope === resource.scope && (requestedScope === "user" ? Boolean(actor.userId) : Boolean(actor.tenantId && (actor.userId || (actor.public && resource.publicRead))));
196
- if (!allowed || !resource.operations.includes(operation)) {
197
- auditLog({ who: actorLabel(actor), operation: "deny", resource: `data:${resource.name}:${requestedScope}:${operation}` });
198
- return false;
199
- }
200
- return true;
201
- }
202
-
203
- function assertWritable(resource, requestedScope, actor, operation) {
204
- if (!isAllowed(resource, requestedScope, actor, operation)) throw new DataScopeError(`Data scope ${requestedScope} cannot ${operation} resource ${resource.name}`);
205
- }
206
-
207
- function assertColumnScopes(resource, actor, columns) {
208
- if (actor.system) return;
209
- for (const column of columns) {
210
- const required = resource.columnScopes[column] || [];
211
- if (required.some((scope) => !actor.scopes?.includes(scope))) throw new DataScopeError(`Missing scope to write ${resource.name}.${column}`);
212
- }
213
- }
214
-
215
- function addScopePredicate(resource, requestedScope, actor, predicates, bindings) {
216
- if (requestedScope === "user") { predicates.push(`${quote(resource.ownerColumn)}=?`); bindings.push(actor.userId); }
217
- 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); } }
218
- }
219
-
220
- function addFilters(resource, where, predicates, bindings) {
221
- for (const [column, value] of Object.entries(where || {})) {
222
- if (!resource.filterableColumns.includes(column)) throw new TypeError(`Column ${column} cannot filter ${resource.name}`);
223
- if (value === null) predicates.push(`${quote(column)} IS NULL`);
224
- else { predicates.push(`${quote(column)}=?`); bindings.push(value); }
225
- }
226
- }
227
-
228
- function cleanWritableValues(resource, values, { includeId = false, includeOwnership = false } = {}) {
229
- return Object.fromEntries(Object.entries(values || {}).filter(([column]) => (resource.writableColumns.includes(column) || (includeOwnership && [resource.ownerColumn, resource.tenantColumn].includes(column))) && (includeId || column !== resource.idColumn)));
230
- }
231
-
232
- function addOwnedValue(resource, requestedScope, actor, data) {
233
- if (requestedScope === "user") data[resource.ownerColumn] = actor.userId;
234
- if (requestedScope === "tenant") data[resource.tenantColumn] = actor.tenantId;
235
- }
236
-
237
- function actorLabel(actor) { return actor.system ? "system:data" : `user:${actor.userId || "unknown"}`; }
238
- function quote(identifier) { return `"${identifier}"`; }
239
- 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) || ""; }
package/src/ui/groups.js DELETED
@@ -1,2 +0,0 @@
1
- export class CfGroupCatalog extends HTMLElement { async connectedCallback() { this.attachShadow({ mode: "open" }).innerHTML = `<style>:host{display:block;color:var(--cf-ui-text,#20231f);font:15px/1.45 system-ui,sans-serif}.card{background:var(--cf-ui-bg,#fff);border:1px solid var(--cf-ui-border,#d8ddd5);border-radius:.6rem;padding:1rem}.status{color:var(--cf-ui-muted,#687067);min-height:1.4em}.list{display:grid;gap:.3rem}.list div{padding:.55rem 0;border-bottom:1px solid var(--cf-ui-border,#d8ddd5)}</style><section class="card"><h2>User groups</h2><p class="status">Loading</p><div class="list"></div></section>`; const response = await fetch("/api/admin/groups", { credentials: "same-origin" }); const groups = (await response.json()).groups || []; const list = this.shadowRoot.querySelector(".list"); list.replaceChildren(...groups.map((group) => { const row = document.createElement("div"); row.textContent = group.display_name + " — " + group.name; return row; })); this.shadowRoot.querySelector(".status").textContent = groups.length + " groups"; } }
2
- if (!customElements.get("cf-group-catalog")) customElements.define("cf-group-catalog", CfGroupCatalog);