@agilesyndrome/cf-genai-base 4.1.3 → 5.0.1

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 (63) hide show
  1. package/CLI.md +121 -0
  2. package/CONTRACT.md +26 -9
  3. package/README.md +105 -22
  4. package/bin/cf-genai.js +11 -0
  5. package/changelog.md +25 -0
  6. package/migrations/0006_jobs.sql +38 -0
  7. package/package.json +27 -7
  8. package/src/admin/index.js +1 -0
  9. package/src/admin/routes.js +64 -0
  10. package/src/api/client.js +8 -0
  11. package/src/api/contracts.js +24 -0
  12. package/src/api/index.js +7 -0
  13. package/src/api/jobs.js +37 -0
  14. package/src/api/testing.js +13 -0
  15. package/src/app.js +5 -0
  16. package/src/auth/constants.js +9 -0
  17. package/src/auth/encoding.js +6 -0
  18. package/src/auth/groups.js +5 -0
  19. package/src/auth/impersonation.js +15 -0
  20. package/src/auth/index.js +8 -0
  21. package/src/auth/oauth-single.js +193 -0
  22. package/src/auth/scopes.js +32 -0
  23. package/src/auth/subscriptions.js +25 -0
  24. package/src/auth/tenants.js +34 -0
  25. package/src/auth/users.js +31 -0
  26. package/src/cli/cli.js +133 -0
  27. package/src/cli/d1.js +165 -0
  28. package/src/cli/operations.js +34 -0
  29. package/src/cli/project.js +560 -0
  30. package/src/cli/status.js +46 -0
  31. package/src/cli/tenants.js +16 -0
  32. package/src/cli/users.js +34 -0
  33. package/src/cli/version.js +54 -0
  34. package/src/{core.js → core/circuits.js} +51 -98
  35. package/src/core/constants.js +5 -0
  36. package/src/core/d1.js +54 -0
  37. package/src/core/event-hub.js +29 -0
  38. package/src/core/events.js +63 -0
  39. package/src/core/identity.js +39 -0
  40. package/src/core/index.js +7 -0
  41. package/src/core/jobs.js +185 -0
  42. package/src/core/security.js +71 -0
  43. package/src/data/context.js +16 -0
  44. package/src/data/index.js +3 -0
  45. package/src/data/reader.js +63 -0
  46. package/src/data/resources.js +42 -0
  47. package/src/index.js +10 -297
  48. package/src/repository.js +63 -0
  49. package/src/runtime/features.js +17 -0
  50. package/src/runtime/health.js +17 -0
  51. package/src/runtime/worker.js +45 -0
  52. package/src/ui/index.js +1 -87
  53. package/src/ui/react/admin-access.jsx +29 -0
  54. package/src/ui/react/admin-catalogs.jsx +39 -0
  55. package/src/ui/react/admin-shell.jsx +17 -0
  56. package/src/ui/react/foundation.jsx +40 -0
  57. package/src/ui/react/index.jsx +6 -0
  58. package/src/ui/react/jobs.jsx +63 -0
  59. package/src/ui/react/live-events.jsx +55 -0
  60. package/src/ui/styles.css +34 -0
  61. package/src/authorization.js +0 -233
  62. package/src/data.js +0 -239
  63. package/src/ui/groups.js +0 -2
