@agilesyndrome/cf-genai-base 1.0.3 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CONTRACT.md CHANGED
@@ -16,7 +16,7 @@ is optional and must use `ctx.waitUntil` for background work.
16
16
  - Admin routes use `AUTH_STRATEGY`; omitted or empty means `http_basic`. Basic auth accepts username `admin` and the value of `ADMIN_TOKEN` (with `admin_token` supported for compatibility). Missing token means all admin routes return 401.
17
17
  - `AUTH_STRATEGY=oauth` delegates identity establishment to the configured auth provider and uses `authorize` for admin policy.
18
18
  - `scopes` registers an application scope manifest. `scopeRoutes` associates route prefixes or match functions with required scopes.
19
- - Base provides `/api/admin/users`, `/api/admin/scopes`, `/api/admin/groups`, `/api/admin/status`, `/api/admin/healthchecks`, `/api/admin/circuit-breakers`, and `/api/admin/users/:id/scopes|groups` for platform administrators when the authorization and core migrations are installed. The exported UI includes users, scopes, groups, healthchecks, and circuit-breaker catalogs.
19
+ - Base provides `/api/admin/users`, `/api/admin/scopes`, `/api/admin/groups`, `/api/admin/status`, `/api/admin/features`, `/api/admin/healthchecks`, `/api/admin/circuit-breakers`, and `/api/admin/users/:id/scopes|groups` for platform administrators when the authorization and core migrations are installed. `GET /api/admin/features` returns the installed runtime feature manifests, package names and versions, per-feature health rollups, healthchecks, and circuit breakers. The browser route `GET /admin/features` renders that catalog. Feature manifests may provide `name`, `displayName`, `packageName`, and `version`. The exported UI includes users, scopes, groups, healthchecks, and circuit-breaker catalogs.
20
20
  - Public APIs must be explicitly listed in provider-specific auth configuration.
21
21
  - Mutating `/api/*` requests require a same-origin `Origin` header.
22
22
 
package/README.md CHANGED
@@ -39,7 +39,7 @@ site initializer to fail closed when its Cloudflare configuration is incomplete.
39
39
 
