@agilesyndrome/cf-genai-base 0.2.5 → 1.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.
package/README.md CHANGED
@@ -33,3 +33,10 @@ Features expose `middleware(request, env, ctx, next, state)` and may short-circu
33
33
  requests, and optionally deliver server-side PostHog events. Use
34
34
  `assertBoot(env, { bindings: ["DB"], required: ["AUTH_SESSION_SECRET"] })` in a
35
35
  site initializer to fail closed when its Cloudflare configuration is incomplete.
36
+
37
+
38
+ ## Core operational services
39
+
40
+ Apply `migrations/0002_core.sql` after the authorization migration. The package exports `registerHealthcheck`, `updateHealthcheck`, `registerCircuitBreaker`, `setCircuitBreaker`, and `evaluateCircuitBreaker` from `/cf-genai-base`. Healthchecks use `red`, `yellow` (unknown/transient), or `green`; breakers use `off`, `tripped`, or `on`, with `any` or `all` healthcheck evaluation. Automated evaluation may only move `on` to `tripped`, or self-healing `tripped` to `on`; admin API writes are the human control plane for the `off` state.
41
+
42
+ Admin APIs are `GET /api/admin/healthchecks`, `PUT /api/admin/healthchecks/:id`, `GET /api/admin/circuit-breakers`, `GET|PUT /api/admin/circuit-breakers/:id`. Feature manifests may expose `healthchecks` and `circuitBreakers`. Use `createD1(env, { who })` for downstream D1 calls; it emits EventLog and AuditLog console records with the requesting actor.
@@ -0,0 +1,45 @@
1
+ CREATE TABLE IF NOT EXISTS core_healthchecks (
2
+ id TEXT PRIMARY KEY,
3
+ feature TEXT NOT NULL,
4
+ component TEXT NOT NULL,
5
+ display_name TEXT NOT NULL,
6
+ state TEXT NOT NULL DEFAULT 'yellow' CHECK (state IN ('red', 'yellow', 'green')),
7
+ metadata_json TEXT NOT NULL DEFAULT '{}',
8
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
9
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
10
+ UNIQUE(feature, component)
11
+ );
12
+
13
+ CREATE INDEX IF NOT EXISTS core_healthchecks_state_idx ON core_healthchecks(state);
14
+
15
+ CREATE TABLE IF NOT EXISTS core_circuit_breakers (
16
+ id TEXT PRIMARY KEY,
17
+ feature TEXT NOT NULL,
18
+ name TEXT NOT NULL,
19
+ display_name TEXT NOT NULL,
20
+ state TEXT NOT NULL DEFAULT 'off' CHECK (state IN ('off', 'tripped', 'on')),
21
+ healthcheck_mode TEXT NOT NULL DEFAULT 'any' CHECK (healthcheck_mode IN ('any', 'all')),
22
+ allow_self_healing INTEGER NOT NULL DEFAULT 0 CHECK (allow_self_healing IN (0, 1)),
23
+ metadata_json TEXT NOT NULL DEFAULT '{}',
24
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
25
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
26
+ UNIQUE(feature, name)
27
+ );
28
+
29
+ CREATE TABLE IF NOT EXISTS core_circuit_breaker_healthchecks (
30
+ circuit_breaker_id TEXT NOT NULL REFERENCES core_circuit_breakers(id) ON DELETE CASCADE,
31
+ healthcheck_id TEXT NOT NULL REFERENCES core_healthchecks(id) ON DELETE CASCADE,
32
+ PRIMARY KEY(circuit_breaker_id, healthcheck_id)
33
+ );
34
+
35
+ CREATE INDEX IF NOT EXISTS core_circuit_breaker_healthchecks_healthcheck_idx
36
+ ON core_circuit_breaker_healthchecks(healthcheck_id);
37
+
38
+ CREATE TABLE IF NOT EXISTS core_circuit_breaker_dependencies (
39
+ circuit_breaker_id TEXT NOT NULL REFERENCES core_circuit_breakers(id) ON DELETE CASCADE,
40
+ dependency_id TEXT NOT NULL REFERENCES core_circuit_breakers(id) ON DELETE CASCADE,
41
+ PRIMARY KEY(circuit_breaker_id, dependency_id)
42
+ );
43
+
44
+ CREATE INDEX IF NOT EXISTS core_circuit_breaker_dependencies_dependency_idx
45
+ ON core_circuit_breaker_dependencies(dependency_id);
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@agilesyndrome/cf-genai-base",
3
- "version": "0.2.5",
3
+ "version": "1.0.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.js",
7
7
  "./authorization": "./src/authorization.js",
