@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
@@ -0,0 +1,25 @@
1
+ import { createD1 } from "../core/d1.js";
2
+ import { deepEqual, parseJsonValue } from "./encoding.js";
3
+ import { SubscriptionError } from "./constants.js";
4
+
5
+ export async function listTenantSubscriptions(env, tenantId, { who = "system:read" } = {}) {
6
+ const { results } = await createD1(env, { who }).prepare("SELECT s.id,s.name,s.created_at,s.updated_at FROM auth_subscriptions s JOIN auth_tenant_subscriptions ts ON ts.subscription_id=s.id WHERE ts.tenant_id=? ORDER BY s.name COLLATE NOCASE").bind(tenantId).all();
7
+ return Promise.all((results || []).map(async (subscription) => ({ ...subscription, entitlements: await listSubscriptionEntitlements(env, subscription.id, { who }) })));
8
+ }
9
+
10
+ export async function listSubscriptionEntitlements(env, subscriptionId, { who = "system:read" } = {}) {
11
+ const { results } = await createD1(env, { who }).prepare("SELECT entitlement,value_json FROM auth_subscription_entitlements WHERE subscription_id=? ORDER BY entitlement").bind(subscriptionId).all();
12
+ return (results || []).map((row) => ({ entitlement: row.entitlement, value: parseJsonValue(row.value_json) }));
13
+ }
14
+
15
+ export function normalizeSubscriptionManifest(manifest = []) { return manifest.map((subscription) => ({ id: String(subscription?.id || "").trim(), name: String(subscription?.name || subscription?.id || "").trim(), entitlements: Object.fromEntries(Object.entries(subscription?.entitlements || {}).map(([key, value]) => [String(key), value])) })).filter((subscription) => /^[a-z0-9][a-z0-9_-]*$/.test(subscription.id) && subscription.name); }
16
+
17
+ export async function ensureSubscriptionManifest(env, manifest = [], { who = "system:update" } = {}) {
18
+ if (!env?.DB) return; const db = createD1(env, { who });
19
+ for (const subscription of normalizeSubscriptionManifest(manifest)) { await db.prepare("INSERT INTO auth_subscriptions (id,name) VALUES (?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name,updated_at=CURRENT_TIMESTAMP").bind(subscription.id, subscription.name).run(); for (const [entitlement, value] of Object.entries(subscription.entitlements)) await db.prepare("INSERT INTO auth_subscription_entitlements (subscription_id,entitlement,value_json) VALUES (?,?,?) ON CONFLICT(subscription_id,entitlement) DO UPDATE SET value_json=excluded.value_json,updated_at=CURRENT_TIMESTAMP").bind(subscription.id, entitlement, JSON.stringify(value)).run(); }
20
+ }
21
+
22
+ export async function hasSubscription(env, tenantId, subscriptionId, { who = "system:read" } = {}) { return Boolean(await createD1(env, { who }).prepare("SELECT 1 FROM auth_tenant_subscriptions WHERE tenant_id=? AND subscription_id=?").bind(tenantId, subscriptionId).first()); }
23
+ export async function requireSubscription(env, tenantId, subscriptionId, { who = "system:read" } = {}) { if (!await hasSubscription(env, tenantId, subscriptionId, { who })) throw new SubscriptionError("Required subscription is not active for this tenant."); return true; }
24
+ export async function hasEntitlement(env, tenantId, entitlement, expectedValue, { who = "system:read" } = {}) { const rows = await createD1(env, { who }).prepare("SELECT e.value_json FROM auth_subscription_entitlements e JOIN auth_tenant_subscriptions ts ON ts.subscription_id=e.subscription_id WHERE ts.tenant_id=? AND e.entitlement=?").bind(tenantId, entitlement).all(); return (rows.results || []).some((row) => expectedValue === undefined || deepEqual(parseJsonValue(row.value_json), expectedValue)); }
25
+ export async function requireEntitlement(env, tenantId, entitlement, expectedValue, { who = "system:read" } = {}) { if (!await hasEntitlement(env, tenantId, entitlement, expectedValue, { who })) throw new SubscriptionError(`Required entitlement is not active: ${entitlement}.`); return true; }
@@ -0,0 +1,34 @@
1
+ import { createD1 } from "../core/d1.js";
2
+
3
+ export async function listUserTenants(env, userId, { who = "system:read" } = {}) {
4
+ const { results } = await createD1(env, { who }).prepare("SELECT t.id,t.name,t.created_at,t.updated_at FROM auth_tenants t JOIN auth_user_tenants ut ON ut.tenant_id=t.id WHERE ut.user_id=? ORDER BY t.name COLLATE NOCASE").bind(userId).all();
5
+ return results || [];
6
+ }
7
+
8
+ export async function listAuthorizationTenants(env, { who = "system:read" } = {}) {
9
+ const { results } = await createD1(env, { who }).prepare("SELECT t.id,t.name,t.created_at,t.updated_at,COUNT(ut.user_id) AS user_count FROM auth_tenants t LEFT JOIN auth_user_tenants ut ON ut.tenant_id=t.id GROUP BY t.id ORDER BY t.name COLLATE NOCASE").all();
10
+ return results || [];
11
+ }
12
+
13
+ export async function getAuthorizationTenant(env, tenantId, { who = "system:read" } = {}) { return createD1(env, { who }).prepare("SELECT id,name,created_at,updated_at FROM auth_tenants WHERE id=?").bind(String(tenantId)).first(); }
14
+
15
+ export async function createAuthorizationTenant(env, tenantId, name, { who = "system:update" } = {}) {
16
+ const id = String(tenantId || "").trim(); const tenantName = String(name || "").trim();
17
+ if (!/^[a-z0-9][a-z0-9_-]*$/.test(id)) throw new Error("Tenant id must contain lowercase letters, numbers, hyphens, or underscores.");
18
+ if (!tenantName) throw new Error("Tenant name is required.");
19
+ await createD1(env, { who }).prepare("INSERT INTO auth_tenants (id,name) VALUES (?,?)").bind(id, tenantName).run();
20
+ return getAuthorizationTenant(env, id, { who });
21
+ }
22
+
23
+ export async function updateAuthorizationTenant(env, tenantId, name, { who = "system:update" } = {}) {
24
+ const tenantName = String(name || "").trim(); if (!tenantName) throw new Error("Tenant name is required.");
25
+ await createD1(env, { who }).prepare("UPDATE auth_tenants SET name=?,updated_at=CURRENT_TIMESTAMP WHERE id=?").bind(tenantName, String(tenantId)).run();
26
+ return getAuthorizationTenant(env, tenantId, { who });
27
+ }
28
+
29
+ export async function replaceUserTenants(env, userId, tenantIds, { who = "system:update" } = {}) {
30
+ const db = createD1(env, { who }); const requested = [...new Set((tenantIds || []).map((id) => String(id).trim()).filter(Boolean))];
31
+ if (requested.length) { const placeholders = requested.map(() => "?").join(","); const { results } = await db.prepare(`SELECT id FROM auth_tenants WHERE id IN (${placeholders})`).bind(...requested).all(); const found = new Set((results || []).map((row) => row.id)); if (found.size !== requested.length) throw new Error("One or more tenants do not exist."); }
32
+ await db.batch([db.prepare("DELETE FROM auth_user_tenants WHERE user_id=?").bind(userId), ...requested.map((tenantId) => db.prepare("INSERT INTO auth_user_tenants (user_id,tenant_id) VALUES (?,?)").bind(userId, tenantId))]);
33
+ return listUserTenants(env, userId, { who });
34
+ }
@@ -0,0 +1,31 @@
1
+ import { createD1 } from "../core/d1.js";
2
+ import { AUTH_GRANT_TABLE, AUTH_USER_TABLE, DEFAULT_TENANT_ID, DEFAULT_TENANT_NAME } from "./constants.js";
3
+ import { listUserGrants } from "./scopes.js";
4
+ import { listUserTenants } from "./tenants.js";
5
+
6
+ export async function ensureUser(env, user, { who = "system:read" } = {}) {
7
+ if (!env?.DB || !user?.sub) return null;
8
+ const db = createD1(env, { who }); const provider = String(user.auth_strategy || "oauth"); const subject = String(user.sub); const email = String(user.email || "").trim().toLowerCase();
9
+ const existing = await db.prepare(`SELECT * FROM ${AUTH_USER_TABLE} WHERE provider=? AND subject=?`).bind(provider, subject).first();
10
+ if (existing) { await db.prepare(`UPDATE ${AUTH_USER_TABLE} SET email=?,display_name=?,updated_at=CURRENT_TIMESTAMP WHERE id=?`).bind(email, String(user.name || email || subject), existing.id).run(); await ensureDefaultTenantMembership(db, existing.id); return { ...existing, email, display_name: String(user.name || email || subject), is_admin: Boolean(existing.is_admin) }; }
11
+ const id = await stableId(`${provider}:${subject}`);
12
+ await db.prepare(`INSERT INTO ${AUTH_USER_TABLE} (id,provider,subject,email,display_name,is_admin) VALUES (?,?,?,?,?,0) ON CONFLICT(provider,subject) DO NOTHING`).bind(id, provider, subject, email, String(user.name || email || subject)).run();
13
+ await ensureDefaultTenantMembership(db, id);
14
+ return db.prepare(`SELECT * FROM ${AUTH_USER_TABLE} WHERE id=?`).bind(id).first();
15
+ }
16
+
17
+ async function ensureDefaultTenantMembership(db, userId) { await db.batch([db.prepare("INSERT OR IGNORE INTO auth_tenants (id,name) VALUES (?,?)").bind(DEFAULT_TENANT_ID, DEFAULT_TENANT_NAME), db.prepare("INSERT OR IGNORE INTO auth_user_tenants (user_id,tenant_id) VALUES (?,?)").bind(userId, DEFAULT_TENANT_ID)]); }
18
+
19
+ export async function hasScope(env, user, scope, { who = "system:read" } = {}) {
20
+ const db = createD1(env, { who }); if (user?.auth_strategy === "http_basic") return true; const authUser = user?.authUser || await ensureUser(env, user, { who }); if (!authUser) return false; if (Boolean(authUser.is_admin)) return true;
21
+ return Boolean(await db.prepare(`SELECT 1 FROM ${AUTH_GRANT_TABLE} WHERE user_id=? AND scope_name=?`).bind(authUser.id, scope).first());
22
+ }
23
+
24
+ export async function listAuthorizationUsers(env, { who = "system:read" } = {}) {
25
+ const { results } = await createD1(env, { who }).prepare(`SELECT id,email,display_name,provider,subject,is_admin,created_at,updated_at FROM ${AUTH_USER_TABLE} ORDER BY email COLLATE NOCASE`).all();
26
+ return Promise.all(results.map(async (user) => ({ ...user, scopes: (await listUserGrants(env, user.id, { who })).map((grant) => grant.scope_name), tenants: await listUserTenants(env, user.id, { who }) })));
27
+ }
28
+
29
+ export async function getAuthorizationUser(env, userId, { who = "system:read" } = {}) { return createD1(env, { who }).prepare(`SELECT id,email,display_name,provider,subject,is_admin,created_at,updated_at FROM ${AUTH_USER_TABLE} WHERE id=?`).bind(userId).first(); }
30
+
31
+ async function stableId(value) { const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("").slice(0, 32); }
@@ -1,54 +1,46 @@
1
- export const HEALTHCHECK_STATES = ["red", "yellow", "green"];
2
- export const CIRCUIT_BREAKER_STATES = ["off", "tripped", "on"];
3
- export const HEALTHCHECK_MODES = ["any", "all"];
4
- export const BASE_PACKAGE_NAME = "@agilesyndrome/cf-genai-base";
5
- export const BASE_VERSION = "4.1.1";
6
-
7
- export function eventLog(level, event, details = {}) {
8
- const method = ["debug", "info", "warn", "error"].includes(level) ? level : "info";
9
- console[method](`[EventLog] ${event}`, details);
1
+ import { auditLog, eventLog } from "./events.js";
2
+ import { createD1 } from "./d1.js";
3
+ import { secureJson } from "./security.js";
4
+ import { BASE_PACKAGE_NAME, BASE_VERSION, CIRCUIT_BREAKER_STATES, HEALTHCHECK_MODES, HEALTHCHECK_STATES } from "./constants.js";
5
+
6
+ export async function featureCircuit(env, id, who = "system:read") {
7
+ return getCircuitBreaker(env, id, { who }).catch((error) => {
8
+ eventLog("error", "feature.circuit.read.failed", { breaker: id, errorName: error?.name || "Error", errorMessage: String(error?.message || "Unknown circuit breaker error").slice(0, 300) });
9
+ void env?.event?.("feature.circuit.read.failed", "feature", { breaker: id, who, errorName: error?.name || "Error" });
10
+ return null;
11
+ });
10
12
  }
11
13
 
12
- export function auditLog({ who = "system", operation, resource, details = {} }) {
13
- console.info(`[AuditLog] ${who}:${operation} ${resource}`, details);
14
- }
14
+ export async function featureAvailable(env, id, who = "system:read") { return (await featureCircuit(env, id, who))?.state === "on"; }
15
15
 
16
- export function requestActor(state = {}) {
17
- if (state.requestedBy) return String(state.requestedBy);
18
- if (state.authUser?.id) return `user:${state.authUser.id}`;
19
- if (state.user?.auth_strategy === "http_basic") return "user:admin";
20
- if (state.user?.sub) return `user:${state.user.sub}`;
21
- return "system:read";
16
+ export async function requireFeatureCircuit(env, { feature, breakerId, operation, requestId, who, message = "The requested feature is unavailable." }) {
17
+ const breaker = await featureCircuit(env, breakerId, who || "system:read");
18
+ if (breaker?.state === "on") return breaker;
19
+ const details = { feature, breaker: breakerId, state: breaker?.state || "missing", operation, requestId: requestId || null };
20
+ eventLog("warn", "feature.request.blocked", details);
21
+ auditLog({ who: who || "system:read", operation: "blocked", resource: `feature:${feature}`, details });
22
+ await env?.event?.("feature.request.blocked", "feature", { ...details, who: who || "system:read" });
23
+ throw Object.assign(new Error(message), { status: 503, code: "feature_circuit_unavailable", feature, breakerId, circuitState: breaker?.state || "missing" });
22
24
  }
23
25
 
24
- export function createD1(env, { who = "system:read" } = {}) {
25
- if (!env?.DB) throw new Error("A DB binding is required");
26
- const db = env.DB;
26
+ export function createAvailabilityFeature({ name = "base", breakerId = `${name}:site-available`, displayName = "Site availability", publicPaths = ["/health", "/api/health"] } = {}) {
27
27
  return {
28
- prepare(sql) {
29
- const statement = db.prepare(sql);
30
- return new Proxy(statement, { get(target, property) {
31
- const value = target[property];
32
- if (typeof value !== "function" || !["run", "first", "all", "raw"].includes(property)) return typeof value === "function" ? value.bind(target) : value;
33
- return async (...args) => {
34
- auditD1(who, sql);
35
- return value.apply(target, args);
36
- };
37
- }});
38
- },
39
- async batch(statements) {
40
- auditD1(who, "BATCH");
41
- return typeof db.batch === "function" ? db.batch(statements) : Promise.all(statements.map((statement) => statement.run()));
28
+ name,
29
+ packageName: BASE_PACKAGE_NAME,
30
+ version: BASE_VERSION,
31
+ healthcheck: async () => ({ feature: name, component: "site-available", displayName, state: "green", metadata: { endpoint: "/health", expectedStatus: 200 } }),
32
+ circuitBreakers: [{ id: breakerId, name: "site-available", displayName, state: "on", allowSelfHealing: false, healthchecks: [`${name}:site-available`] }],
33
+ async middleware(request, env, ctx, next) {
34
+ const pathname = new URL(request.url).pathname;
35
+ if (publicPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))) return next(request);
36
+ const breaker = await featureCircuit(env, breakerId, "system:read");
37
+ if (breaker?.state === "on") return next(request);
38
+ await env.event?.("feature.maintenance", "availability", { feature: name, breaker: breakerId, state: breaker?.state || "missing", pathname, method: request.method });
39
+ return secureJson({ error: "The site is temporarily unavailable." }, 503);
42
40
  },
43
41
  };
44
42
  }
45
43
 
46
- function auditD1(who, sql) {
47
- const operation = String(sql).trim().match(/^(SELECT|INSERT|UPDATE|DELETE|REPLACE|WITH)/i)?.[1]?.toLowerCase() || "execute";
48
- eventLog("debug", "d1.operation", { who, operation });
49
- auditLog({ who, operation, resource: "d1" });
50
- }
51
-
52
44
  export function normalizeHealthcheck(input = {}) {
53
45
  const state = String(input.state || "yellow").toLowerCase();
54
46
  if (!HEALTHCHECK_STATES.includes(state)) throw new Error("Healthcheck state must be red, yellow, or green");
@@ -66,19 +58,15 @@ export function normalizeCircuitBreaker(input = {}) {
66
58
  }
67
59
 
68
60
  export async function registerHealthcheck(env, input, { who = "system:read" } = {}) {
69
- const item = normalizeHealthcheck(input);
70
- const db = createD1(env, { who });
71
- const id = String(input.id || `${item.feature}:${item.component}`);
61
+ const item = normalizeHealthcheck(input); const db = createD1(env, { who }); const id = String(input.id || `${item.feature}:${item.component}`);
72
62
  await db.prepare(`INSERT INTO core_healthchecks (id,feature,component,display_name,state,metadata_json) VALUES (?,?,?,?,?,?) ON CONFLICT(feature,component) DO UPDATE SET display_name=excluded.display_name,metadata_json=excluded.metadata_json,updated_at=CURRENT_TIMESTAMP`).bind(id, item.feature, item.component, item.displayName, item.state, JSON.stringify(item.metadata)).run();
73
63
  return getHealthcheck(env, id, { who });
74
64
  }
75
65
 
76
66
  export async function updateHealthcheck(env, id, state, { who = "system:read" } = {}) {
77
67
  if (!HEALTHCHECK_STATES.includes(String(state).toLowerCase())) throw new Error("Healthcheck state must be red, yellow, or green");
78
- const item = await getHealthcheck(env, id, { who });
79
- if (!item) return null;
80
- const db = createD1(env, { who });
81
- await db.prepare("UPDATE core_healthchecks SET state=?,updated_at=CURRENT_TIMESTAMP WHERE id=?").bind(String(state).toLowerCase(), id).run();
68
+ const item = await getHealthcheck(env, id, { who }); if (!item) return null;
69
+ await createD1(env, { who }).prepare("UPDATE core_healthchecks SET state=?,updated_at=CURRENT_TIMESTAMP WHERE id=?").bind(String(state).toLowerCase(), id).run();
82
70
  auditLog({ who, operation: "update", resource: `feature:${item.feature} component:${item.component} ${String(state).toLowerCase()}` });
83
71
  return getHealthcheck(env, id, { who });
84
72
  }
@@ -87,22 +75,18 @@ export async function getHealthcheck(env, id, { who = "system:read" } = {}) { re
87
75
  export async function listHealthchecks(env, { who = "system:read" } = {}) { return (await createD1(env, { who }).prepare("SELECT * FROM core_healthchecks ORDER BY feature,component").bind().all()).results || []; }
88
76
 
89
77
  export async function registerCircuitBreaker(env, input, { who = "system:read" } = {}) {
90
- const item = normalizeCircuitBreaker(input);
91
- const db = createD1(env, { who });
92
- const id = String(input.id || `${item.feature}:${item.name}`);
78
+ const item = normalizeCircuitBreaker(input); const db = createD1(env, { who }); const id = String(input.id || `${item.feature}:${item.name}`);
93
79
  await db.prepare(`INSERT INTO core_circuit_breakers (id,feature,name,display_name,state,healthcheck_mode,allow_self_healing,metadata_json) VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(feature,name) DO UPDATE SET display_name=excluded.display_name,healthcheck_mode=excluded.healthcheck_mode,allow_self_healing=excluded.allow_self_healing,metadata_json=excluded.metadata_json,updated_at=CURRENT_TIMESTAMP`).bind(id, item.feature, item.name, item.displayName, item.state, item.healthcheckMode, item.allowSelfHealing ? 1 : 0, JSON.stringify(item.metadata)).run();
94
80
  await db.batch([db.prepare("DELETE FROM core_circuit_breaker_healthchecks WHERE circuit_breaker_id=?").bind(id), db.prepare("DELETE FROM core_circuit_breaker_dependencies WHERE circuit_breaker_id=?").bind(id), ...item.healthchecks.map((healthcheckId) => db.prepare("INSERT OR IGNORE INTO core_circuit_breaker_healthchecks (circuit_breaker_id,healthcheck_id) VALUES (?,?)").bind(id, healthcheckId)), ...item.dependsOnCircuitBreakers.map((dependencyId) => db.prepare("INSERT OR IGNORE INTO core_circuit_breaker_dependencies (circuit_breaker_id,dependency_id) VALUES (?,?)").bind(id, dependencyId))]);
95
81
  return getCircuitBreaker(env, id, { who });
96
82
  }
97
83
 
98
- export async function getCircuitBreaker(env, id, { who = "system:read" } = {}) { const db = createD1(env, { who }); const breaker = await db.prepare("SELECT * FROM core_circuit_breakers WHERE id=?").bind(id).first(); if (!breaker) return null; breaker.healthchecks = (await db.prepare("SELECT healthcheck_id FROM core_circuit_breaker_healthchecks WHERE circuit_breaker_id=?").bind(id).all()).results.map((row) => row.healthcheck_id) ; breaker.depends_on_circuit_breakers = (await db.prepare("SELECT dependency_id FROM core_circuit_breaker_dependencies WHERE circuit_breaker_id=?").bind(id).all()).results.map((row) => row.dependency_id); return breaker; }
84
+ export async function getCircuitBreaker(env, id, { who = "system:read" } = {}) { const db = createD1(env, { who }); const breaker = await db.prepare("SELECT * FROM core_circuit_breakers WHERE id=?").bind(id).first(); if (!breaker) return null; breaker.healthchecks = (await db.prepare("SELECT healthcheck_id FROM core_circuit_breaker_healthchecks WHERE circuit_breaker_id=?").bind(id).all()).results.map((row) => row.healthcheck_id); breaker.depends_on_circuit_breakers = (await db.prepare("SELECT dependency_id FROM core_circuit_breaker_dependencies WHERE circuit_breaker_id=?").bind(id).all()).results.map((row) => row.dependency_id); return breaker; }
99
85
  export async function listCircuitBreakers(env, { who = "system:read" } = {}) { const rows = (await createD1(env, { who }).prepare("SELECT * FROM core_circuit_breakers ORDER BY feature,name").bind().all()).results || []; return Promise.all(rows.map((row) => getCircuitBreaker(env, row.id, { who }))); }
100
86
 
101
87
  export async function setCircuitBreaker(env, id, state, { who = "system:read", automated = false } = {}) {
102
- const next = String(state).toLowerCase();
103
- if (!CIRCUIT_BREAKER_STATES.includes(next)) throw new Error("Circuit breaker state must be off, tripped, or on");
104
- const current = await getCircuitBreaker(env, id, { who });
105
- if (!current) return null;
88
+ const next = String(state).toLowerCase(); if (!CIRCUIT_BREAKER_STATES.includes(next)) throw new Error("Circuit breaker state must be off, tripped, or on");
89
+ const current = await getCircuitBreaker(env, id, { who }); if (!current) return null;
106
90
  if (automated && !((current.state === "on" && next === "tripped") || (current.state === "tripped" && next === "on" && Boolean(current.allow_self_healing)))) return current;
107
91
  if (automated && next === "off") return current;
108
92
  await createD1(env, { who }).prepare("UPDATE core_circuit_breakers SET state=?,updated_at=CURRENT_TIMESTAMP WHERE id=?").bind(next, id).run();
@@ -111,27 +95,18 @@ export async function setCircuitBreaker(env, id, state, { who = "system:read", a
111
95
  }
112
96
 
113
97
  export async function evaluateCircuitBreaker(env, id, { who = "system:read" } = {}) {
114
- const breaker = await getCircuitBreaker(env, id, { who });
115
- if (!breaker) return null;
116
- const db = createD1(env, { who });
98
+ const breaker = await getCircuitBreaker(env, id, { who }); if (!breaker) return null; const db = createD1(env, { who });
117
99
  const rows = (await db.prepare("SELECT h.state FROM core_healthchecks h JOIN core_circuit_breaker_healthchecks b ON b.healthcheck_id=h.id WHERE b.circuit_breaker_id=?").bind(id).all()).results || [];
118
- const failing = rows.map((row) => row.state === "red");
119
- const dependencies = (await db.prepare("SELECT b.state FROM core_circuit_breakers b JOIN core_circuit_breaker_dependencies d ON d.dependency_id=b.id WHERE d.circuit_breaker_id=?").bind(id).all()).results || [];
120
- const dependencyFailed = dependencies.some((row) => row.state === "tripped");
121
- const shouldTrip = dependencyFailed || (failing.length > 0 && (breaker.healthcheck_mode === "all" ? failing.every(Boolean) : failing.some(Boolean)));
100
+ const failing = rows.map((row) => row.state === "red"); const dependencies = (await db.prepare("SELECT b.state FROM core_circuit_breakers b JOIN core_circuit_breaker_dependencies d ON d.dependency_id=b.id WHERE d.circuit_breaker_id=?").bind(id).all()).results || [];
101
+ const dependencyFailed = dependencies.some((row) => row.state === "tripped"); const shouldTrip = dependencyFailed || (failing.length > 0 && (breaker.healthcheck_mode === "all" ? failing.every(Boolean) : failing.some(Boolean)));
122
102
  if (breaker.state === "on" && shouldTrip) return setCircuitBreaker(env, id, "tripped", { who, automated: true });
123
103
  if (breaker.state === "tripped" && !shouldTrip && breaker.allow_self_healing) return setCircuitBreaker(env, id, "on", { who, automated: true });
124
104
  return breaker;
125
105
  }
126
106
 
127
-
128
107
  export async function registerFeatureManifests(env, features = [], { who = "system:read" } = {}) {
129
108
  for (const feature of features) {
130
- const name = String(feature?.name || feature?.id || "feature");
131
- const declaredResult = typeof feature?.healthcheck === "function" ? await feature.healthcheck(env, { who }) : (feature?.healthchecks || feature?.healthChecks || []);
132
- const declaredHealthchecks = Array.isArray(declaredResult) ? declaredResult : [declaredResult];
133
- const healthchecks = [];
134
- const breakers = [];
109
+ const name = String(feature?.name || feature?.id || "feature"); const declaredResult = typeof feature?.healthcheck === "function" ? await feature.healthcheck(env, { who }) : (feature?.healthchecks || feature?.healthChecks || []); const declaredHealthchecks = Array.isArray(declaredResult) ? declaredResult : [declaredResult]; const healthchecks = []; const breakers = [];
135
110
  for (const healthcheck of declaredHealthchecks) healthchecks.push(await registerHealthcheck(env, { ...healthcheck, feature: healthcheck.feature || name }, { who }));
136
111
  for (const breaker of feature?.circuitBreakers || feature?.circuit_breakers || []) breakers.push(await registerCircuitBreaker(env, { ...breaker, feature: breaker.feature || name }, { who }));
137
112
  await registerCircuitBreaker(env, { id: `${name}:rollup`, feature: name, name: "rollup", displayName: `${name} feature`, state: "on", allowSelfHealing: true, healthchecks: healthchecks.filter(Boolean).map((item) => item.id), dependsOnCircuitBreakers: breakers.filter(Boolean).map((item) => item.id) }, { who });
@@ -139,43 +114,21 @@ export async function registerFeatureManifests(env, features = [], { who = "syst
139
114
  }
140
115
 
141
116
  export async function listFeatureHealth(env, { who = "system:read" } = {}) {
142
- const checks = await listHealthchecks(env, { who });
143
- const severity = { green: 0, yellow: 1, red: 2 };
144
- const features = {};
117
+ const checks = await listHealthchecks(env, { who }); const severity = { green: 0, yellow: 1, red: 2 }; const features = {};
145
118
  for (const check of checks) { const name = check.feature; if (!features[name] || severity[check.state] > severity[features[name].state]) features[name] = { feature: name, state: check.state, healthchecks: 0 }; features[name].healthchecks += 1; }
146
119
  return Object.values(features).sort((a, b) => a.feature.localeCompare(b.feature));
147
120
  }
148
121
 
149
-
150
122
  export function normalizeFeatureManifest(feature = {}) {
151
- const source = feature && typeof feature === "object" ? feature : {};
152
- const manifest = source.manifest && typeof source.manifest === "object" ? source.manifest : source;
153
- const name = String(source.name || source.id || manifest.name || "feature");
154
- const packageName = source.packageName || source.package_name || source.package || manifest.packageName || manifest.package_name || manifest.package;
155
- const version = source.version || source.packageVersion || source.package_version || manifest.version;
156
- return {
157
- feature: name,
158
- display_name: String(source.displayName || source.display_name || manifest.displayName || manifest.display_name || name),
159
- package_name: packageName ? String(packageName) : null,
160
- version: version ? String(version) : null,
161
- };
123
+ const source = feature && typeof feature === "object" ? feature : {}; const manifest = source.manifest && typeof source.manifest === "object" ? source.manifest : source; const name = String(source.name || source.id || manifest.name || "feature"); const packageName = source.packageName || source.package_name || source.package || manifest.packageName || manifest.package_name || manifest.package; const version = source.version || source.packageVersion || source.package_version || manifest.version;
124
+ return { feature: name, display_name: String(source.displayName || source.display_name || manifest.displayName || manifest.display_name || name), package_name: packageName ? String(packageName) : null, version: version ? String(version) : null };
162
125
  }
163
126
 
164
127
  export async function listFeatureCatalog(env, features = [], { who = "system:read" } = {}) {
165
- const [healthchecks, circuitBreakers] = await Promise.all([listHealthchecks(env, { who }), listCircuitBreakers(env, { who })]);
166
- const manifests = new Map([["base", { feature: "base", display_name: "Base platform", package_name: BASE_PACKAGE_NAME, version: BASE_VERSION }]]);
167
- for (const feature of features) {
168
- const manifest = normalizeFeatureManifest(feature);
169
- manifests.set(manifest.feature, manifest);
170
- }
128
+ const [healthchecks, circuitBreakers] = await Promise.all([listHealthchecks(env, { who }), listCircuitBreakers(env, { who })]); const manifests = new Map([["base", { feature: "base", display_name: "Base platform", package_name: BASE_PACKAGE_NAME, version: BASE_VERSION }]]);
129
+ for (const feature of features) { const manifest = normalizeFeatureManifest(feature); manifests.set(manifest.feature, manifest); }
171
130
  for (const item of healthchecks) if (!manifests.has(item.feature)) manifests.set(item.feature, normalizeFeatureManifest({ name: item.feature }));
172
131
  for (const item of circuitBreakers) if (!manifests.has(item.feature)) manifests.set(item.feature, normalizeFeatureManifest({ name: item.feature }));
173
- const severity = { green: 0, yellow: 1, red: 2 };
174
- const state = (items) => items.reduce((current, item) => severity[item.state] > severity[current] ? item.state : current, "green");
175
- return [...manifests.values()].sort((a, b) => a.feature.localeCompare(b.feature)).map((manifest) => {
176
- const featureHealthchecks = healthchecks.filter((item) => item.feature === manifest.feature);
177
- const featureBreakers = circuitBreakers.filter((item) => item.feature === manifest.feature);
178
- const rollup = featureBreakers.find((item) => item.name === "rollup") || null;
179
- return { ...manifest, health: featureHealthchecks.length ? state(featureHealthchecks) : "yellow", healthchecks: featureHealthchecks, circuit_breakers: featureBreakers, circuit_breaker: rollup };
180
- });
132
+ const severity = { green: 0, yellow: 1, red: 2 }; const state = (items) => items.reduce((current, item) => severity[item.state] > severity[current] ? item.state : current, "green");
133
+ return [...manifests.values()].sort((a, b) => a.feature.localeCompare(b.feature)).map((manifest) => { const featureHealthchecks = healthchecks.filter((item) => item.feature === manifest.feature); const featureBreakers = circuitBreakers.filter((item) => item.feature === manifest.feature); const rollup = featureBreakers.find((item) => item.name === "rollup") || null; return { ...manifest, health: featureHealthchecks.length ? state(featureHealthchecks) : "yellow", healthchecks: featureHealthchecks, circuit_breakers: featureBreakers, circuit_breaker: rollup }; });
181
134
  }
@@ -0,0 +1,5 @@
1
+ export const HEALTHCHECK_STATES = ["red", "yellow", "green"];
2
+ export const CIRCUIT_BREAKER_STATES = ["off", "tripped", "on"];
3
+ export const HEALTHCHECK_MODES = ["any", "all"];
4
+ export const BASE_PACKAGE_NAME = "@agilesyndrome/cf-genai-base";
5
+ export const BASE_VERSION = "4.1.3";
package/src/core/d1.js ADDED
@@ -0,0 +1,54 @@
1
+ import { auditLog, eventLog } from "./events.js";
2
+
3
+ export function createD1(env, { who = "system:read" } = {}) {
4
+ if (!env?.DB) throw new Error("A DB binding is required");
5
+ const db = env.DB;
6
+ return {
7
+ prepare(sql) {
8
+ const statement = db.prepare(sql);
9
+ return new Proxy(statement, { get(target, property) {
10
+ const value = target[property];
11
+ if (typeof value !== "function" || !["run", "first", "all", "raw"].includes(property)) return typeof value === "function" ? value.bind(target) : value;
12
+ return async (...args) => { auditD1(who, sql); return value.apply(target, args); };
13
+ }});
14
+ },
15
+ async batch(statements) {
16
+ auditD1(who, "BATCH");
17
+ return typeof db.batch === "function" ? db.batch(statements) : Promise.all(statements.map((statement) => statement.run()));
18
+ },
19
+ };
20
+ }
21
+
22
+ export function auditedD1(db, who = "system:read") {
23
+ if (!db) return db;
24
+ return new Proxy(db, {
25
+ get(target, property) {
26
+ const value = target[property];
27
+ if (property === "prepare") return (sql) => {
28
+ const statement = value.call(target, sql);
29
+ return new Proxy(statement, {
30
+ get(statementTarget, statementProperty) {
31
+ const method = statementTarget[statementProperty];
32
+ if (!["run", "first", "all", "raw"].includes(statementProperty) || typeof method !== "function") return typeof method === "function" ? method.bind(statementTarget) : method;
33
+ return async (...args) => { auditD1(who, sql); return method.apply(statementTarget, args); };
34
+ },
35
+ });
36
+ };
37
+ if (property === "batch") return (statements) => { auditD1(who, "BATCH"); return typeof value === "function" ? value.call(target, statements) : Promise.all(statements.map((statement) => statement.run())); };
38
+ return typeof value === "function" ? value.bind(target) : value;
39
+ },
40
+ });
41
+ }
42
+
43
+ export function auditedEnv(env, who = "system:read") {
44
+ if (!env?.DB) return env;
45
+ const result = Object.create(env);
46
+ result.DB = auditedD1(env.DB, who);
47
+ return result;
48
+ }
49
+
50
+ function auditD1(who, sql) {
51
+ const operation = String(sql).trim().match(/^(SELECT|INSERT|UPDATE|DELETE|REPLACE|WITH)/i)?.[1]?.toLowerCase() || "execute";
52
+ eventLog("debug", "d1.operation", { who, operation });
53
+ auditLog({ who, operation, resource: "d1" });
54
+ }
@@ -0,0 +1,29 @@
1
+ import { DurableObject } from "cloudflare:workers";
2
+
3
+ export class EventHub extends DurableObject {
4
+ async fetch(request) {
5
+ const url = new URL(request.url);
6
+ if (request.method === "POST" && url.pathname === "/publish") {
7
+ if (request.headers.get("X-CF-GenAI-Event") !== "1") return new Response("Not found", { status: 404 });
8
+ const event = await request.json().catch(() => null);
9
+ if (!event?.id || !event?.what) return Response.json({ error: "Invalid event" }, { status: 400 });
10
+ const message = JSON.stringify({ type: "event", event });
11
+ for (const socket of this.ctx.getWebSockets()) {
12
+ try { socket.send(message); } catch { socket.close(1011, "Event delivery failed"); }
13
+ }
14
+ return Response.json({ ok: true });
15
+ }
16
+ if (request.method !== "GET" || request.headers.get("Upgrade")?.toLowerCase() !== "websocket") return new Response("WebSocket upgrade required", { status: 426 });
17
+ const pair = new WebSocketPair();
18
+ const [client, server] = Object.values(pair);
19
+ this.ctx.acceptWebSocket(server);
20
+ return new Response(null, { status: 101, webSocket: client });
21
+ }
22
+
23
+ webSocketMessage(socket, message) {
24
+ if (message === "ping") socket.send(JSON.stringify({ type: "pong" }));
25
+ }
26
+
27
+ webSocketClose() {}
28
+ webSocketError() {}
29
+ }
@@ -0,0 +1,63 @@
1
+ export function Event(who, what, where, when = new Date(), details = {}) {
2
+ return {
3
+ id: crypto.randomUUID(),
4
+ who: String(who || "system"),
5
+ what: String(what || "event"),
6
+ where: String(where || "application"),
7
+ when: when instanceof Date ? when.toISOString() : new Date(when || Date.now()).toISOString(),
8
+ details: details && typeof details === "object" ? details : { value: details },
9
+ };
10
+ }
11
+
12
+ export function createEventHandler(features = [], { eventHubBinding = "EVENT_HUB" } = {}) {
13
+ const handlers = features.flatMap((feature) => {
14
+ const handler = feature?.eventHandler || feature?.event_handler;
15
+ return typeof handler === "function" ? [handler.bind(feature)] : [];
16
+ });
17
+ return async (event, env, ctx) => {
18
+ if (!event) return null;
19
+ eventLog("info", event.what, { who: event.who, where: event.where, when: event.when, ...event.details });
20
+ await Promise.all([
21
+ ...handlers.map((handler) => Promise.resolve(handler(event, { env, ctx }))),
22
+ publishLiveEvent(env, event, eventHubBinding),
23
+ ]);
24
+ return event;
25
+ };
26
+ }
27
+
28
+ export function eventRooms(event) {
29
+ const audience = event?.details?.audience || {};
30
+ const rooms = [];
31
+ if (audience.userId) rooms.push(`user:${audience.userId}`);
32
+ if (audience.tenantId) rooms.push(`tenant:${audience.tenantId}`);
33
+ if (!rooms.length && String(event?.who || "").startsWith("user:")) rooms.push(String(event.who));
34
+ return [...new Set(rooms)];
35
+ }
36
+
37
+ async function publishLiveEvent(env, event, bindingName) {
38
+ const namespace = bindingName && env?.[bindingName];
39
+ if (!namespace || typeof namespace.idFromName !== "function") return;
40
+ for (const room of eventRooms(event)) {
41
+ try {
42
+ const stub = namespace.get(namespace.idFromName(room));
43
+ await stub.fetch("https://cf-genai-event-hub/publish", { method: "POST", headers: { "X-CF-GenAI-Event": "1", "Content-Type": "application/json" }, body: JSON.stringify(event) });
44
+ } catch (error) {
45
+ eventLog("warn", "live.event.publish.failed", { room, error: error?.message || String(error) });
46
+ }
47
+ }
48
+ }
49
+
50
+ export async function emitEvent(env, event, ctx) {
51
+ if (typeof env?.eventHandler === "function") return env.eventHandler(event, env, ctx);
52
+ if (event) eventLog("info", event.what, { who: event.who, where: event.where, when: event.when, ...event.details });
53
+ return event;
54
+ }
55
+
56
+ export function eventLog(level, event, details = {}) {
57
+ const method = ["debug", "info", "warn", "error"].includes(level) ? level : "info";
58
+ console[method](`[EventLog] ${event}`, details);
59
+ }
60
+
61
+ export function auditLog({ who = "system", operation, resource, details = {} }) {
62
+ console.info(`[AuditLog] ${who}:${operation} ${resource}`, details);
63
+ }
@@ -0,0 +1,39 @@
1
+ import { Event, emitEvent } from "./events.js";
2
+
3
+ export function requestActor(state = {}) {
4
+ if (state.requestedBy) return String(state.requestedBy);
5
+ const canonical = authUser(state);
6
+ if (canonical?.id) return `user:${canonical.id}`;
7
+ if (state.user?.auth_strategy === "http_basic") return "user:admin";
8
+ if (state.user?.sub) return `user:${state.user.sub}`;
9
+ return "system:read";
10
+ }
11
+
12
+ export function authUser(value) {
13
+ return value?.authUser || value?.user?.authUser || value?.state?.authUser || value?.state?.user?.authUser || (value?.id && value?.provider && value?.subject ? value : null) || null;
14
+ }
15
+
16
+ export function userId(value) {
17
+ return authUser(value)?.id || value?.userId || value?.sub || value?.email || null;
18
+ }
19
+
20
+ export function requestIdentity(state = {}) {
21
+ const canonical = authUser(state);
22
+ return {
23
+ user: state.user || null,
24
+ authUser: canonical,
25
+ userId: canonical?.id || null,
26
+ who: requestActor(state),
27
+ isAuthenticated: Boolean(canonical || state.user),
28
+ isAdmin: Boolean(state.user?.auth_strategy === "http_basic" || canonical?.is_admin),
29
+ };
30
+ }
31
+
32
+ export function requestContext({ request, env, ctx, state, data } = {}) {
33
+ const identity = requestIdentity(state);
34
+ return {
35
+ request, env, ctx, state, data: data || state?.data,
36
+ ...identity,
37
+ event: (what, where = "application", details = {}, when = new Date()) => emitEvent(env, Event(identity.who, what, where, when, details), ctx),
38
+ };
39
+ }
@@ -0,0 +1,7 @@
1
+ export * from "./constants.js";
2
+ export * from "./events.js";
3
+ export * from "./identity.js";
4
+ export * from "./security.js";
5
+ export * from "./d1.js";
6
+ export * from "./circuits.js";
7
+ export * from "./jobs.js";