40
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
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.
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`, and `GET /api/admin/features`. The browser route `/admin/features` renders the same feature catalog for administrators. The catalog lists each installed runtime feature, its `packageName` and `version`, its most severe healthcheck state, all feature healthchecks, and its circuit breakers (including the feature roll-up breaker). Feature manifests may expose `healthchecks` and `circuitBreakers`; add `displayName`, `packageName`, and `version` to make the installation identity explicit. Use `createD1(env, { who })` for downstream D1 calls; it emits EventLog and AuditLog console records with the requesting actor.
43
43
 
44
44
 
45
45
  ## User administration
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agilesyndrome/cf-genai-base",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.js",
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "homepage": "https://github.com/agilesyndrome/cf-genai-base#readme",
30
30
  "scripts": {
31
- "check": "node --check src/index.js",
31
+ "check": "node --check src/index.js && node --check src/core.js && node --check src/authorization.js",
32
32
  "test": "node --test tests/*.test.mjs",
33
33
  "build": "npm run check && npm test && npm pack --dry-run"
34
34
  }
package/src/core.js CHANGED
@@ -1,6 +1,8 @@
1
1
  export const HEALTHCHECK_STATES = ["red", "yellow", "green"];
2
2
  export const CIRCUIT_BREAKER_STATES = ["off", "tripped", "on"];
3
3
  export const HEALTHCHECK_MODES = ["any", "all"];
4
+ export const BASE_PACKAGE_NAME = "@agilesyndrome/cf-genai-base";
5
+ export const BASE_VERSION = "1.0.3";
4
6
 
5
7
  export function eventLog(level, event, details = {}) {
6
8
  const method = ["debug", "info", "warn", "error"].includes(level) ? level : "info";
@@ -143,3 +145,37 @@ export async function listFeatureHealth(env, { who = "system:read" } = {}) {
143
145
  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
146
  return Object.values(features).sort((a, b) => a.feature.localeCompare(b.feature));
145
147
  }
148
+
149
+
150
+ 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
+ };
162
+ }
163
+
164
+ 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
+ }
171
+ for (const item of healthchecks) if (!manifests.has(item.feature)) manifests.set(item.feature, normalizeFeatureManifest({ name: item.feature }));
172
+ 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
+ });
181
+ }
package/src/index.js CHANGED
@@ -3,13 +3,13 @@
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, listGroups, listUserGroups, listUserGrants, replaceUserGroups, replaceUserGrants } from "./authorization.js";
6
- import { getCircuitBreaker, evaluateCircuitBreaker, listCircuitBreakers, listHealthchecks, listFeatureHealth, registerFeatureManifests, requestActor, setCircuitBreaker, updateHealthcheck } from "./core.js";
6
+ import { getCircuitBreaker, evaluateCircuitBreaker, listCircuitBreakers, listHealthchecks, listFeatureCatalog, listFeatureHealth, registerFeatureManifests, requestActor, setCircuitBreaker, updateHealthcheck } from "./core.js";
7
7
  export * from "./core.js";
8
8
  export function createWorker({ fetch, scheduled, auth, authorize, scopes = [], scopeRoutes = [], middleware = [], features = [], health, boot, metrics, security = true }) {
9
9
  if (typeof fetch !== "function") throw new TypeError("createWorker requires a fetch handler");
10
10
  const provider = auth || features.find((feature) => typeof feature?.getUser === "function");
11
11
  const chain = [
12
- (request, env, ctx, next, state) => adminBoundary(request, env, ctx, next, state, { provider, authorize, scopes, scopeRoutes }),
12
+ (request, env, ctx, next, state) => adminBoundary(request, env, ctx, next, state, { provider, authorize, scopes, scopeRoutes, features }),
13
13
  ...features.flatMap((feature) => feature?.middleware ? [feature.middleware.bind(feature)] : []),
14
14
  ...middleware,
15
15
  ...(auth ? [(request, env, ctx, next) => auth(request, env, ctx, next)] : []),
@@ -47,7 +47,7 @@ export function createWorker({ fetch, scheduled, auth, authorize, scopes = [], s
47
47
  }
48
48
 
49
49
 
50
- async function adminBoundary(request, env, ctx, next, state, { provider, authorize, scopes, scopeRoutes }) {
50
+ async function adminBoundary(request, env, ctx, next, state, { provider, authorize, scopes, scopeRoutes, features }) {
51
51
  const url = new URL(request.url);
52
52
  if (!isAdminPath(url.pathname)) return next(request);
53
53
  const strategy = String(env?.AUTH_STRATEGY || "http_basic").trim().toLowerCase();
@@ -70,8 +70,13 @@ async function adminBoundary(request, env, ctx, next, state, { provider, authori
70
70
  if (!scopeAllowed || (authorize && state.user.auth_strategy !== "http_basic" && !(await authorize({ request, url, user: state.user, env, ctx, state })))) {
71
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" } });
72
72
  }
73
- const platformResponse = await authorizationApi(request, env, url, state);
74
- return platformResponse || next(request);
73
+ const platformResponse = await authorizationApi(request, env, url, state, features);
74
+ if (platformResponse) return platformResponse;
75
+ if (url.pathname === "/admin/features" && request.method === "GET") {
76
+ if (!(state.user.auth_strategy === "http_basic" || (state.authUser && state.authUser.is_admin))) return new Response("Administrator access is required.", { status: 403, headers: { "Cache-Control": "no-store" } });
77
+ return featureCatalogPage(env, features, state);
78
+ }
79
+ return next(request);
75
80
  }
76
81
 
77
82
  function requiredScopeFor(pathname, routes) {
@@ -79,14 +84,15 @@ function requiredScopeFor(pathname, routes) {
79
84
  return route && route.scope ? route.scope : null;
80
85
  }
81
86
 
82
- async function authorizationApi(request, env, url, state) {
87
+ async function authorizationApi(request, env, url, state, features = []) {
83
88
  const grantsMatch = url.pathname.match(/\/api\/admin\/users\/([^/]+)\/scopes$/);
84
- const platformPath = url.pathname === "/api/admin/users" || url.pathname === "/api/admin/scopes" || url.pathname === "/api/admin/groups" || url.pathname.startsWith("/api/admin/users/") || 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);
89
+ const platformPath = url.pathname === "/api/admin/users" || url.pathname === "/api/admin/scopes" || url.pathname === "/api/admin/groups" || url.pathname.startsWith("/api/admin/users/") || url.pathname === "/api/admin/status" || url.pathname === "/api/admin/features" || 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);
85
90
  if (!platformPath) return null;
86
91
  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" } });
87
92
  if (url.pathname === "/api/admin/users" && request.method === "GET") return Response.json({ users: await listAuthorizationUsers(env, { who: requestActor(state) }) });
88
93
  if (url.pathname === "/api/admin/scopes" && request.method === "GET") return Response.json({ scopes: await listAuthorizationScopes(env, { who: requestActor(state) }) });
89
94
  if (url.pathname === "/api/admin/status" && request.method === "GET") return Response.json({ features: await listFeatureHealth(env, { who: requestActor(state) }) });
95
+ if (url.pathname === "/api/admin/features" && request.method === "GET") return Response.json({ features: await listFeatureCatalog(env, features, { who: requestActor(state) }) });
90
96
  if (url.pathname === "/api/admin/groups" && request.method === "GET") return Response.json({ groups: await listGroups(env, { who: requestActor(state) }) });
91
97
  if (url.pathname === "/api/admin/healthchecks" && request.method === "GET") return Response.json({ healthchecks: await listHealthchecks(env, { who: requestActor(state) }) });
92
98
  if (url.pathname === "/api/admin/circuit-breakers" && request.method === "GET") return Response.json({ circuit_breakers: await listCircuitBreakers(env, { who: requestActor(state) }) });
@@ -189,3 +195,22 @@ async function track(env, event, properties, { tokenEnv, host }) {
189
195
  console.error("[metrics] delivery failed", error);
190
196
  }
191
197
  }
198
+
199
+
200
+ async function featureCatalogPage(env, features, state) {
201
+ const catalog = await listFeatureCatalog(env, features, { who: requestActor(state) });
202
+ return new Response(featureCatalogMarkup(catalog), { headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" } });
203
+ }
204
+
205
+ function featureCatalogMarkup(catalog) {
206
+ const rows = catalog.map((item) => {
207
+ const checks = item.healthchecks.length ? "<ul>" + item.healthchecks.map((check) => "<li><strong>" + escapeHtml(check.display_name) + "</strong>: " + escapeHtml(check.state) + "</li>").join("") + "</ul>" : "<span>None registered</span>";
208
+ const breakers = item.circuit_breakers.length ? "<ul>" + item.circuit_breakers.map((breaker) => "<li><strong>" + escapeHtml(breaker.display_name) + "</strong>: " + escapeHtml(breaker.state) + "</li>").join("") + "</ul>" : "<span>None registered</span>";
209
+ const packageLabel = item.package_name ? escapeHtml(item.package_name) : "Unknown package";
210
+ const versionLabel = item.version ? escapeHtml(item.version) : "Unknown version";
211
+ return "<tr><td><strong>" + escapeHtml(item.display_name) + "</strong><br><code>" + escapeHtml(item.feature) + "</code></td><td>" + packageLabel + "<br>" + versionLabel + "</td><td><span class=\"state state-" + escapeHtml(item.health) + "\">" + escapeHtml(item.health) + "</span></td><td>" + (item.circuit_breaker ? escapeHtml(item.circuit_breaker.state) : "None") + "</td><td>" + checks + "</td><td>" + breakers + "</td></tr>";
212
+ }).join("");
213
+ return "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Features</title><style>body{font:15px/1.45 system-ui,sans-serif;color:#20231f;background:#f7f7f5;margin:0;padding:2rem}main{max-width:1200px;margin:auto;background:#fff;padding:1.5rem;border:1px solid #d8ddd5;border-radius:.6rem}nav{display:flex;gap:1rem;margin-bottom:1.5rem}a{color:#2f6f52}table{width:100%;border-collapse:collapse}th,td{padding:.7rem;border-bottom:1px solid #d8ddd5;text-align:left;vertical-align:top}th{font-size:.8rem;color:#687067;text-transform:uppercase}ul{margin:.25rem 0;padding-left:1.2rem}code{color:#687067}.state{font-weight:700}.state-green{color:#26734d}.state-yellow{color:#9a6b00}.state-red{color:#b3261e}</style></head><body><main><nav><a href=\"/admin\">Admin</a><a href=\"/admin/features\" aria-current=\"page\">Features</a><a href=\"/admin/users\">Users</a><a href=\"/admin/groups\">Groups</a></nav><h1>Installed features</h1><p>Runtime modules, package versions, healthchecks, and circuit breakers.</p><table><thead><tr><th>Feature</th><th>Package/version</th><th>Health</th><th>Roll-up breaker</th><th>Healthchecks</th><th>Circuit breakers</th></tr></thead><tbody>" + rows + "</tbody></table></main></body></html>";
214
+ }
215
+
216
+ function escapeHtml(value) { return String(value ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\"", "&quot;").replaceAll(String.fromCharCode(39), "&#39;"); }
package/src/ui/index.js CHANGED
@@ -4,7 +4,7 @@ const styles = `:host { --cf-ui-bg:#fff; --cf-ui-surface:#f7f7f5; --cf-ui-text:#
4
4
  export class CfAdminShell extends HTMLElement {
5
5
  connectedCallback() {
6
6
  const active = this.getAttribute("active") || "";
7
- 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><a href="/admin/groups">Groups</a></nav><slot></slot></div>`;
7
+ 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/features">Features</a><a href="/admin/healthchecks">Healthchecks</a><a href="/admin/circuit-breakers">Circuit breakers</a><a href="/admin/groups">Groups</a></nav><slot></slot></div>`;
8
8
  }
9
9
  }
10
10