@agilesyndrome/cf-genai-base 4.1.0 → 4.1.2

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 CHANGED
@@ -4,7 +4,7 @@ Every site built from this foundation follows the same edge contract.
4
4
 
5
5
  ## Worker entrypoint
6
6
 
7
- `createWorker({ fetch, features?, middleware?, auth?, authorize?, scheduled?, security? })` owns the Worker lifecycle and reserved admin boundary. Features run in declaration order and may call `next()` or return a response. The site router owns pages, APIs, D1 queries, and R2 object keys. `scheduled`
7
+ `createWorker({ fetch, features?, middleware?, auth?, authorize?, scheduled?, security? })` owns the Worker lifecycle and reserved admin boundary. Features run in declaration order and may call `next()` or return a response. A feature may also declare `{ routes: [{ match, handle }] }`; matching handlers receive `{ request, env, ctx, state, next }` and run before the site handler. The site router owns pages, APIs, D1 queries, and R2 object keys. `scheduled`
8
8
  is optional and must use `ctx.waitUntil` for background work.
9
9
 
10
10
  ## Routes
@@ -68,7 +68,8 @@ updates and deletes. Anonymous tenant reads require an explicit worker
68
68
  adds a row visibility predicate.
69
69
  Request handlers receive `state.data`, whose scope-specific readers apply the
70
70
  validated user or tenant predicate. Domain handlers must not use unrestricted
71
- `env.DB` for registered resources. Base cannot provide row-level security to
71
+ `readableColumns` list the only columns returned by reads; callers must declare
72
+ them explicitly. Base cannot provide row-level security to
72
73
  direct D1 calls, so applications must keep raw database access out of domain
73
74
  features. The cookbook migration must add and backfill `tenant_id` on recipe
74
75
  tables, register recipes as tenant-scoped, replace direct D1 reads/writes with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agilesyndrome/cf-genai-base",
3
- "version": "4.1.0",
3
+ "version": "4.1.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.js",
@@ -32,13 +32,14 @@ export async function ensureUser(env, user, { who = "system:read" } = {}) {
32
32
  const email = String(user.email || "").trim().toLowerCase();
33
33
  const existing = await db.prepare(`SELECT * FROM ${AUTH_USER_TABLE} WHERE provider=? AND subject=?`).bind(provider, subject).first();
34
34
  const bootstrap = new Set(String(env.AUTH_ADMIN_EMAILS || env.ADMIN_EMAILS || "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean));
35
+ const bootstrapAdmin = user.email_verified === true && bootstrap.has(email);
35
36
  if (existing) {
36
- await db.prepare(`UPDATE ${AUTH_USER_TABLE} SET email=?,display_name=?,is_admin=CASE WHEN is_admin=1 OR ? THEN 1 ELSE 0 END,updated_at=CURRENT_TIMESTAMP WHERE id=?`).bind(email, String(user.name || email || subject), bootstrap.has(email) ? 1 : 0, existing.id).run();
37
+ await db.prepare(`UPDATE ${AUTH_USER_TABLE} SET email=?,display_name=?,is_admin=CASE WHEN is_admin=1 OR ? THEN 1 ELSE 0 END,updated_at=CURRENT_TIMESTAMP WHERE id=?`).bind(email, String(user.name || email || subject), bootstrapAdmin ? 1 : 0, existing.id).run();
37
38
  await ensureDefaultTenantMembership(db, existing.id);
38
- return { ...existing, email, display_name: String(user.name || email || subject), is_admin: Boolean(existing.is_admin || bootstrap.has(email)) };
39
+ return { ...existing, email, display_name: String(user.name || email || subject), is_admin: Boolean(existing.is_admin || bootstrapAdmin) };
39
40
  }
40
41
  const id = await stableId(`${provider}:${subject}`);
41
- await db.prepare(`INSERT INTO ${AUTH_USER_TABLE} (id,provider,subject,email,display_name,is_admin) VALUES (?,?,?,?,?,?) ON CONFLICT(provider,subject) DO NOTHING`).bind(id, provider, subject, email, String(user.name || email || subject), bootstrap.has(email) ? 1 : 0).run();
42
+ await db.prepare(`INSERT INTO ${AUTH_USER_TABLE} (id,provider,subject,email,display_name,is_admin) VALUES (?,?,?,?,?,?) ON CONFLICT(provider,subject) DO NOTHING`).bind(id, provider, subject, email, String(user.name || email || subject), bootstrapAdmin ? 1 : 0).run();
42
43
  await ensureDefaultTenantMembership(db, id);
43
44
  return await db.prepare(`SELECT * FROM ${AUTH_USER_TABLE} WHERE id=?`).bind(id).first();
44
45
  }
package/src/core.js CHANGED
@@ -2,7 +2,7 @@ export const HEALTHCHECK_STATES = ["red", "yellow", "green"];
2
2
  export const CIRCUIT_BREAKER_STATES = ["off", "tripped", "on"];
3
3
  export const HEALTHCHECK_MODES = ["any", "all"];
4
4
  export const BASE_PACKAGE_NAME = "@agilesyndrome/cf-genai-base";
5
- export const BASE_VERSION = "1.0.7";
5
+ export const BASE_VERSION = "4.1.1";
6
6
 
7
7
  export function eventLog(level, event, details = {}) {
8
8
  const method = ["debug", "info", "warn", "error"].includes(level) ? level : "info";
package/src/data.js CHANGED
@@ -36,11 +36,14 @@ export function normalizeDataResources(resources = []) {
36
36
  if (writableColumns.some((column) => !columns.includes(column))) throw new TypeError(`Data resource ${resource.name} references an unwritable column`);
37
37
  const columnScopes = Object.fromEntries(Object.entries(resource.columnScopes || {}).map(([column, scopes]) => [String(column), Array.isArray(scopes) ? scopes.map(String) : [String(scopes)]]));
38
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`);
39
42
  const operations = [...new Set((resource.operations || ["read", ...(writableColumns.length ? ["create", "update", "delete"] : [])]).map((operation) => String(operation).toLowerCase()))];
40
43
  if (!operations.length || operations.some((operation) => !DATA_OPERATIONS.includes(operation)) || !operations.includes("read")) throw new TypeError(`Data resource ${name} has invalid operations`);
41
44
  const publicRead = resource.publicRead && typeof resource.publicRead === "object" ? { column: String(resource.publicRead.column || ""), value: resource.publicRead.value } : Boolean(resource.publicRead);
42
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`);
43
- return { ...resource, name, table: String(resource.table), scope, columns, idColumn, ownerColumn, tenantColumn, filterableColumns, orderableColumns, writableColumns, columnScopes, operations, publicRead };
46
+ return { ...resource, name, table: String(resource.table), scope, columns, readableColumns, idColumn, ownerColumn, tenantColumn, filterableColumns, orderableColumns, writableColumns, columnScopes, operations, publicRead };
44
47
  });
45
48
  }
