@agilesyndrome/cf-genai-base 1.0.5 → 1.0.7

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,6 +16,8 @@ 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
+ - `adminPage` optionally renders the authorized platform admin browser pages so a site can keep the shared system menu and its own visual shell consistent.
20
+ - `siteAdminPage` optionally renders site-owned pages below `/admin/site/*`, keeping them separate from the reserved platform page paths.
19
21
  - 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
22
  - Public APIs must be explicitly listed in provider-specific auth configuration.
21
23
  - Mutating `/api/*` requests require a same-origin `Origin` header.
@@ -37,9 +39,11 @@ Standard bindings:
37
39
 
38
40
  Build metadata is optional: `BUILD_SHA` and `BUILD_NUMBER`.
39
41
 
40
- The package includes `migrations/0001_authorization.sql`; each site must apply
41
- the equivalent migration to its own D1 database before enabling the generic
42
- user/scope APIs.
42
+ The package includes ordered migrations. Each site must apply
43
+ `migrations/0001_authorization.sql` before enabling the generic user/scope APIs
44
+ and `migrations/0004_tenants.sql` for tenant membership and subscriptions.
45
+ The tenant migration seeds the `Easley Family` tenant and `VIP` subscription,
46
+ and migrates existing authorization users into that tenant.
43
47
 
44
48
  D1 migrations are committed with the site, applied by Wrangler, and are the
45
49
  source of truth for schema changes. R2 stores binary data; metadata and access
@@ -50,3 +54,22 @@ control remain in D1.
50
54
  Auth returns a stable `sub`, normalized lowercase `email`, and display `name`.
51
55
  Applications may add roles or an internal D1 user id in `onLogin`; authorization
52
56
  must remain in the application router rather than in the shared auth package.