@@ -0,0 +1,34 @@
1
+ import { executeJson, parseJsonRows } from "./d1.js";
2
+
3
+ function sql(value) { return "'" + String(value).replaceAll("'", "''") + "'"; }
4
+ function query(database, target, command, options) { return parseJsonRows(executeJson(options.wranglerCommand, database, target, command, options)); }
5
+ function csv(value) { return String(value).split(",").map((item) => item.trim()).filter(Boolean); }
6
+
7
+ export function runUserCommand({ action, username, options }) {
8
+ const target = options.target || "local";
9
+ if (!["local", "staging", "production"].includes(target)) throw new Error("User target must be local, staging, or production.");
10
+ if (action === "list") {
11
+ const users = query(options.database, target, "SELECT id,email,display_name,provider,subject,is_admin,created_at,updated_at FROM auth_users ORDER BY email COLLATE NOCASE;", options);
12
+ return console.log(JSON.stringify(users.map((user) => ({ ...user, tenants: query(options.database, target, "SELECT t.id,t.name FROM auth_tenants t JOIN auth_user_tenants ut ON ut.tenant_id=t.id WHERE ut.user_id=" + sql(user.id) + " ORDER BY t.name COLLATE NOCASE;", options) })), null, 2));
13
+ }
14
+ if (!username) throw new Error("A username, email, subject, or user id is required.");
15
+ const where = `(id=${sql(username)} OR email=${sql(username)} OR subject=${sql(username)})`;
16
+ const rows = query(options.database, target, `SELECT id,email,display_name,provider,subject,is_admin,created_at,updated_at FROM auth_users WHERE ${where} LIMIT 1;`, options);
17
+ if (!rows[0]) throw new Error(`User not found: ${username}`);
18
+ if (action === "get") {
19
+ const user = rows[0];
20
+ const scopes = query(options.database, target, `SELECT scope_name,granted_at FROM auth_user_scopes WHERE user_id=${sql(user.id)} ORDER BY scope_name;`, options);
21
+ const groups = query(options.database, target, `SELECT group_name,granted_at FROM auth_user_groups WHERE user_id=${sql(user.id)} ORDER BY group_name;`, options);
22
+ const tenants = query(options.database, target, `SELECT t.id,t.name,ut.joined_at FROM auth_tenants t JOIN auth_user_tenants ut ON ut.tenant_id=t.id WHERE ut.user_id=${sql(user.id)} ORDER BY t.name COLLATE NOCASE;`, options);
23
+ return console.log(JSON.stringify({ ...user, scopes, groups, tenants }, null, 2));
24
+ }
25
+ if (action !== "update") throw new Error("Unknown user action.");
26
+ const assignments = [];
27
+ if (options.roles !== undefined) assignments.push(`is_admin=${csv(options.roles).includes("admin") ? 1 : 0}`);
28
+ if (!assignments.length && options.scopes === undefined && options.groups === undefined && options.tenants === undefined) throw new Error("user:update requires --roles, --scopes, --groups, or --tenants.");
29
+ if (assignments.length) query(options.database, target, `UPDATE auth_users SET ${assignments.join(",")},updated_at=CURRENT_TIMESTAMP WHERE id=${sql(rows[0].id)};`, options);
30
+ if (options.scopes !== undefined) { query(options.database, target, "DELETE FROM auth_user_scopes WHERE user_id=" + sql(rows[0].id) + ";", options); for (const scope of csv(options.scopes)) query(options.database, target, "INSERT OR IGNORE INTO auth_user_scopes (user_id,scope_name,granted_by) VALUES (" + sql(rows[0].id) + "," + sql(scope) + ",'human:cli');", options); }
31
+ if (options.groups !== undefined) { query(options.database, target, "DELETE FROM auth_user_groups WHERE user_id=" + sql(rows[0].id) + ";", options); for (const group of csv(options.groups)) query(options.database, target, "INSERT OR IGNORE INTO auth_user_groups (user_id,group_name,granted_by) VALUES (" + sql(rows[0].id) + "," + sql(group) + ",'human:cli');", options); }
32
+ if (options.tenants !== undefined) { const tenants = csv(options.tenants); if (tenants.length) { const available = query(options.database, target, "SELECT id FROM auth_tenants WHERE id IN (" + tenants.map(sql).join(",") + ");", options); const found = new Set(available.map((tenant) => tenant.id)); if (found.size !== tenants.length) throw new Error("One or more tenants do not exist."); } query(options.database, target, "DELETE FROM auth_user_tenants WHERE user_id=" + sql(rows[0].id) + ";", options); for (const tenant of tenants) query(options.database, target, "INSERT OR IGNORE INTO auth_user_tenants (user_id,tenant_id) VALUES (" + sql(rows[0].id) + "," + sql(tenant) + ");", options); }
33
+ return runUserCommand({ action: "get", username: rows[0].id, options });
34
+ }
@@ -0,0 +1,54 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+
4
+ export const PACKAGE_NAME = "@agilesyndrome/cf-genai-base";
5
+
6
+ export function localVersion() {
7
+ const packageUrl = new URL("../../package.json", import.meta.url);
8
+ return JSON.parse(readFileSync(packageUrl, "utf8")).version;
9
+ }
10
+
11
+ export function latestVersion(packageName = PACKAGE_NAME) {
12
+ const result = spawnSync("npm", ["view", packageName, "version"], {
13
+ stdio: ["ignore", "pipe", "pipe"],
14
+ encoding: "utf8",
15
+ });
16
+ if (result.error || result.status !== 0) return null;
17
+ const value = result.stdout.trim();
18
+ return value || null;
19
+ }
20
+
21
+ function parsedVersion(value) {
22
+ const match = String(value).trim().replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);
23
+ if (!match) return null;
24
+ return {
25
+ numbers: match.slice(1, 4).map(Number),
26
+ prerelease: match[4]?.split(".") ?? [],
27
+ };
28
+ }
29
+
30
+ export function compareVersions(left, right) {
31
+ const a = parsedVersion(left);
32
+ const b = parsedVersion(right);
33
+ if (!a || !b) return String(left).localeCompare(String(right));
34
+ for (let index = 0; index < 3; index += 1) {
35
+ if (a.numbers[index] !== b.numbers[index]) return a.numbers[index] - b.numbers[index];
36
+ }
37
+ if (a.prerelease.length === 0 && b.prerelease.length > 0) return 1;
38
+ if (a.prerelease.length > 0 && b.prerelease.length === 0) return -1;
39
+ return String(a.prerelease).localeCompare(String(b.prerelease), undefined, { numeric: true });
40
+ }
41
+
42
+ export function formatVersionOutput({ local, latest, packageName = PACKAGE_NAME }) {
43
+ const lines = [`${packageName} local: ${local}`, `${packageName} latest: ${latest ?? "unavailable"}`];
44
+ if (latest && compareVersions(latest, local) > 0) {
45
+ lines.push("Upgrade available:", ` npm i -g ${packageName}`);
46
+ }
47
+ return lines.join("\n");
48
+ }
49
+
50
+ export function runVersionCommand() {
51
+ const local = localVersion();
52
+ const latest = latestVersion();
53
+ console.log(formatVersionOutput({ local, latest }));
54
+ }
@@ -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";