46
49
 
@@ -74,7 +77,7 @@ export function createDataReader(env, { resources = [], context } = {}) {
74
77
  if (cursor !== undefined && cursor !== null) { predicates.push(`${quote(resource.idColumn)}>?`); bindings.push(cursor); }
75
78
  const safeLimit = Math.min(Math.max(Number(limit) || 100, 1), 1000);
76
79
  const safeOffset = Math.max(Number(offset) || 0, 0);
77
- let sql = `SELECT ${resource.columns.map(quote).join(",")} FROM ${quote(resource.table)}${predicates.length ? ` WHERE ${predicates.join(" AND ")}` : ""} LIMIT ${safeLimit} OFFSET ${safeOffset}`;
80
+ let sql = `SELECT ${resource.readableColumns.map(quote).join(",")} FROM ${quote(resource.table)}${predicates.length ? ` WHERE ${predicates.join(" AND ")}` : ""} LIMIT ${safeLimit} OFFSET ${safeOffset}`;
78
81
  if (orderBy) {
79
82
  const [column, direction = "ASC"] = String(orderBy).split(/\s+/, 2);
80
83
  if (!resource.orderableColumns.includes(column)) throw new TypeError(`Column ${column} cannot order ${resource.name}`);
package/src/index.js CHANGED
@@ -8,13 +8,44 @@ import { createDataReader, DataScopeError, normalizeDataResources, requestDataCo
8
8
  export * from "./core.js";
9
9
  export * from "./data.js";
10
10
  export * from "./authorization.js";
11
+ const featureRegistrationPromises = new WeakMap();
12
+
13
+ export function ensureFeatureManifests(env, features = [], { who = "system:update" } = {}) {
14
+ if (!env || typeof env !== "object" || !env.DB) return Promise.resolve();
15
+ const key = features.map((feature) => String(feature?.name || feature?.id || "feature")).join("|");
16
+ let registrations = featureRegistrationPromises.get(env.DB);
17
+ if (!registrations) {
18
+ registrations = new Map();
19
+ featureRegistrationPromises.set(env.DB, registrations);
20
+ }
21
+ let promise = registrations.get(key);
22
+ if (!promise) {
23
+ promise = (async () => {
24
+ await registerFeatureManifests(env, features, { who });
25
+ const breakers = await listCircuitBreakers(env, { who });
26
+ await Promise.all(breakers.filter(Boolean).map((breaker) => evaluateCircuitBreaker(env, breaker.id, { who })));
27
+ })();
28
+ registrations.set(key, promise);
29
+ promise.catch(() => registrations.delete(key));
30
+ }
31
+ return promise;
32
+ }
33
+
11
34
  export function createWorker({ fetch, scheduled, auth, authorize, scopes = [], subscriptionManifest = [], scopeRoutes = [], middleware = [], features = [], dataResources = [], publicTenantId = null, health, boot, metrics, security = true, adminPage, siteAdminPage }) {
12
35
  if (typeof fetch !== "function") throw new TypeError("createWorker requires a fetch handler");
13
36
  const provider = auth || features.find((feature) => typeof feature?.getUser === "function");
14
37
  const registeredDataResources = normalizeDataResources([...dataResources, ...features.flatMap((feature) => Array.isArray(feature?.dataResources) ? feature.dataResources : [])]);
38
+ const featureRoutes = features.flatMap((feature) => Array.isArray(feature?.routes) ? [async (request, env, ctx, next, state) => {
39
+ for (const route of feature.routes) {
40
+ const matches = typeof route?.match === "function" ? await route.match(request, env, state) : route?.path === new URL(request.url).pathname;
41
+ if (matches && typeof route.handle === "function") return route.handle({ request, env, ctx, state, next });
42
+ }
43
+ return next();
44
+ }] : []);
15
45
  const chain = [
16
46
  (request, env, ctx, next, state) => adminBoundary(request, env, ctx, next, state, { provider, authorize, scopes, scopeRoutes, features, adminPage, siteAdminPage }),
17
47
  ...features.flatMap((feature) => feature?.middleware ? [feature.middleware.bind(feature)] : []),
48
+ ...featureRoutes,
18
49
  ...middleware,
19
50
  ...(auth ? [(request, env, ctx, next) => auth(request, env, ctx, next)] : []),
20
51
  ].filter(Boolean);
@@ -27,7 +58,7 @@ export function createWorker({ fetch, scheduled, auth, authorize, scopes = [], s
27
58
  const state = Object.create(null);
28
59
  if (provider?.getUser) state.user = await provider.getUser(request, env).catch(() => null);
29
60
  state.data = createDataReader(env, { resources: registeredDataResources, context: () => requestDataContext(env, { state, request, publicTenantId }) });
30
- if (env?.DB && features.some((feature) => typeof feature?.healthcheck === "function" || feature?.healthchecks?.length || feature?.healthChecks?.length || feature?.circuitBreakers?.length || feature?.circuit_breakers?.length)) ctx?.waitUntil?.(registerFeatureManifests(env, features, { who: "system:update" }).then(() => listCircuitBreakers(env, { who: "system:update" }).then((breakers) => Promise.all(breakers.filter(Boolean).map((breaker) => evaluateCircuitBreaker(env, breaker.id, { who: "system:update" }))))).catch((error) => console.error("[EventLog] feature manifest registration failed", error)));
61
+ if (env?.DB && features.some((feature) => typeof feature?.healthcheck === "function" || feature?.healthchecks?.length || feature?.healthChecks?.length || feature?.circuitBreakers?.length || feature?.circuit_breakers?.length)) ctx?.waitUntil?.(ensureFeatureManifests(env, features).catch((error) => console.error("[EventLog] feature manifest registration failed", error)));
31
62
  const dispatch = async (index, currentRequest = request) => {
32
63
  const layer = chain[index];
33
64
  if (!layer) {
@@ -76,6 +107,10 @@ async function adminBoundary(request, env, ctx, next, state, { provider, authori
76
107
  } else {
77
108
  return new Response("Unsupported AUTH_STRATEGY", { status: 500, headers: { "Cache-Control": "no-store" } });
78
109
  }
110
+ if (["POST", "PUT", "PATCH", "DELETE"].includes(request.method) && url.pathname.startsWith("/api/")) {
111
+ const origin = request.headers.get("Origin");
112
+ if (!origin || (() => { try { return new URL(origin).origin !== url.origin; } catch { return true; } })()) return Response.json({ error: "A same-origin request is required." }, { status: 403, headers: { "Cache-Control": "no-store" } });
113
+ }
79
114
  await ensureScopes(env, scopes, { who: state.user?.auth_strategy === "http_basic" ? "user:admin" : `user:${state.user?.sub || "unknown"}` });
80
115
  state.authUser = await ensureUser(env, state.user, { who: state.user?.auth_strategy === "http_basic" ? "user:admin" : `user:${state.user?.sub || "unknown"}` });
81
116
  state.requestedBy = requestActor(state);