8
+ "./core": "./src/core.js",
8
9
  "./ui": "./src/ui/index.js",
9
10
  "./ui/styles.css": "./src/ui/styles.css"
10
11
  },
@@ -1,3 +1,5 @@
1
+ import { createD1 } from "./core.js";
2
+
1
3
  export const AUTH_USER_TABLE = "auth_users";
2
4
  export const AUTH_SCOPE_TABLE = "auth_scopes";
3
5
  export const AUTH_GRANT_TABLE = "auth_user_scopes";
@@ -8,58 +10,65 @@ export function normalizeScopes(scopes = []) {
8
10
  .map((scope) => ({ name: String(scope.name), label: String(scope.label || scope.name), description: String(scope.description || ""), system: Boolean(scope.system) }));
9
11
  }
10
12
 
11
- export async function ensureScopes(env, scopes = []) {
13
+ export async function ensureScopes(env, scopes = [], { who = "system:read" } = {}) {
12
14
  if (!env?.DB) return;
15
+ const db = createD1(env, { who });
13
16
  for (const scope of normalizeScopes(scopes)) {
14
- await env.DB.prepare(`INSERT INTO ${AUTH_SCOPE_TABLE} (name,label,description,system) VALUES (?,?,?,?) ON CONFLICT(name) DO UPDATE SET label=excluded.label,description=excluded.description,system=excluded.system`).bind(scope.name, scope.label, scope.description, scope.system ? 1 : 0).run();
17
+ await db.prepare(`INSERT INTO ${AUTH_SCOPE_TABLE} (name,label,description,system) VALUES (?,?,?,?) ON CONFLICT(name) DO UPDATE SET label=excluded.label,description=excluded.description,system=excluded.system`).bind(scope.name, scope.label, scope.description, scope.system ? 1 : 0).run();
15
18
  }
16
19
  }
17
20
 
18
- export async function ensureUser(env, user) {
21
+ export async function ensureUser(env, user, { who = "system:read" } = {}) {
19
22
  if (!env?.DB || !user?.sub) return null;
23
+ const db = createD1(env, { who });
20
24
  const provider = String(user.auth_strategy || "oauth");
21
25
  const subject = String(user.sub);
22
26
  const email = String(user.email || "").trim().toLowerCase();
23
- const existing = await env.DB.prepare(`SELECT * FROM ${AUTH_USER_TABLE} WHERE provider=? AND subject=?`).bind(provider, subject).first();
27
+ const existing = await db.prepare(`SELECT * FROM ${AUTH_USER_TABLE} WHERE provider=? AND subject=?`).bind(provider, subject).first();
24
28
  const bootstrap = new Set(String(env.AUTH_ADMIN_EMAILS || env.ADMIN_EMAILS || "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean));
25
29
  if (existing) {
26
- await env.DB.prepare(`UPDATE ${AUTH_USER_TABLE} SET email=?,display_name=?,is_admin=CASE WHEN is_admin=1 OR ? THEN 1 ELSE 0 END,updated_at=CURRENT_TIMESTAMP WHERE id=?`).bind(email, String(user.name || email || subject), bootstrap.has(email) ? 1 : 0, existing.id).run();
30
+ await db.prepare(`UPDATE ${AUTH_USER_TABLE} SET email=?,display_name=?,is_admin=CASE WHEN is_admin=1 OR ? THEN 1 ELSE 0 END,updated_at=CURRENT_TIMESTAMP WHERE id=?`).bind(email, String(user.name || email || subject), bootstrap.has(email) ? 1 : 0, existing.id).run();
27
31
  return { ...existing, email, display_name: String(user.name || email || subject), is_admin: Boolean(existing.is_admin || bootstrap.has(email)) };
28
32
  }
29
33
  const id = await stableId(`${provider}:${subject}`);
30
- await env.DB.prepare(`INSERT INTO ${AUTH_USER_TABLE} (id,provider,subject,email,display_name,is_admin) VALUES (?,?,?,?,?,?) ON CONFLICT(provider,subject) DO NOTHING`).bind(id, provider, subject, email, String(user.name || email || subject), bootstrap.has(email) ? 1 : 0).run();
31
- return await env.DB.prepare(`SELECT * FROM ${AUTH_USER_TABLE} WHERE id=?`).bind(id).first();
34
+ await db.prepare(`INSERT INTO ${AUTH_USER_TABLE} (id,provider,subject,email,display_name,is_admin) VALUES (?,?,?,?,?,?) ON CONFLICT(provider,subject) DO NOTHING`).bind(id, provider, subject, email, String(user.name || email || subject), bootstrap.has(email) ? 1 : 0).run();
35
+ return await db.prepare(`SELECT * FROM ${AUTH_USER_TABLE} WHERE id=?`).bind(id).first();
32
36
  }
33
37
 
34
- export async function hasScope(env, user, scope) {
38
+ export async function hasScope(env, user, scope, { who = "system:read" } = {}) {
39
+ const db = createD1(env, { who });
35
40
  if (user?.auth_strategy === "http_basic") return true;
36
- const authUser = await ensureUser(env, user);
41
+ const authUser = await ensureUser(env, user, { who });
37
42
  if (!authUser) return false;
38
43
  if (Boolean(authUser.is_admin)) return true;
39
- return Boolean(await env.DB.prepare(`SELECT 1 FROM ${AUTH_GRANT_TABLE} WHERE user_id=? AND scope_name=?`).bind(authUser.id, scope).first());
44
+ return Boolean(await db.prepare(`SELECT 1 FROM ${AUTH_GRANT_TABLE} WHERE user_id=? AND scope_name=?`).bind(authUser.id, scope).first());
40
45
  }
41
46
 
42
- export async function listAuthorizationUsers(env) {
43
- const { results } = await env.DB.prepare(`SELECT id,email,display_name,provider,subject,is_admin,created_at,updated_at FROM ${AUTH_USER_TABLE} ORDER BY email COLLATE NOCASE`).all();
44
- return Promise.all(results.map(async (user) => ({ ...user, scopes: (await listUserGrants(env, user.id)).map((grant) => grant.scope_name) })));
47
+ export async function listAuthorizationUsers(env, { who = "system:read" } = {}) {
48
+ const db = createD1(env, { who });
49
+ const { results } = await db.prepare(`SELECT id,email,display_name,provider,subject,is_admin,created_at,updated_at FROM ${AUTH_USER_TABLE} ORDER BY email COLLATE NOCASE`).all();
50
+ return Promise.all(results.map(async (user) => ({ ...user, scopes: (await listUserGrants(env, user.id, { who })).map((grant) => grant.scope_name) })));
45
51
  }
46
52
 
47
- export async function listAuthorizationScopes(env) {
48
- const { results } = await env.DB.prepare(`SELECT name,label,description,system FROM ${AUTH_SCOPE_TABLE} ORDER BY name`).all();
53
+ export async function listAuthorizationScopes(env, { who = "system:read" } = {}) {
54
+ const db = createD1(env, { who });
55
+ const { results } = await db.prepare(`SELECT name,label,description,system FROM ${AUTH_SCOPE_TABLE} ORDER BY name`).all();
49
56
  return results;
50
57
  }
51
58
 
52
- export async function listUserGrants(env, userId) {
53
- const { results } = await env.DB.prepare(`SELECT scope_name,granted_at FROM ${AUTH_GRANT_TABLE} WHERE user_id=? ORDER BY scope_name`).bind(userId).all();
59
+ export async function listUserGrants(env, userId, { who = "system:read" } = {}) {
60
+ const db = createD1(env, { who });
61
+ const { results } = await db.prepare(`SELECT scope_name,granted_at FROM ${AUTH_GRANT_TABLE} WHERE user_id=? ORDER BY scope_name`).bind(userId).all();
54
62
  return results;
55
63
  }
56
64
 
57
- export async function replaceUserGrants(env, userId, scopes, grantedBy) {
58
- const valid = new Set((await listAuthorizationScopes(env)).map((scope) => scope.name));
65
+ export async function replaceUserGrants(env, userId, scopes, grantedBy, { who = "system:read" } = {}) {
66
+ const valid = new Set((await listAuthorizationScopes(env, { who })).map((scope) => scope.name));
67
+ const db = createD1(env, { who });
59
68
  const requested = [...new Set(scopes)].filter((scope) => valid.has(scope));
60
- await env.DB.batch([
61
- env.DB.prepare(`DELETE FROM ${AUTH_GRANT_TABLE} WHERE user_id=?`).bind(userId),
62
- ...requested.map((scope) => env.DB.prepare(`INSERT INTO ${AUTH_GRANT_TABLE} (user_id,scope_name,granted_by) VALUES (?,?,?)`).bind(userId, scope, grantedBy || null))
69
+ await db.batch([
70
+ db.prepare(`DELETE FROM ${AUTH_GRANT_TABLE} WHERE user_id=?`).bind(userId),
71
+ ...requested.map((scope) => db.prepare(`INSERT INTO ${AUTH_GRANT_TABLE} (user_id,scope_name,granted_by) VALUES (?,?,?)`).bind(userId, scope, grantedBy || null))
63
72
  ]);
64
73
  return requested;
65
74
  }
package/src/core.js ADDED
@@ -0,0 +1,145 @@
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
+
5
+ export function eventLog(level, event, details = {}) {
6
+ const method = ["debug", "info", "warn", "error"].includes(level) ? level : "info";
7
+ console[method](`[EventLog] ${event}`, details);
8
+ }
9
+
10
+ export function auditLog({ who = "system", operation, resource, details = {} }) {
11
+ console.info(`[AuditLog] ${who}:${operation} ${resource}`, details);
12
+ }
13
+
14
+ export function requestActor(state = {}) {
15
+ if (state.requestedBy) return String(state.requestedBy);
16
+ if (state.authUser?.id) return `user:${state.authUser.id}`;
17
+ if (state.user?.auth_strategy === "http_basic") return "user:admin";
18
+ if (state.user?.sub) return `user:${state.user.sub}`;
19
+ return "system:read";
20
+ }
21
+
22
+ export function createD1(env, { who = "system:read" } = {}) {
23
+ if (!env?.DB) throw new Error("A DB binding is required");
24
+ const db = env.DB;
25
+ return {
26
+ prepare(sql) {
27
+ const statement = db.prepare(sql);
28
+ return new Proxy(statement, { get(target, property) {
29
+ const value = target[property];
30
+ if (typeof value !== "function" || !["run", "first", "all", "raw"].includes(property)) return typeof value === "function" ? value.bind(target) : value;
31
+ return async (...args) => {
32
+ auditD1(who, sql);
33
+ return value.apply(target, args);
34
+ };
35
+ }});
36
+ },
37
+ async batch(statements) {
38
+ auditD1(who, "BATCH");
39
+ return typeof db.batch === "function" ? db.batch(statements) : Promise.all(statements.map((statement) => statement.run()));
40
+ },
41
+ };
42
+ }
43
+
44
+ function auditD1(who, sql) {
45
+ const operation = String(sql).trim().match(/^(SELECT|INSERT|UPDATE|DELETE|REPLACE|WITH)/i)?.[1]?.toLowerCase() || "execute";
46
+ eventLog("debug", "d1.operation", { who, operation });
47
+ auditLog({ who, operation, resource: "d1" });
48
+ }
49
+
50
+ export function normalizeHealthcheck(input = {}) {
51
+ const state = String(input.state || "yellow").toLowerCase();
52
+ if (!HEALTHCHECK_STATES.includes(state)) throw new Error("Healthcheck state must be red, yellow, or green");
53
+ if (!input.feature || !input.component || !input.displayName) throw new Error("Healthchecks require feature, component, and displayName");
54
+ return { feature: String(input.feature), component: String(input.component), displayName: String(input.displayName), state, metadata: input.metadata || {} };
55
+ }
56
+
57
+ export function normalizeCircuitBreaker(input = {}) {
58
+ const state = String(input.state || "off").toLowerCase();
59
+ const healthcheckMode = String(input.healthcheckMode || "any").toLowerCase();
60
+ if (!CIRCUIT_BREAKER_STATES.includes(state)) throw new Error("Circuit breaker state must be off, tripped, or on");
61
+ if (!HEALTHCHECK_MODES.includes(healthcheckMode)) throw new Error("Circuit breaker healthcheckMode must be any or all");
62
+ if (!input.feature || !input.name || !input.displayName) throw new Error("Circuit breakers require feature, name, and displayName");
63
+ return { feature: String(input.feature), name: String(input.name), displayName: String(input.displayName), state, healthcheckMode, allowSelfHealing: Boolean(input.allowSelfHealing), healthchecks: Array.isArray(input.healthchecks) ? input.healthchecks.map(String) : [], dependsOnCircuitBreakers: Array.isArray(input.dependsOnCircuitBreakers || input.dependencies) ? (input.dependsOnCircuitBreakers || input.dependencies).map(String) : [], metadata: input.metadata || {} };
64
+ }
65
+
66
+ export async function registerHealthcheck(env, input, { who = "system:read" } = {}) {
67
+ const item = normalizeHealthcheck(input);
68
+ const db = createD1(env, { who });
69
+ const id = String(input.id || `${item.feature}:${item.component}`);
70
+ 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();
71
+ return getHealthcheck(env, id, { who });
72
+ }
73
+
74
+ export async function updateHealthcheck(env, id, state, { who = "system:read" } = {}) {
75
+ if (!HEALTHCHECK_STATES.includes(String(state).toLowerCase())) throw new Error("Healthcheck state must be red, yellow, or green");
76
+ const item = await getHealthcheck(env, id, { who });
77
+ if (!item) return null;
78
+ const db = createD1(env, { who });
79
+ await db.prepare("UPDATE core_healthchecks SET state=?,updated_at=CURRENT_TIMESTAMP WHERE id=?").bind(String(state).toLowerCase(), id).run();
80
+ auditLog({ who, operation: "update", resource: `feature:${item.feature} component:${item.component} ${String(state).toLowerCase()}` });
81
+ return getHealthcheck(env, id, { who });
82
+ }
83
+
84
+ export async function getHealthcheck(env, id, { who = "system:read" } = {}) { return (await createD1(env, { who }).prepare("SELECT * FROM core_healthchecks WHERE id=?").bind(id).first()) || null; }
85
+ 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 || []; }
86
+
87
+ export async function registerCircuitBreaker(env, input, { who = "system:read" } = {}) {
88
+ const item = normalizeCircuitBreaker(input);
89
+ const db = createD1(env, { who });
90
+ const id = String(input.id || `${item.feature}:${item.name}`);
91
+ 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();
92
+ 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))]);
93
+ return getCircuitBreaker(env, id, { who });
94
+ }
95
+
96
+ 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; }
97
+ 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 }))); }
98
+
99
+ export async function setCircuitBreaker(env, id, state, { who = "system:read", automated = false } = {}) {
100
+ const next = String(state).toLowerCase();
101
+ if (!CIRCUIT_BREAKER_STATES.includes(next)) throw new Error("Circuit breaker state must be off, tripped, or on");
102
+ const current = await getCircuitBreaker(env, id, { who });
103
+ if (!current) return null;
104
+ if (automated && !((current.state === "on" && next === "tripped") || (current.state === "tripped" && next === "on" && Boolean(current.allow_self_healing)))) return current;
105
+ if (automated && next === "off") return current;
106
+ await createD1(env, { who }).prepare("UPDATE core_circuit_breakers SET state=?,updated_at=CURRENT_TIMESTAMP WHERE id=?").bind(next, id).run();
107
+ auditLog({ who, operation: "update", resource: `feature:${current.feature} circuit_breaker:${current.name} ${next}` });
108
+ return getCircuitBreaker(env, id, { who });
109
+ }
110
+
111
+ export async function evaluateCircuitBreaker(env, id, { who = "system:read" } = {}) {
112
+ const breaker = await getCircuitBreaker(env, id, { who });
113
+ if (!breaker) return null;
114
+ const db = createD1(env, { who });
115
+ 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 || [];
116
+ const failing = rows.map((row) => row.state === "red");
117
+ 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 || [];
118
+ const dependencyFailed = dependencies.some((row) => row.state === "tripped");
119
+ const shouldTrip = dependencyFailed || (failing.length > 0 && (breaker.healthcheck_mode === "all" ? failing.every(Boolean) : failing.some(Boolean)));
120
+ if (breaker.state === "on" && shouldTrip) return setCircuitBreaker(env, id, "tripped", { who, automated: true });
121
+ if (breaker.state === "tripped" && !shouldTrip && breaker.allow_self_healing) return setCircuitBreaker(env, id, "on", { who, automated: true });
122
+ return breaker;
123
+ }
124
+
125
+
126
+ export async function registerFeatureManifests(env, features = [], { who = "system:read" } = {}) {
127
+ for (const feature of features) {
128
+ const name = String(feature?.name || feature?.id || "feature");
129
+ const declaredResult = typeof feature?.healthcheck === "function" ? await feature.healthcheck(env, { who }) : (feature?.healthchecks || feature?.healthChecks || []);
130
+ const declaredHealthchecks = Array.isArray(declaredResult) ? declaredResult : [declaredResult];
131
+ const healthchecks = [];
132
+ const breakers = [];
133
+ for (const healthcheck of declaredHealthchecks) healthchecks.push(await registerHealthcheck(env, { ...healthcheck, feature: healthcheck.feature || name }, { who }));
134
+ for (const breaker of feature?.circuitBreakers || feature?.circuit_breakers || []) breakers.push(await registerCircuitBreaker(env, { ...breaker, feature: breaker.feature || name }, { who }));
135
+ 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 });
136
+ }
137
+ }
138
+
139
+ export async function listFeatureHealth(env, { who = "system:read" } = {}) {
140
+ const checks = await listHealthchecks(env, { who });
141
+ const severity = { green: 0, yellow: 1, red: 2 };
142
+ const features = {};
143
+ 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; }
144
+ return Object.values(features).sort((a, b) => a.feature.localeCompare(b.feature));
145
+ }
package/src/index.js CHANGED
@@ -3,6 +3,8 @@
3
3
  * Site code owns domain routes and data; this owns lifecycle and edge concerns.