57
+
58
+ ## Scoped data contract
59
+
60
+ `createWorker` accepts `dataResources`, and features may expose the same
61
+ manifest through `feature.dataResources`. Each resource must declare a safe
62
+ name, table, explicit columns, and one scope: `user`, `tenant`, or `system`.
63
+ Resources may also declare allowed operations (`read`, `create`, `update`, and
64
+ `delete`); reads support bounded cursor pagination through `reader.page()`.
65
+ Request handlers receive `state.data`, whose scope-specific readers apply the
66
+ validated user or tenant predicate. Domain handlers must not use unrestricted
67
+ `env.DB` for registered resources. Base cannot provide row-level security to
68
+ direct D1 calls, so applications must keep raw database access out of domain
69
+ features. The cookbook migration must add and backfill `tenant_id` on recipe
70
+ tables, register recipes as tenant-scoped, replace direct D1 reads/writes with
71
+ `state.data.tenant`, and add cross-tenant isolation tests. The companion
72
+ `cf-genai-cli` should lint `cf-genai-*` working folders for direct
73
+ `env.DB.prepare(` usage as a follow-up enforcement check.
74
+ Scoped write violations are returned as a generic 403 response; the detailed
75
+ scope/resource identity is retained in the audit log only.
package/README.md CHANGED
@@ -27,6 +27,21 @@ export default createWorker({
27
27
 
28
28
  Features expose `middleware(request, env, ctx, next, state)` and may short-circuit reserved routes, attach request state, or call `next()`.
29
29
 
30
+ Sites may provide `adminPage({ request, env, url, state, features })` to render
31
+ the shared platform pages (`/admin/users`, `/admin/scopes`, `/admin/groups`,
32
+ `/admin/features`, `/admin/healthchecks`, and `/admin/circuit-breakers`) inside
33
+ their own shell. The callback runs after the shared authorization boundary and
34
+ must return a `Response` or `null`.
35
+
36
+ Sites may separately provide `siteAdminPage({ request, env, url, state,
37
+ features })` for a `/admin/site/*` namespace. This is useful when a site wants
38
+ its own admin pages to have an explicit boundary beside the shared platform
39
+ pages.
40
+
41
+ The shared `<cf-admin-shell>` accepts an optional `cookbook-links` attribute
42
+ containing semicolon-separated `Label|URL|active-key` entries. This lets a site
43
+ replace the default Cookbook links while keeping the System links consistent.
44
+
30
45
  ## Shared platform helpers
31
46
 
32
47
  `createWorker` can own `/health` and `/api/health`, run a boot validator before
@@ -44,6 +59,11 @@ Admin APIs are `GET /api/admin/healthchecks`, `PUT /api/admin/healthchecks/:id`,
44
59
 
45
60
  ## User administration
46
61
 
62
+ Apply `migrations/0004_tenants.sql` after the authorization migration to add
63
+ tenant membership and subscriptions. It creates the `Easley Family` tenant,
64
+ the `VIP` subscription, associates them, migrates all existing users into the
65
+ tenant, and keeps newly provisioned users attached to it.
66
+
47
67
  Use the selected D1 target (local by default) to inspect and update users:
48
68
 
49
69
  cf-genai user list --target local
@@ -51,3 +71,22 @@ Use the selected D1 target (local by default) to inspect and update users:
51
71
  cf-genai user update someone.com --roles admin --target production
52
72
 
53
73
  `user:get` also reports scopes and groups. The user update command resolves an email, subject, or internal id and supports `admin` or `none` roles. Production commands should be run through the repository credentials wrapper and reviewed as an administrative change.
74
+
75
+ ## Scoped data access
76
+
77
+ Features may register D1 resources with `dataResources` and receive the
78
+ scoped reader on the request state as `state.data`. Resources declare `user`,
79
+ `tenant`, or `system` scope, their physical table, and an explicit column
80
+ allowlist. Use `state.data.tenant`, `state.data.user`, or `state.data.system`;
81
+ the reader applies ownership predicates, supports bounded cursor pagination via
82
+ `.page()`, and never accepts raw SQL. Resources can explicitly restrict their
83
+ operations to `read`, `create`, `update`, and `delete`.
84
+
85
+ For example, a tenant-owned resource registers its `tenant_id` column with
86
+ base, while feature code calls `state.data.tenant.list("recipes")` without
87
+ passing a tenant ID. The active tenant must be a validated membership. A
88
+ resource used with the wrong scope returns no rows; writes fail closed.
89
+
90
+ Applications using scoped data must stop passing unrestricted `env.DB` to
91
+ domain features. Their migrations still add and backfill ownership columns,
92
+ and their resources must be registered with base.
@@ -0,0 +1,45 @@
1
+ CREATE TABLE IF NOT EXISTS auth_tenants (
2
+ id TEXT PRIMARY KEY,
3
+ name TEXT NOT NULL UNIQUE COLLATE NOCASE,
4
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
5
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
6
+ );
7
+
8
+ CREATE TABLE IF NOT EXISTS auth_subscriptions (
9
+ id TEXT PRIMARY KEY,
10
+ name TEXT NOT NULL UNIQUE COLLATE NOCASE,
11
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
12
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
13
+ );
14
+
15
+ CREATE TABLE IF NOT EXISTS auth_tenant_subscriptions (
16
+ tenant_id TEXT NOT NULL REFERENCES auth_tenants(id) ON DELETE CASCADE,
17
+ subscription_id TEXT NOT NULL REFERENCES auth_subscriptions(id) ON DELETE CASCADE,
18
+ granted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
19
+ PRIMARY KEY (tenant_id, subscription_id)
20
+ );
21
+
22
+ CREATE INDEX IF NOT EXISTS auth_tenant_subscriptions_subscription_idx
23
+ ON auth_tenant_subscriptions(subscription_id);
24
+
25
+ CREATE TABLE IF NOT EXISTS auth_user_tenants (
26
+ user_id TEXT NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE,
27
+ tenant_id TEXT NOT NULL REFERENCES auth_tenants(id) ON DELETE CASCADE,
28
+ joined_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
29
+ PRIMARY KEY (user_id, tenant_id)
30
+ );
31
+
32
+ CREATE INDEX IF NOT EXISTS auth_user_tenants_tenant_idx
33
+ ON auth_user_tenants(tenant_id);
34
+
35
+ INSERT OR IGNORE INTO auth_tenants (id, name)
36
+ VALUES ('easley-family', 'Easley Family');
37
+
38
+ INSERT OR IGNORE INTO auth_subscriptions (id, name)
39
+ VALUES ('vip', 'VIP');
40
+
41
+ INSERT OR IGNORE INTO auth_tenant_subscriptions (tenant_id, subscription_id)
42
+ VALUES ('easley-family', 'vip');
43
+
44
+ INSERT OR IGNORE INTO auth_user_tenants (user_id, tenant_id)
45
+ SELECT id, 'easley-family' FROM auth_users;
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@agilesyndrome/cf-genai-base",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.js",
7
7
  "./authorization": "./src/authorization.js",
8
+ "./data": "./src/data.js",
8
9
  "./core": "./src/core.js",
9
10
  "./ui": "./src/ui/index.js",
10
11
  "./ui/styles.css": "./src/ui/styles.css"
@@ -28,7 +29,7 @@
28
29
  },
29
30
  "homepage": "https://github.com/agilesyndrome/cf-genai-base#readme",
30
31
  "scripts": {
31
- "check": "node --check src/index.js && node --check src/core.js && node --check src/authorization.js",
32
+ "check": "node --check src/index.js && node --check src/core.js && node --check src/authorization.js && node --check src/data.js",
32
33
  "test": "node --test tests/*.test.mjs",
33
34
  "build": "npm run check && npm test && npm pack --dry-run"
34
35
  }
@@ -3,6 +3,10 @@ import { createD1 } from "./core.js";
3
3
  export const AUTH_USER_TABLE = "auth_users";
4
4
  export const AUTH_SCOPE_TABLE = "auth_scopes";
5
5
  export const AUTH_GRANT_TABLE = "auth_user_scopes";
6
+ export const DEFAULT_TENANT_ID = "easley-family";
7
+ export const DEFAULT_TENANT_NAME = "Easley Family";
8
+ export const DEFAULT_SUBSCRIPTION_ID = "vip";
9
+ export const DEFAULT_SUBSCRIPTION_NAME = "VIP";
6
10
 
7
11
  export function normalizeScopes(scopes = []) {
8
12
  return scopes.map((scope) => typeof scope === "string" ? { name: scope, label: scope, description: "", system: false } : scope)
@@ -28,13 +32,36 @@ export async function ensureUser(env, user, { who = "system:read" } = {}) {
28
32
  const bootstrap = new Set(String(env.AUTH_ADMIN_EMAILS || env.ADMIN_EMAILS || "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean));
29
33
  if (existing) {
30
34
  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();
35
+ await ensureDefaultTenantMembership(db, existing.id);
31
36
  return { ...existing, email, display_name: String(user.name || email || subject), is_admin: Boolean(existing.is_admin || bootstrap.has(email)) };
32
37
  }
33
38
  const id = await stableId(`${provider}:${subject}`);
34
39
  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();
40
+ await ensureDefaultTenantMembership(db, id);
35
41
  return await db.prepare(`SELECT * FROM ${AUTH_USER_TABLE} WHERE id=?`).bind(id).first();
36
42
  }
37
43
 
44
+ async function ensureDefaultTenantMembership(db, userId) {
45
+ await db.batch([
46
+ db.prepare("INSERT OR IGNORE INTO auth_tenants (id,name) VALUES (?,?)").bind(DEFAULT_TENANT_ID, DEFAULT_TENANT_NAME),
47
+ db.prepare("INSERT OR IGNORE INTO auth_subscriptions (id,name) VALUES (?,?)").bind(DEFAULT_SUBSCRIPTION_ID, DEFAULT_SUBSCRIPTION_NAME),
48
+ db.prepare("INSERT OR IGNORE INTO auth_tenant_subscriptions (tenant_id,subscription_id) VALUES (?,?)").bind(DEFAULT_TENANT_ID, DEFAULT_SUBSCRIPTION_ID),
49
+ db.prepare("INSERT OR IGNORE INTO auth_user_tenants (user_id,tenant_id) VALUES (?,?)").bind(userId, DEFAULT_TENANT_ID)
50
+ ]);
51
+ }
52
+
53
+ export async function listUserTenants(env, userId, { who = "system:read" } = {}) {
54
+ const db = createD1(env, { who });
55
+ const { results } = await db.prepare(`SELECT t.id,t.name,t.created_at,t.updated_at FROM auth_tenants t JOIN auth_user_tenants ut ON ut.tenant_id=t.id WHERE ut.user_id=? ORDER BY t.name COLLATE NOCASE`).bind(userId).all();
56
+ return results || [];
57
+ }
58
+
59
+ export async function listTenantSubscriptions(env, tenantId, { who = "system:read" } = {}) {
60
+ const db = createD1(env, { who });
61
+ const { results } = await db.prepare(`SELECT s.id,s.name,s.created_at,s.updated_at FROM auth_subscriptions s JOIN auth_tenant_subscriptions ts ON ts.subscription_id=s.id WHERE ts.tenant_id=? ORDER BY s.name COLLATE NOCASE`).bind(tenantId).all();
62
+ return results || [];
63
+ }
64
+
38
65
  export async function hasScope(env, user, scope, { who = "system:read" } = {}) {
39
66
  const db = createD1(env, { who });
40
67
  if (user?.auth_strategy === "http_basic") return true;
package/src/core.js CHANGED
@@ -2,7 +2,7 @@ export const HEALTHCHECK_STATES = ["red", "yellow", "green"];
2
2
  export const CIRCUIT_BREAKER_STATES = ["off", "tripped", "on"];
3
3
  export const HEALTHCHECK_MODES = ["any", "all"];
4
4
  export const BASE_PACKAGE_NAME = "@agilesyndrome/cf-genai-base";
5
- export const BASE_VERSION = "1.0.5";
5
+ export const BASE_VERSION = "1.0.7";
6
6
 
7
7
  export function eventLog(level, event, details = {}) {
8
8
  const method = ["debug", "info", "warn", "error"].includes(level) ? level : "info";
package/src/data.js ADDED
@@ -0,0 +1,171 @@
1
+ import { auditLog, createD1 } from "./core.js";
2
+ import { ensureUser, listUserTenants } from "./authorization.js";
3
+
4
+ export const DATA_SCOPES = ["user", "tenant", "system"];
5
+ export const DATA_OPERATIONS = ["read", "create", "update", "delete"];
6
+
7
+ export class DataScopeError extends Error {
8
+ constructor(message) {
9
+ super(message);
10
+ this.name = "DataScopeError";
11
+ }
12
+ }
13
+
14
+ export function normalizeDataResources(resources = []) {
15
+ const names = new Set();
16
+ return resources.map((resource) => {
17
+ if (!resource || !/^[a-z][a-z0-9_-]*$/.test(String(resource.name || ""))) throw new TypeError("Data resources require a safe name");
18
+ if (!/^[a-z][a-z0-9_]*$/.test(String(resource.table || ""))) throw new TypeError(`Data resource ${resource.name} requires a safe table name`);
19
+ const name = String(resource.name);
20
+ if (names.has(name)) throw new TypeError(`Duplicate data resource: ${name}`);
21
+ names.add(name);
22
+ const scope = String(resource.scope || "").toLowerCase();
23
+ if (!DATA_SCOPES.includes(scope)) throw new TypeError(`Data resource ${resource.name} requires scope user, tenant, or system`);
24
+ const columns = [...new Set((resource.columns || []).map(String))];
25
+ if (!columns.length || columns.some((column) => !/^[a-z][a-z0-9_]*$/.test(column))) throw new TypeError(`Data resource ${resource.name} requires safe columns`);
26
+ const idColumn = String(resource.idColumn || "id");
27
+ if (!columns.includes(idColumn)) throw new TypeError(`Data resource ${resource.name} must include ${idColumn}`);
28
+ const ownerColumn = resource.ownerColumn ? String(resource.ownerColumn) : "user_id";
29
+ const tenantColumn = resource.tenantColumn ? String(resource.tenantColumn) : "tenant_id";
30
+ if (scope === "user" && !columns.includes(ownerColumn)) throw new TypeError(`User resource ${resource.name} must include ${ownerColumn}`);
31
+ if (scope === "tenant" && !columns.includes(tenantColumn)) throw new TypeError(`Tenant resource ${resource.name} must include ${tenantColumn}`);
32
+ const filterableColumns = [...new Set((resource.filterableColumns || columns).map(String))];
33
+ const orderableColumns = [...new Set((resource.orderableColumns || columns).map(String))];
34
+ for (const column of [...filterableColumns, ...orderableColumns]) if (!columns.includes(column)) throw new TypeError(`Data resource ${resource.name} references an unselected column`);
35
+ const writableColumns = [...new Set((resource.writableColumns || []).map(String))].filter((column) => column !== ownerColumn && column !== tenantColumn);
36
+ if (writableColumns.some((column) => !columns.includes(column))) throw new TypeError(`Data resource ${resource.name} references an unwritable column`);
37
+ const operations = [...new Set((resource.operations || ["read", ...(writableColumns.length ? ["create", "update", "delete"] : [])]).map((operation) => String(operation).toLowerCase()))];
38
+ if (!operations.length || operations.some((operation) => !DATA_OPERATIONS.includes(operation)) || !operations.includes("read")) throw new TypeError(`Data resource ${name} has invalid operations`);
39
+ return { ...resource, name, table: String(resource.table), scope, columns, idColumn, ownerColumn, tenantColumn, filterableColumns, orderableColumns, writableColumns, operations };
40
+ });
41
+ }
42
+
43
+ export function createDataReader(env, { resources = [], context } = {}) {
44
+ const registry = new Map(normalizeDataResources(resources).map((resource) => [resource.name, resource]));
45
+ const getContext = typeof context === "function" ? context : async () => context || {};
46
+ const scope = (requestedScope) => ({
47
+ list: (name, options) => readList(name, requestedScope, options),
48
+ page: (name, options) => readPage(name, requestedScope, options),
49
+ get: async (name, id) => (await readList(name, requestedScope, { where: { [registry.get(name)?.idColumn || "id"]: id }, limit: 1 }))[0] || null,
50
+ insert: (name, values) => writeInsert(name, requestedScope, values),
51
+ update: (name, id, changes) => writeUpdate(name, requestedScope, id, changes),
52
+ delete: (name, id) => writeDelete(name, requestedScope, id),
53
+ });
54
+
55
+ async function readList(name, requestedScope, { where = {}, limit = 100, orderBy } = {}) {
56
+ return (await readPage(name, requestedScope, { where, limit, orderBy })).rows;
57
+ }
58
+
59
+ async function readPage(name, requestedScope, { where = {}, limit = 100, orderBy, cursor } = {}) {
60
+ const resource = getResource(name);
61
+ const actor = await getContext();
62
+ if (!isAllowed(resource, requestedScope, actor, "read")) return { rows: [], nextCursor: null };
63
+ const predicates = [];
64
+ const bindings = [];
65
+ addScopePredicate(resource, requestedScope, actor, predicates, bindings);
66
+ addFilters(resource, where, predicates, bindings);
67
+ if (cursor !== undefined && cursor !== null) { predicates.push(`${quote(resource.idColumn)}>?`); bindings.push(cursor); }
68
+ const safeLimit = Math.min(Math.max(Number(limit) || 100, 1), 1000);
69
+ let sql = `SELECT ${resource.columns.map(quote).join(",")} FROM ${quote(resource.table)}${predicates.length ? ` WHERE ${predicates.join(" AND ")}` : ""} LIMIT ${safeLimit}`;
70
+ if (orderBy) {
71
+ const [column, direction = "ASC"] = String(orderBy).split(/\s+/, 2);
72
+ if (!resource.orderableColumns.includes(column)) throw new TypeError(`Column ${column} cannot order ${resource.name}`);
73
+ sql = sql.replace(` LIMIT ${safeLimit}`, ` ORDER BY ${quote(column)} ${direction.toUpperCase() === "DESC" ? "DESC" : "ASC"} LIMIT ${safeLimit}`);
74
+ }
75
+ const result = await createD1(env, { who: actorLabel(actor) }).prepare(sql).bind(...bindings).all();
76
+ const rows = result.results || [];
77
+ return { rows, nextCursor: rows.length === safeLimit ? rows[rows.length - 1][resource.idColumn] : null };
78
+ }
79
+
80
+ async function writeInsert(name, requestedScope, values = {}) {
81
+ const resource = getResource(name);
82
+ const actor = await getContext();
83
+ assertWritable(resource, requestedScope, actor, "create");
84
+ const data = cleanWritableValues(resource, values, { includeId: true, includeOwnership: requestedScope === "system" });
85
+ addOwnedValue(resource, requestedScope, actor, data);
86
+ const columns = Object.keys(data);
87
+ if (!columns.length) throw new TypeError(`No writable values supplied for ${resource.name}`);
88
+ await createD1(env, { who: actorLabel(actor) }).prepare(`INSERT INTO ${quote(resource.table)} (${columns.map(quote).join(",")}) VALUES (${columns.map(() => "?").join(",")})`).bind(...columns.map((column) => data[column])).run();
89
+ return data[resource.idColumn] === undefined ? data : (await readList(name, requestedScope, { where: { [resource.idColumn]: data[resource.idColumn] }, limit: 1 }))[0] || data;
90
+ }
91
+
92
+ async function writeUpdate(name, requestedScope, id, changes = {}) {
93
+ const resource = getResource(name);
94
+ const actor = await getContext();
95
+ assertWritable(resource, requestedScope, actor, "update");
96
+ const data = cleanWritableValues(resource, changes, { includeOwnership: requestedScope === "system" });
97
+ const columns = Object.keys(data);
98
+ if (!columns.length) throw new TypeError(`No writable values supplied for ${resource.name}`);
99
+ const predicates = [`${quote(resource.idColumn)}=?`];
100
+ const bindings = [id];
101
+ addScopePredicate(resource, requestedScope, actor, predicates, bindings);
102
+ await createD1(env, { who: actorLabel(actor) }).prepare(`UPDATE ${quote(resource.table)} SET ${columns.map((column) => `${quote(column)}=?`).join(",")} WHERE ${predicates.join(" AND ")}`).bind(...columns.map((column) => data[column]), ...bindings).run();
103
+ return (await readList(name, requestedScope, { where: { [resource.idColumn]: id }, limit: 1 }))[0] || null;
104
+ }
105
+
106
+ async function writeDelete(name, requestedScope, id) {
107
+ const resource = getResource(name);
108
+ const actor = await getContext();
109
+ assertWritable(resource, requestedScope, actor, "delete");
110
+ const predicates = [`${quote(resource.idColumn)}=?`];
111
+ const bindings = [id];
112
+ addScopePredicate(resource, requestedScope, actor, predicates, bindings);
113
+ return createD1(env, { who: actorLabel(actor) }).prepare(`DELETE FROM ${quote(resource.table)} WHERE ${predicates.join(" AND ")}`).bind(...bindings).run();
114
+ }
115
+
116
+ function getResource(name) {
117
+ const resource = registry.get(String(name));
118
+ if (!resource) throw new TypeError(`Unknown data resource: ${name}`);
119
+ return resource;
120
+ }
121
+
122
+ return { user: scope("user"), tenant: scope("tenant"), system: scope("system"), resources: [...registry.values()] };
123
+ }
124
+
125
+ export async function requestDataContext(env, { state = {}, request } = {}) {
126
+ const authUser = state.authUser || (state.user ? await ensureUser(env, state.user, { who: `user:${state.user.sub || "unknown"}` }) : null);
127
+ const system = Boolean(state.user?.auth_strategy === "http_basic" || (authUser && authUser.is_admin));
128
+ if (!authUser) return { userId: null, tenantId: null, system: false };
129
+ const tenants = await listUserTenants(env, authUser.id, { who: `user:${authUser.id}` });
130
+ const requestedTenant = state.tenantId || request?.headers?.get("X-Tenant-ID") || null;
131
+ const tenant = requestedTenant ? tenants.find((item) => item.id === requestedTenant) : tenants.length === 1 ? tenants[0] : null;
132
+ return { userId: authUser.id, tenantId: tenant?.id || null, system, tenants };
133
+ }
134
+
135
+ function isAllowed(resource, requestedScope, actor, operation) {
136
+ const allowed = requestedScope === "system" ? Boolean(actor.system) : requestedScope === resource.scope && (requestedScope === "user" ? Boolean(actor.userId) : Boolean(actor.userId && actor.tenantId));
137
+ if (!allowed || !resource.operations.includes(operation)) {
138
+ auditLog({ who: actorLabel(actor), operation: "deny", resource: `data:${resource.name}:${requestedScope}:${operation}` });
139
+ return false;
140
+ }
141
+ return true;
142
+ }
143
+
144
+ function assertWritable(resource, requestedScope, actor, operation) {
145
+ if (!isAllowed(resource, requestedScope, actor, operation)) throw new DataScopeError(`Data scope ${requestedScope} cannot ${operation} resource ${resource.name}`);
146
+ }
147
+
148
+ function addScopePredicate(resource, requestedScope, actor, predicates, bindings) {
149
+ if (requestedScope === "user") { predicates.push(`${quote(resource.ownerColumn)}=?`); bindings.push(actor.userId); }
150
+ if (requestedScope === "tenant") { predicates.push(`${quote(resource.tenantColumn)}=?`); bindings.push(actor.tenantId); }
151
+ }
152
+
153
+ function addFilters(resource, where, predicates, bindings) {
154
+ for (const [column, value] of Object.entries(where || {})) {
155
+ if (!resource.filterableColumns.includes(column)) throw new TypeError(`Column ${column} cannot filter ${resource.name}`);
156
+ if (value === null) predicates.push(`${quote(column)} IS NULL`);
157
+ else { predicates.push(`${quote(column)}=?`); bindings.push(value); }
158
+ }
159
+ }
160
+
161
+ function cleanWritableValues(resource, values, { includeId = false, includeOwnership = false } = {}) {
162
+ return Object.fromEntries(Object.entries(values || {}).filter(([column]) => (resource.writableColumns.includes(column) || (includeOwnership && [resource.ownerColumn, resource.tenantColumn].includes(column))) && (includeId || column !== resource.idColumn)));
163
+ }
164
+
165
+ function addOwnedValue(resource, requestedScope, actor, data) {
166
+ if (requestedScope === "user") data[resource.ownerColumn] = actor.userId;
167
+ if (requestedScope === "tenant") data[resource.tenantColumn] = actor.tenantId;
168
+ }
169
+
170
+ function actorLabel(actor) { return actor.system ? "system:data" : `user:${actor.userId || "unknown"}`; }
171
+ function quote(identifier) { return `"${identifier}"`; }
package/src/index.js CHANGED
@@ -4,12 +4,15 @@
4
4
  */
5
5
  import { ensureScopes, ensureUser, hasScope, listAuthorizationScopes, listAuthorizationUsers, listGroups, listUserGroups, listUserGrants, replaceUserGroups, replaceUserGrants } from "./authorization.js";
6
6
  import { getCircuitBreaker, evaluateCircuitBreaker, listCircuitBreakers, listHealthchecks, listFeatureCatalog, listFeatureHealth, registerFeatureManifests, requestActor, setCircuitBreaker, updateHealthcheck } from "./core.js";
7
+ import { createDataReader, DataScopeError, normalizeDataResources, requestDataContext } from "./data.js";
7
8
  export * from "./core.js";
8
- export function createWorker({ fetch, scheduled, auth, authorize, scopes = [], scopeRoutes = [], middleware = [], features = [], health, boot, metrics, security = true }) {
9
+ export * from "./data.js";
10
+ export function createWorker({ fetch, scheduled, auth, authorize, scopes = [], scopeRoutes = [], middleware = [], features = [], dataResources = [], health, boot, metrics, security = true, adminPage, siteAdminPage }) {
9
11
  if (typeof fetch !== "function") throw new TypeError("createWorker requires a fetch handler");
10
12
  const provider = auth || features.find((feature) => typeof feature?.getUser === "function");
13
+ const registeredDataResources = normalizeDataResources([...dataResources, ...features.flatMap((feature) => Array.isArray(feature?.dataResources) ? feature.dataResources : [])]);
11
14
  const chain = [
12
- (request, env, ctx, next, state) => adminBoundary(request, env, ctx, next, state, { provider, authorize, scopes, scopeRoutes, features }),
15
+ (request, env, ctx, next, state) => adminBoundary(request, env, ctx, next, state, { provider, authorize, scopes, scopeRoutes, features, adminPage, siteAdminPage }),
13
16
  ...features.flatMap((feature) => feature?.middleware ? [feature.middleware.bind(feature)] : []),
14
17
  ...middleware,
15
18
  ...(auth ? [(request, env, ctx, next) => auth(request, env, ctx, next)] : []),
@@ -20,6 +23,7 @@ export function createWorker({ fetch, scheduled, auth, authorize, scopes = [], s
20
23
  if (boot) await boot(env, { request, ctx });
21
24
  const url = new URL(request.url);
22
25
  const state = Object.create(null);
26
+ state.data = createDataReader(env, { resources: registeredDataResources, context: () => requestDataContext(env, { state, request }) });
23
27
  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)));
24
28
  const dispatch = async (index, currentRequest = request) => {
25
29
  const layer = chain[index];
@@ -39,6 +43,7 @@ export function createWorker({ fetch, scheduled, auth, authorize, scopes = [], s
39
43
  return security ? secureResponse(response) : response;
40
44
  } catch (error) {
41
45
  console.error("[worker] request failed", error);
46
+ if (error instanceof DataScopeError) return secureResponse(Response.json({ error: "Data access is not permitted." }, { status: 403, headers: { "Cache-Control": "no-store" } }));
42
47
  return secureResponse(Response.json({ error: "Internal server error" }, { status: 500, headers: { "Cache-Control": "no-store" } }));
43
48
  }
44
49
  },
@@ -47,7 +52,7 @@ export function createWorker({ fetch, scheduled, auth, authorize, scopes = [], s
47
52
  }
48
53
 
49
54
 
50
- async function adminBoundary(request, env, ctx, next, state, { provider, authorize, scopes, scopeRoutes, features }) {
55
+ async function adminBoundary(request, env, ctx, next, state, { provider, authorize, scopes, scopeRoutes, features, adminPage, siteAdminPage }) {
51
56
  const url = new URL(request.url);
52
57
  if (!isAdminPath(url.pathname)) return next(request);
53
58
  const strategy = String(env?.AUTH_STRATEGY || "http_basic").trim().toLowerCase();
@@ -72,6 +77,15 @@ async function adminBoundary(request, env, ctx, next, state, { provider, authori
72
77
  }
73
78
  const platformResponse = await authorizationApi(request, env, url, state, features);
74
79
  if (platformResponse) return platformResponse;
80
+ if (request.method === "GET" && isSiteAdminPage(url.pathname) && typeof siteAdminPage === "function") {
81
+ const response = await siteAdminPage({ request, env, url, state, features });
82
+ if (response) return response;
83
+ }
84
+ if (request.method === "GET" && isPlatformAdminPage(url.pathname) && typeof adminPage === "function") {
85
+ 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" } });
86
+ const response = await adminPage({ request, env, url, state, features });
87
+ if (response) return response;
88
+ }
75
89
  if (url.pathname === "/admin/features" && request.method === "GET") {
76
90
  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
91
  return featureCatalogPage(env, features, state);
@@ -79,6 +93,14 @@ async function adminBoundary(request, env, ctx, next, state, { provider, authori
79
93
  return next(request);
80
94
  }
81
95
 
96
+ function isPlatformAdminPage(pathname) {
97
+ return ["/admin/users", "/admin/scopes", "/admin/groups", "/admin/features", "/admin/healthchecks", "/admin/circuit-breakers"].includes(pathname);
98
+ }
99
+
100
+ function isSiteAdminPage(pathname) {
101
+ return pathname === "/admin/site" || pathname.startsWith("/admin/site/");
102
+ }
103
+
82
104
  function requiredScopeFor(pathname, routes) {
83
105
  const route = routes.find((entry) => typeof entry.match === "function" ? entry.match(pathname) : pathname === entry.path || pathname.startsWith(String(entry.path || "") + "/"));
84
106
  return route && route.scope ? route.scope : null;
package/src/ui/groups.js CHANGED
@@ -1,2 +1,2 @@
1
- export class CfGroupCatalog extends HTMLElement { async connectedCallback() { this.innerHTML = "<section class=\"card\"><h2>User groups</h2><p class=\"status\">Loading</p><div class=\"list\"></div></section>"; const response = await fetch("/api/admin/groups", { credentials: "same-origin" }); const groups = (await response.json()).groups || []; const list = this.querySelector(".list"); list.replaceChildren(...groups.map((group) => { const row = document.createElement("div"); row.textContent = group.display_name + " — " + group.name; return row; })); this.querySelector(".status").textContent = groups.length + " groups"; } }
1
+ export class CfGroupCatalog extends HTMLElement { async connectedCallback() { this.attachShadow({ mode: "open" }).innerHTML = `<style>:host{display:block;color:var(--cf-ui-text,#20231f);font:15px/1.45 system-ui,sans-serif}.card{background:var(--cf-ui-bg,#fff);border:1px solid var(--cf-ui-border,#d8ddd5);border-radius:.6rem;padding:1rem}.status{color:var(--cf-ui-muted,#687067);min-height:1.4em}.list{display:grid;gap:.3rem}.list div{padding:.55rem 0;border-bottom:1px solid var(--cf-ui-border,#d8ddd5)}</style><section class="card"><h2>User groups</h2><p class="status">Loading</p><div class="list"></div></section>`; const response = await fetch("/api/admin/groups", { credentials: "same-origin" }); const groups = (await response.json()).groups || []; const list = this.shadowRoot.querySelector(".list"); list.replaceChildren(...groups.map((group) => { const row = document.createElement("div"); row.textContent = group.display_name + " — " + group.name; return row; })); this.shadowRoot.querySelector(".status").textContent = groups.length + " groups"; } }
2
2
  if (!customElements.get("cf-group-catalog")) customElements.define("cf-group-catalog", CfGroupCatalog);
package/src/ui/index.js CHANGED
@@ -1,22 +1,29 @@
1
1
  export * from "./groups.js";
2
- const styles = `:host { --cf-ui-bg:#fff; --cf-ui-surface:#f7f7f5; --cf-ui-text:#20231f; --cf-ui-muted:#687067; --cf-ui-border:#d8ddd5; --cf-ui-primary:#2f6f52; color:var(--cf-ui-text); font:15px/1.45 system-ui,sans-serif } *,*::before,*::after{box-sizing:border-box}.shell{display:grid;gap:1rem}.nav{display:flex;flex-wrap:wrap;gap:.5rem;border-bottom:1px solid var(--cf-ui-border);padding-bottom:.75rem}.nav a{color:var(--cf-ui-text);padding:.45rem .7rem;border-radius:.4rem;text-decoration:none}.nav a:hover,.nav a[aria-current=page]{background:var(--cf-ui-surface);color:var(--cf-ui-primary)}.card{background:var(--cf-ui-bg);border:1px solid var(--cf-ui-border);border-radius:.6rem;padding:1rem;overflow:auto}table{width:100%;border-collapse:collapse}th,td{padding:.65rem;border-bottom:1px solid var(--cf-ui-border);text-align:left;vertical-align:top}th{color:var(--cf-ui-muted);font-size:.8rem;text-transform:uppercase;letter-spacing:.04em}button{border:1px solid var(--cf-ui-border);border-radius:.4rem;background:var(--cf-ui-bg);color:inherit;padding:.45rem .65rem;cursor:pointer}.scope-list{display:grid;gap:.3rem;min-width:14rem}.scope-list label{display:flex;gap:.4rem;align-items:center}.status{color:var(--cf-ui-muted);min-height:1.4em}`;
2
+ const styles = `:host { --cf-ui-bg:#fff; --cf-ui-surface:#f7f7f5; --cf-ui-text:#20231f; --cf-ui-muted:#687067; --cf-ui-border:#d8ddd5; --cf-ui-primary:#2f6f52; color:var(--cf-ui-text); font:15px/1.45 system-ui,sans-serif } *,*::before,*::after{box-sizing:border-box}.shell{display:grid;grid-template-columns:180px minmax(0,1fr);gap:1.5rem}.nav{display:grid;align-content:start;gap:.2rem;border-right:1px solid var(--cf-ui-border);padding-right:1rem}.nav-group{display:grid;gap:.15rem;margin-bottom:.85rem}.nav-label{padding:.35rem .7rem;color:var(--cf-ui-muted);font-size:.68rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase}.nav a{color:var(--cf-ui-text);padding:.48rem .7rem;border-radius:.4rem;text-decoration:none}.nav a:hover,.nav a[aria-current=page]{background:var(--cf-ui-surface);color:var(--cf-ui-primary)}.card{background:var(--cf-ui-bg);border:1px solid var(--cf-ui-border);border-radius:.6rem;padding:1rem;overflow:auto}table{width:100%;border-collapse:collapse}th,td{padding:.65rem;border-bottom:1px solid var(--cf-ui-border);text-align:left;vertical-align:top}th{color:var(--cf-ui-muted);font-size:.8rem;text-transform:uppercase;letter-spacing:.04em}button{border:1px solid var(--cf-ui-border);border-radius:.4rem;background:var(--cf-ui-bg);color:inherit;padding:.45rem .65rem;cursor:pointer}.scope-list,.catalog-list{display:grid;gap:.3rem;min-width:14rem}.scope-list label{display:flex;gap:.4rem;align-items:center}.catalog-list div{padding:.55rem 0;border-bottom:1px solid var(--cf-ui-border)}.status{color:var(--cf-ui-muted);min-height:1.4em}.state{font-weight:700}.state-green{color:#26734d}.state-yellow{color:#9a6b00}.state-red{color:#b3261e}@media(max-width:640px){.shell{grid-template-columns:1fr}.nav{grid-template-columns:repeat(2,minmax(0,1fr));border-right:0;border-bottom:1px solid var(--cf-ui-border);padding:0 0 1rem}.nav-group{margin:0}.nav-label{grid-column:1/-1}}`;
3
+
4
+ const enhancedStyles = styles + `.status-grid,.breaker-groups,.feature-grid{display:grid;gap:.85rem}.status-grid{grid-template-columns:repeat(auto-fit,minmax(230px,1fr))}.status-card,.feature-card{padding:1rem;background:var(--cf-ui-bg);border:1px solid var(--cf-ui-border);border-radius:.65rem}.status-card h3,.feature-card h3{margin:0 0 .35rem;font-size:1rem}.status-card p,.feature-card p{margin:.3rem 0;color:var(--cf-ui-muted)}.status-line{display:flex;align-items:center;gap:.55rem;margin:.35rem 0}.state{font-weight:700}.state-green{color:#26734d}.state-yellow{color:#9a6b00}.state-red{color:#b3261e}.state-off{color:#687067}.state-tripped{color:#a33b32}.state-on{color:#26734d}.breaker-group{padding:1rem;background:var(--cf-ui-bg);border:1px solid var(--cf-ui-border);border-radius:.75rem}.breaker-group-heading{display:flex;align-items:start;justify-content:space-between;gap:1rem;margin-bottom:.75rem}.breaker-group h3{margin:0}.breaker-list{display:grid;gap:.55rem}.breaker-row{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:.7rem;align-items:center;padding:.7rem 0;border-top:1px solid var(--cf-ui-border)}.breaker-row.rollup{margin-top:.2rem;padding:.8rem;background:var(--cf-ui-surface);border:1px solid var(--cf-ui-border);border-radius:.55rem}.breaker-name{font-weight:700}.breaker-key{display:block;color:var(--cf-ui-muted);font:12px ui-monospace,monospace}.breaker-actions{display:flex;gap:.35rem;flex-wrap:wrap;justify-content:end}.breaker-actions button.is-active{border-color:var(--cf-ui-primary);background:var(--cf-ui-surface);color:var(--cf-ui-primary);font-weight:700}.feature-links{display:flex;gap:.65rem;flex-wrap:wrap;margin-top:.7rem}.feature-links a{color:var(--cf-ui-primary);font-weight:700}.error{color:var(--cf-ui-danger);min-height:1.4em}@media(max-width:640px){.breaker-row{grid-template-columns:1fr}.breaker-actions{justify-content:start}}`;
5
+ const stateInfo = { green: ["🟢", "Green"], yellow: ["🟡", "Yellow"], red: ["🔴", "Red"], off: ["⚪", "Off"], tripped: ["🔴", "Tripped"], on: ["🟢", "On"] };
6
+ const stateMarkup = (state) => { const [emoji, label] = stateInfo[String(state || "yellow").toLowerCase()] || ["⚪", String(state || "Unknown")]; return `<span class="state state-${String(state || "").toLowerCase()}">${emoji} ${label}</span>`; };
3
7
 
4
8
  export class CfAdminShell extends HTMLElement {
5
9
  connectedCallback() {
6
10
  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/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>`;
11
+ const configured = this.getAttribute("cookbook-links");
12
+ const links = (configured ? configured.split(";") : ["New recipe|/new", "Reviewers|/admin#reviewers"]).map((entry) => { const [label, href, key] = entry.split("|"); return { label: label || "", href: href || "#", key: key || "" }; }).filter((entry) => entry.label && entry.href);
13
+ const cookbookLinks = links.map(({ label, href, key }) => `<a href="${href}" ${active === key ? 'aria-current="page"' : ""}>${label}</a>`).join("");
14
+ this.attachShadow({ mode: "open" }).innerHTML = `<style>${enhancedStyles}</style><div class="shell"><nav class="nav" part="navigation"><div class="nav-group"><div class="nav-label">Cookbook</div><a href="/admin" ${active === "home" ? 'aria-current="page"' : ""}>Overview</a>${cookbookLinks}</div><div class="nav-group"><div class="nav-label">System</div><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" ${active === "features" ? 'aria-current="page"' : ""}>Features</a><a href="/admin/healthchecks" ${active === "healthchecks" ? 'aria-current="page"' : ""}>Healthchecks</a><a href="/admin/circuit-breakers" ${active === "circuit-breakers" ? 'aria-current="page"' : ""}>Circuit breakers</a><a href="/admin/groups" ${active === "groups" ? 'aria-current="page"' : ""}>Groups</a></div></nav><main><slot></slot></main></div>`;
8
15
  }
9
16
  }
10
17
 
11
18
  export class CfScopeBadge extends HTMLElement {
12
- connectedCallback() { this.attachShadow({ mode: "open" }).innerHTML = `<style>${styles}.badge{display:inline-block;border:1px solid var(--cf-ui-border);border-radius:999px;padding:.15rem .5rem;color:var(--cf-ui-primary);background:var(--cf-ui-surface);font-size:.85rem}</style><span class="badge" part="badge"></span>`; this.shadowRoot.querySelector(".badge").textContent = this.getAttribute("scope") || this.textContent || ""; }
19
+ connectedCallback() { this.attachShadow({ mode: "open" }).innerHTML = `<style>${enhancedStyles}.badge{display:inline-block;border:1px solid var(--cf-ui-border);border-radius:999px;padding:.15rem .5rem;color:var(--cf-ui-primary);background:var(--cf-ui-surface);font-size:.85rem;cursor:help}</style><span class="badge" part="badge"></span>`; const name = this.getAttribute("scope") || this.textContent || ""; this.shadowRoot.querySelector(".badge").textContent = name; this.shadowRoot.querySelector(".badge").title = this.getAttribute("label") || name; }
13
20
  }
14
21
 
15
22
  export class CfUserManagement extends HTMLElement {
16
- async connectedCallback() { this.attachShadow({ mode: "open" }).innerHTML = `<style>${styles}</style><section class="card" part="panel"><h2>User access</h2><p class="status" part="status">Loading users and scopes…</p><table hidden part="table"><thead><tr><th>User</th><th>Scopes</th><th>Save</th></tr></thead><tbody></tbody></table></section>`; try { await this.load(); } catch (error) { this.status.textContent = error.message || "Unable to load access data."; } }
23
+ async connectedCallback() { this.attachShadow({ mode: "open" }).innerHTML = `<style>${enhancedStyles}</style><section class="card" part="panel"><h2>User access</h2><p class="status" part="status">Loading users and scopes…</p><table hidden part="table"><thead><tr><th>User</th><th>Scopes</th><th>Save</th></tr></thead><tbody></tbody></table></section>`; try { await this.load(); } catch (error) { this.status.textContent = error.message || "Unable to load access data."; } }
17
24
  get status() { return this.shadowRoot.querySelector(".status"); }
18
25
  async load() { const [usersResponse, scopesResponse] = await Promise.all([fetch("/api/admin/users", { credentials: "same-origin" }), fetch("/api/admin/scopes", { credentials: "same-origin" })]); if (!usersResponse.ok || !scopesResponse.ok) throw new Error("Unable to load user access."); const users = (await usersResponse.json()).users || []; const scopes = (await scopesResponse.json()).scopes || []; const body = this.shadowRoot.querySelector("tbody"); body.replaceChildren(...users.map((user) => this.row(user, scopes))); this.shadowRoot.querySelector("table").hidden = false; this.status.textContent = `${users.length} user${users.length === 1 ? "" : "s"}`; }
19
- row(user, scopes) { const row = document.createElement("tr"); const identity = document.createElement("td"); identity.textContent = `${user.display_name || user.email || "Unnamed user"} (${user.email || "no email"})`; const grants = document.createElement("td"); const list = document.createElement("div"); list.className = "scope-list"; for (const scope of scopes) { const label = document.createElement("label"); const input = document.createElement("input"); input.type = "checkbox"; input.value = scope.name; input.checked = Boolean((user.scopes || []).includes(scope.name)); label.append(input, document.createTextNode(scope.label || scope.name)); list.append(label); } grants.append(list); const action = document.createElement("td"); const button = document.createElement("button"); button.textContent = "Save"; button.addEventListener("click", async () => { button.disabled = true; const selected = [...list.querySelectorAll("input:checked")].map((input) => input.value); const response = await fetch(`/api/admin/users/${encodeURIComponent(user.id)}/scopes`, { method: "PUT", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scopes: selected }) }); button.disabled = false; this.status.textContent = response.ok ? "Access saved." : "Unable to save access."; }); action.append(button); row.append(identity, grants, action); return row; }
26
+ row(user, scopes) { const row = document.createElement("tr"); const identity = document.createElement("td"); identity.textContent = `${user.display_name || user.email || "Unnamed user"} (${user.email || "no email"})`; const grants = document.createElement("td"); const list = document.createElement("div"); list.className = "scope-list"; for (const scope of scopes) { const label = document.createElement("label"); const input = document.createElement("input"); input.type = "checkbox"; input.value = scope.name; input.checked = Boolean((user.scopes || []).includes(scope.name)); const text = document.createElement("span"); text.textContent = scope.label || scope.name; text.title = scope.name; label.append(input, text); list.append(label); } grants.append(list); const action = document.createElement("td"); const button = document.createElement("button"); button.textContent = "Save"; button.addEventListener("click", async () => { button.disabled = true; const selected = [...list.querySelectorAll("input:checked")].map((input) => input.value); const response = await fetch(`/api/admin/users/${encodeURIComponent(user.id)}/scopes`, { method: "PUT", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scopes: selected }) }); button.disabled = false; this.status.textContent = response.ok ? "Access saved." : "Unable to save access."; }); action.append(button); row.append(identity, grants, action); return row; }
20
27
  }
21
28
 
22
29
  if (!customElements.get("cf-admin-shell")) customElements.define("cf-admin-shell", CfAdminShell);
@@ -25,7 +32,7 @@ if (!customElements.get("cf-user-management")) customElements.define("cf-user-ma
25
32
 
26
33
  export class CfScopeCatalog extends HTMLElement {
27
34
  async connectedCallback() {
28
- this.attachShadow({ mode: "open" }).innerHTML = `<style>${styles}</style><section class="card" part="panel"><h2>Available scopes</h2><p class="status" part="status">Loading scopes…</p><div class="scope-list" hidden part="list"></div></section>`;
35
+ this.attachShadow({ mode: "open" }).innerHTML = `<style>${enhancedStyles}</style><section class="card" part="panel"><h2>Available scopes</h2><p class="status" part="status">Loading scopes…</p><div class="scope-list" hidden part="list"></div></section>`;
29
36
  try {
30
37
  const response = await fetch("/api/admin/scopes", { credentials: "same-origin" });
31
38
  if (!response.ok) throw new Error("Unable to load scopes.");
@@ -36,7 +43,8 @@ export class CfScopeCatalog extends HTMLElement {
36
43
  const badge = document.createElement("cf-scope-badge");
37
44
  badge.setAttribute("scope", scope.name);
38
45
  const description = document.createElement("span");
39
- description.textContent = scope.description || scope.label || scope.name;
46
+ description.textContent = ` ${scope.label || scope.description || scope.name}`;
47
+ description.title = scope.label || scope.name;
40
48
  item.append(badge, description);
41
49
  return item;
42
50
  }));
@@ -48,16 +56,32 @@ export class CfScopeCatalog extends HTMLElement {
48
56
  }
49
57
  }
50
58
 
51
- if (!customElements.get("cf-scope-catalog")) customElements.define("cf-scope-catalog", CfScopeCatalog);
52
59
  if (!customElements.get("cf-scope-catalog")) customElements.define("cf-scope-catalog", CfScopeCatalog);
53
60
 
54
61
  export class CfHealthcheckCatalog extends HTMLElement {
55
- 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; } }
62
+ async connectedCallback() {
63
+ this.attachShadow({ mode: "open" }).innerHTML = `<style>${enhancedStyles}</style><section class="card"><h2>Healthchecks</h2><p class="status">Loading…</p><div class="status-grid" hidden></div></section>`;
64
+ try { const response = await fetch("/api/admin/healthchecks", { credentials: "same-origin" }); if (!response.ok) throw new Error("Unable to load healthchecks."); let items = (await response.json()).healthchecks || []; const feature = new URLSearchParams(location.search).get("feature"); if (feature) items = items.filter((item) => item.feature === feature); const grid = this.shadowRoot.querySelector(".status-grid"); grid.replaceChildren(...items.map((item) => { const card = document.createElement("article"); card.className = "status-card"; card.innerHTML = `<h3></h3><div class="status-line"></div><p></p>`; card.querySelector("h3").textContent = item.display_name; card.querySelector(".status-line").innerHTML = stateMarkup(item.state); card.querySelector("p").textContent = `${item.feature} / ${item.component}`; return card; })); grid.hidden = false; this.shadowRoot.querySelector(".status").textContent = `${items.length} healthcheck${items.length === 1 ? "" : "s"}${feature ? ` for ${feature}` : ""}`; } catch (error) { this.shadowRoot.querySelector(".status").textContent = error.message; }
65
+ }
56
66
  }
57
67
 
58
68
  export class CfCircuitBreakerCatalog extends HTMLElement {
59
- 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; } }
69
+ async connectedCallback() {
70
+ this.attachShadow({ mode: "open" }).innerHTML = `<style>${enhancedStyles}</style><section class="card"><h2>Circuit breakers</h2><p class="status">Loading…</p><div class="breaker-groups" hidden></div><p class="error" role="status"></p></section>`;
71
+ try { const response = await fetch("/api/admin/circuit-breakers", { credentials: "same-origin" }); if (!response.ok) throw new Error("Unable to load circuit breakers."); let items = (await response.json()).circuit_breakers || []; const feature = new URLSearchParams(location.search).get("feature"); if (feature) items = items.filter((item) => item.feature === feature); const groups = new Map(); for (const item of items) { if (!groups.has(item.feature)) groups.set(item.feature, []); groups.get(item.feature).push(item); } const root = this.shadowRoot.querySelector(".breaker-groups"); root.replaceChildren(...[...groups].map(([featureName, breakers]) => this.group(featureName, breakers))); root.hidden = false; this.shadowRoot.querySelector(".status").textContent = `${items.length} circuit breaker${items.length === 1 ? "" : "s"}${feature ? ` for ${feature}` : ""}`; } catch (error) { this.shadowRoot.querySelector(".status").textContent = error.message; }
72
+ }
73
+ group(feature, breakers) { const section = document.createElement("section"); section.className = "breaker-group"; const heading = document.createElement("div"); heading.className = "breaker-group-heading"; heading.innerHTML = `<div><h3></h3><span class="breaker-key"></span></div><a href="/admin/features?feature=${encodeURIComponent(feature)}">View feature</a>`; heading.querySelector("h3").textContent = feature; heading.querySelector(".breaker-key").textContent = `${breakers.length} breaker${breakers.length === 1 ? "" : "s"}`; section.append(heading); const list = document.createElement("div"); list.className = "breaker-list"; const ordered = [...breakers].sort((a, b) => Number(b.name === "rollup") - Number(a.name === "rollup") || a.name.localeCompare(b.name)); list.replaceChildren(...ordered.map((breaker) => this.row(breaker))); section.append(list); return section; }
74
+ row(breaker) { const row = document.createElement("div"); row.className = `breaker-row${breaker.name === "rollup" ? " rollup" : ""}`; row.innerHTML = `<div><span class="breaker-name"></span><span class="breaker-key"></span></div><span class="breaker-state"></span><div class="breaker-actions"><button data-state="off">Off</button><button data-state="tripped">Tripped</button><button data-state="on">On</button></div>`; row.querySelector(".breaker-name").textContent = breaker.name === "rollup" ? `↳ ${breaker.display_name} · feature rollup` : breaker.display_name; row.querySelector(".breaker-key").textContent = `${breaker.feature}/${breaker.name}`; row.querySelector(".breaker-state").innerHTML = stateMarkup(breaker.state); for (const button of row.querySelectorAll("button")) { button.classList.toggle("is-active", button.dataset.state === breaker.state); button.addEventListener("click", () => this.update(breaker, row, button.dataset.state)); } return row; }
75
+ async update(breaker, row, state) { const buttons = [...row.querySelectorAll("button")]; buttons.forEach((button) => { button.disabled = true; }); try { const response = await fetch(`/api/admin/circuit-breakers/${encodeURIComponent(breaker.id)}`, { method: "PUT", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ state }) }); const result = await response.json(); if (!response.ok) throw new Error(result.error || "Unable to update circuit breaker."); breaker.state = result.circuit_breaker?.state || state; row.querySelector(".breaker-state").innerHTML = stateMarkup(breaker.state); buttons.forEach((button) => button.classList.toggle("is-active", button.dataset.state === breaker.state)); } catch (error) { this.shadowRoot.querySelector(".error").textContent = error.message; } finally { buttons.forEach((button) => { button.disabled = false; }); } }
76
+ }
77
+
78
+ export class CfFeatureCatalog extends HTMLElement {
79
+ async connectedCallback() {
80
+ this.attachShadow({ mode: "open" }).innerHTML = `<style>${enhancedStyles}</style><section class="card"><h2>Installed features</h2><p class="status">Loading…</p><div class="feature-grid" hidden></div></section>`;
81
+ try { const response = await fetch("/api/admin/features", { credentials: "same-origin" }); if (!response.ok) throw new Error("Unable to load features."); let items = (await response.json()).features || []; const selected = new URLSearchParams(location.search).get("feature"); if (selected) items = items.filter((item) => item.feature === selected); const grid = this.shadowRoot.querySelector(".feature-grid"); grid.replaceChildren(...items.map((item) => { const card = document.createElement("article"); card.className = "feature-card"; const rollup = item.circuit_breaker; card.innerHTML = `<h3></h3><p class="feature-health"></p><p class="feature-rollup"></p><div class="feature-links"><a class="health-link">Healthchecks</a><a class="breaker-link">Circuit breakers</a></div>`; card.querySelector("h3").textContent = item.display_name || item.feature; card.querySelector(".feature-health").innerHTML = `Health: ${stateMarkup(item.health)}`; card.querySelector(".feature-rollup").innerHTML = `Rollup: ${rollup ? stateMarkup(rollup.state) : "—"}`; card.querySelector(".health-link").href = `/admin/healthchecks?feature=${encodeURIComponent(item.feature)}`; card.querySelector(".breaker-link").href = `/admin/circuit-breakers?feature=${encodeURIComponent(item.feature)}`; return card; })); grid.hidden = false; this.shadowRoot.querySelector(".status").textContent = `${items.length} feature${items.length === 1 ? "" : "s"}`; } catch (error) { this.shadowRoot.querySelector(".status").textContent = error.message; }
82
+ }
60
83
  }
61
84
 
62
85
  if (!customElements.get("cf-healthcheck-catalog")) customElements.define("cf-healthcheck-catalog", CfHealthcheckCatalog);
63
86
  if (!customElements.get("cf-circuit-breaker-catalog")) customElements.define("cf-circuit-breaker-catalog", CfCircuitBreakerCatalog);
87
+ if (!customElements.get("cf-feature-catalog")) customElements.define("cf-feature-catalog", CfFeatureCatalog);