@agilesyndrome/cf-genai-base 4.1.1 → 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 +3 -2
- package/package.json +1 -1
- package/src/authorization.js +4 -3
- package/src/core.js +1 -1
- package/src/data.js +5 -2
- package/src/index.js +36 -1
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
|
-
`
|
|
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
package/src/authorization.js
CHANGED
|
@@ -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),
|
|
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 ||
|
|
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),
|
|
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.
|
|
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.
|
|
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?.(
|
|
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);
|