4
4
  */
5
5
  import { ensureScopes, ensureUser, hasScope, listAuthorizationScopes, listAuthorizationUsers, listUserGrants, replaceUserGrants } from "./authorization.js";
6
+ import { getCircuitBreaker, evaluateCircuitBreaker, listCircuitBreakers, listHealthchecks, listFeatureHealth, registerFeatureManifests, requestActor, setCircuitBreaker, updateHealthcheck } from "./core.js";
7
+ export * from "./core.js";
6
8
  export function createWorker({ fetch, scheduled, auth, authorize, scopes = [], scopeRoutes = [], middleware = [], features = [], health, boot, metrics, security = true }) {
7
9
  if (typeof fetch !== "function") throw new TypeError("createWorker requires a fetch handler");
8
10
  const provider = auth || features.find((feature) => typeof feature?.getUser === "function");
@@ -18,12 +20,14 @@ export function createWorker({ fetch, scheduled, auth, authorize, scopes = [], s
18
20
  if (boot) await boot(env, { request, ctx });
19
21
  const url = new URL(request.url);
20
22
  const state = Object.create(null);
23
+ if (env?.DB && features.some((feature) => typeof feature?.healthcheck === "function" || feature?.healthchecks?.length || feature?.healthChecks?.length || feature?.circuitBreakers?.length || feature?.circuit_breakers?.length)) ctx?.waitUntil?.(registerFeatureManifests(env, features, { who: "system:update" }).then(() => listCircuitBreakers(env, { who: "system:update" }).then((breakers) => Promise.all(breakers.filter(Boolean).map((breaker) => evaluateCircuitBreaker(env, breaker.id, { who: "system:update" }))))).catch((error) => console.error("[EventLog] feature manifest registration failed", error)));
21
24
  const dispatch = async (index, currentRequest = request) => {
22
25
  const layer = chain[index];
23
26
  if (!layer) {
24
27
  if (url.pathname === "/health" || url.pathname === "/api/health") {
25
28
  const details = health ? await health(env, { request: currentRequest, ctx, state }) : {};
26
- return healthResponse(env, details);
29
+ const featureHealth = env?.DB ? await listFeatureHealth(env, { who: "system:read" }).catch(() => []) : [];
30
+ return healthResponse(env, featureHealth.length ? { ...details, features: featureHealth } : details);
27
31
  }
28
32
  return fetch(currentRequest, env, ctx, state);
29
33
  }
@@ -58,10 +62,11 @@ async function adminBoundary(request, env, ctx, next, state, { provider, authori
58
62
  } else {
59
63
  return new Response("Unsupported AUTH_STRATEGY", { status: 500, headers: { "Cache-Control": "no-store" } });
60
64
  }
61
- await ensureScopes(env, scopes);
62
- state.authUser = await ensureUser(env, state.user);
65
+ await ensureScopes(env, scopes, { who: state.user?.auth_strategy === "http_basic" ? "user:admin" : `user:${state.user?.sub || "unknown"}` });
66
+ state.authUser = await ensureUser(env, state.user, { who: state.user?.auth_strategy === "http_basic" ? "user:admin" : `user:${state.user?.sub || "unknown"}` });
67
+ state.requestedBy = requestActor(state);
63
68
  const requiredScope = requiredScopeFor(url.pathname, scopeRoutes);
64
- const scopeAllowed = !requiredScope || await hasScope(env, state.user, requiredScope);
69
+ const scopeAllowed = !requiredScope || await hasScope(env, state.user, requiredScope, { who: requestActor(state) });
65
70
  if (!scopeAllowed || (authorize && state.user.auth_strategy !== "http_basic" && !(await authorize({ request, url, user: state.user, env, ctx, state })))) {
66
71
  return url.pathname.startsWith("/api/") ? Response.json({ error: "Administrator access is required." }, { status: 403, headers: { "Cache-Control": "no-store" } }) : new Response("Administrator access is required.", { status: 403, headers: { "Cache-Control": "no-store" } });
67
72
  }
@@ -76,16 +81,24 @@ function requiredScopeFor(pathname, routes) {
76
81
 
77
82
  async function authorizationApi(request, env, url, state) {
78
83
  const grantsMatch = url.pathname.match(/\/api\/admin\/users\/([^/]+)\/scopes$/);
79
- const platformPath = url.pathname === "/api/admin/users" || url.pathname === "/api/admin/scopes" || Boolean(grantsMatch);
84
+ const platformPath = url.pathname === "/api/admin/users" || url.pathname === "/api/admin/scopes" || url.pathname === "/api/admin/status" || url.pathname === "/api/admin/healthchecks" || url.pathname === "/api/admin/circuit-breakers" || url.pathname.startsWith("/api/admin/healthchecks/") || url.pathname.startsWith("/api/admin/circuit-breakers/") || Boolean(grantsMatch);
80
85
  if (!platformPath) return null;
81
86
  if (!(state.user.auth_strategy === "http_basic" || (state.authUser && state.authUser.is_admin))) return Response.json({ error: "Administrator access is required." }, { status: 403, headers: { "Cache-Control": "no-store" } });
82
- if (url.pathname === "/api/admin/users" && request.method === "GET") return Response.json({ users: await listAuthorizationUsers(env) });
83
- if (url.pathname === "/api/admin/scopes" && request.method === "GET") return Response.json({ scopes: await listAuthorizationScopes(env) });
84
- if (grantsMatch && request.method === "GET") return Response.json({ grants: await listUserGrants(env, decodeURIComponent(grantsMatch[1])) });
87
+ if (url.pathname === "/api/admin/users" && request.method === "GET") return Response.json({ users: await listAuthorizationUsers(env, { who: requestActor(state) }) });
88
+ if (url.pathname === "/api/admin/scopes" && request.method === "GET") return Response.json({ scopes: await listAuthorizationScopes(env, { who: requestActor(state) }) });
89
+ if (url.pathname === "/api/admin/status" && request.method === "GET") return Response.json({ features: await listFeatureHealth(env, { who: requestActor(state) }) });
90
+ if (url.pathname === "/api/admin/healthchecks" && request.method === "GET") return Response.json({ healthchecks: await listHealthchecks(env, { who: requestActor(state) }) });
91
+ if (url.pathname === "/api/admin/circuit-breakers" && request.method === "GET") return Response.json({ circuit_breakers: await listCircuitBreakers(env, { who: requestActor(state) }) });
92
+ const healthcheckMatch = url.pathname.match(/\/api\/admin\/healthchecks\/([^/]+)$/);
93
+ if (healthcheckMatch && request.method === "PUT") { const body = await request.json().catch(() => null); if (!body?.state) return Response.json({ error: "state is required" }, { status: 400 }); const healthcheck = await updateHealthcheck(env, decodeURIComponent(healthcheckMatch[1]), body.state, { who: requestActor(state) }); return healthcheck ? Response.json({ healthcheck }) : Response.json({ error: "Healthcheck not found" }, { status: 404 }); }
94
+ const breakerMatch = url.pathname.match(/\/api\/admin\/circuit-breakers\/([^/]+)$/);
95
+ if (breakerMatch && request.method === "GET") return Response.json({ circuit_breaker: await getCircuitBreaker(env, decodeURIComponent(breakerMatch[1]), { who: requestActor(state) }) });
96
+ if (breakerMatch && request.method === "PUT") { const body = await request.json().catch(() => null); if (!body?.state) return Response.json({ error: "state is required" }, { status: 400 }); const breaker = await setCircuitBreaker(env, decodeURIComponent(breakerMatch[1]), body.state, { who: requestActor(state) }); return breaker ? Response.json({ circuit_breaker: breaker }) : Response.json({ error: "Circuit breaker not found" }, { status: 404 }); }
97
+ if (grantsMatch && request.method === "GET") return Response.json({ grants: await listUserGrants(env, decodeURIComponent(grantsMatch[1]), { who: requestActor(state) }) });
85
98
  if (grantsMatch && request.method === "PUT") {
86
99
  const body = await request.json().catch(() => null);
87
100
  if (!body || !Array.isArray(body.scopes)) return Response.json({ error: "scopes must be an array" }, { status: 400 });
88
- const grants = await replaceUserGrants(env, decodeURIComponent(grantsMatch[1]), body.scopes, state.authUser && state.authUser.id);
101
+ const grants = await replaceUserGrants(env, decodeURIComponent(grantsMatch[1]), body.scopes, state.authUser && state.authUser.id, { who: requestActor(state) });
89
102
  return Response.json({ grants });
90
103
  }
91
104
  return null;
package/src/ui/index.js CHANGED
@@ -3,7 +3,7 @@ const styles = `:host { --cf-ui-bg:#fff; --cf-ui-surface:#f7f7f5; --cf-ui-text:#
3
3
  export class CfAdminShell extends HTMLElement {
4
4
  connectedCallback() {
5
5
  const active = this.getAttribute("active") || "";
6
- this.attachShadow({ mode: "open" }).innerHTML = `<style>${styles}</style><div class="shell"><nav class="nav" part="navigation"><a href="/admin" ${active === "home" ? 'aria-current="page"' : ""}>Admin</a><a href="/admin/users" ${active === "users" ? 'aria-current="page"' : ""}>Users</a><a href="/admin/scopes" ${active === "scopes" ? 'aria-current="page"' : ""}>Scopes</a></nav><slot></slot></div>`;
6
+ this.attachShadow({ mode: "open" }).innerHTML = `<style>${styles}</style><div class="shell"><nav class="nav" part="navigation"><a href="/admin" ${active === "home" ? 'aria-current="page"' : ""}>Admin</a><a href="/admin/users" ${active === "users" ? 'aria-current="page"' : ""}>Users</a><a href="/admin/scopes" ${active === "scopes" ? 'aria-current="page"' : ""}>Scopes</a><a href="/admin/healthchecks">Healthchecks</a><a href="/admin/circuit-breakers">Circuit breakers</a></nav><slot></slot></div>`;
7
7
  }
8
8
  }
9
9
 
@@ -49,3 +49,14 @@ export class CfScopeCatalog extends HTMLElement {
49
49
 
50
50
  if (!customElements.get("cf-scope-catalog")) customElements.define("cf-scope-catalog", CfScopeCatalog);
51
51
  if (!customElements.get("cf-scope-catalog")) customElements.define("cf-scope-catalog", CfScopeCatalog);
52
+
53
+ export class CfHealthcheckCatalog extends HTMLElement {
54
+ async connectedCallback() { this.attachShadow({ mode: "open" }).innerHTML = `<style>${styles}</style><section class="card"><h2>Healthchecks</h2><p class="status">Loading…</p><div class="list" hidden></div></section>`; try { const response = await fetch("/api/admin/healthchecks", { credentials: "same-origin" }); if (!response.ok) throw new Error("Unable to load healthchecks."); const items = (await response.json()).healthchecks || []; const list = this.shadowRoot.querySelector(".list"); list.replaceChildren(...items.map((item) => { const row = document.createElement("div"); row.textContent = `${item.display_name} — ${item.feature}/${item.component}: ${item.state}`; list.append(row); return row; })); list.hidden = false; this.shadowRoot.querySelector(".status").textContent = `${items.length} healthcheck${items.length === 1 ? "" : "s"}`; } catch (error) { this.shadowRoot.querySelector(".status").textContent = error.message; } }
55
+ }
56
+
57
+ export class CfCircuitBreakerCatalog extends HTMLElement {
58
+ async connectedCallback() { this.attachShadow({ mode: "open" }).innerHTML = `<style>${styles}</style><section class="card"><h2>Circuit breakers</h2><p class="status">Loading…</p><div class="list" hidden></div></section>`; try { const response = await fetch("/api/admin/circuit-breakers", { credentials: "same-origin" }); if (!response.ok) throw new Error("Unable to load circuit breakers."); const items = (await response.json()).circuit_breakers || []; const list = this.shadowRoot.querySelector(".list"); list.replaceChildren(...items.map((item) => { const row = document.createElement("div"); row.textContent = `${item.display_name} — ${item.feature}/${item.name}: ${item.state}`; list.append(row); return row; })); list.hidden = false; this.shadowRoot.querySelector(".status").textContent = `${items.length} circuit breaker${items.length === 1 ? "" : "s"}`; } catch (error) { this.shadowRoot.querySelector(".status").textContent = error.message; } }
59
+ }
60
+
61
+ if (!customElements.get("cf-healthcheck-catalog")) customElements.define("cf-healthcheck-catalog", CfHealthcheckCatalog);
62
+ if (!customElements.get("cf-circuit-breaker-catalog")) customElements.define("cf-circuit-breaker-catalog", CfCircuitBreakerCatalog);