@agilesyndrome/cf-genai-base 4.1.1 → 4.1.3

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
@@ -4,7 +4,7 @@ Every site built from this foundation follows the same edge contract.
4
4
 
5
5
  ## Worker entrypoint
6
6
 
7
- `createWorker({ fetch, features?, middleware?, auth?, authorize?, scheduled?, security? })` owns the Worker lifecycle and reserved admin boundary. Features run in declaration order and may call `next()` or return a response. The site router owns pages, APIs, D1 queries, and R2 object keys. `scheduled`
7
+ `createWorker({ fetch, features?, middleware?, auth?, authorize?, scheduled?, security? })` owns the Worker lifecycle and reserved admin boundary. Features run in declaration order and may call `next()` or return a response. A feature may also declare `{ routes: [{ match, handle }] }`; matching handlers receive `{ request, env, ctx, state, next }` and run before the site handler. The site router owns pages, APIs, D1 queries, and R2 object keys. `scheduled`
8
8
  is optional and must use `ctx.waitUntil` for background work.
9
9
 
10
10
  ## Routes
@@ -68,7 +68,8 @@ updates and deletes. Anonymous tenant reads require an explicit worker
68
68
  adds a row visibility predicate.
69
69
  Request handlers receive `state.data`, whose scope-specific readers apply the
70
70
  validated user or tenant predicate. Domain handlers must not use unrestricted
71
- `env.DB` for registered resources. Base cannot provide row-level security to
71
+ `readableColumns` list the only columns returned by reads; callers must declare
72
+ them explicitly. Base cannot provide row-level security to
72
73
  direct D1 calls, so applications must keep raw database access out of domain
73
74
  features. The cookbook migration must add and backfill `tenant_id` on recipe
74
75
  tables, register recipes as tenant-scoped, replace direct D1 reads/writes with
package/README.md CHANGED
@@ -70,7 +70,7 @@ Use the selected D1 target (local by default) to inspect and update users:
70
70
  cf-genai user get someone.com --target staging
71
71
  cf-genai user update someone.com --roles admin --target production
72
72
 
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.
73
+ `user:get` also reports scopes, groups, and tenant memberships. The shared admin user page displays each user’s tenant memberships and lets an administrator attach or detach tenants. The admin API exposes `GET|POST /api/admin/tenants`, `GET|PUT /api/admin/tenants/:id`, and `GET|PUT /api/admin/users/:id/tenants`. Production commands should be run through the repository credentials wrapper and reviewed as an administrative change.
74
74
 
75
75
  ## Scoped data access
76
76
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agilesyndrome/cf-genai-base",
3
- "version": "4.1.1",
3
+ "version": "4.1.3",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.js",
@@ -32,13 +32,14 @@ export async function ensureUser(env, user, { who = "system:read" } = {}) {
32
32
  const email = String(user.email || "").trim().toLowerCase();
33
33
  const existing = await db.prepare(`SELECT * FROM ${AUTH_USER_TABLE} WHERE provider=? AND subject=?`).bind(provider, subject).first();
34
34
  const bootstrap = new Set(String(env.AUTH_ADMIN_EMAILS || env.ADMIN_EMAILS || "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean));
35
+ const bootstrapAdmin = user.email_verified === true && bootstrap.has(email);
35
36
  if (existing) {
36
- 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();
37
+ 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), bootstrapAdmin ? 1 : 0, existing.id).run();
37
38
  await ensureDefaultTenantMembership(db, existing.id);
38
- return { ...existing, email, display_name: String(user.name || email || subject), is_admin: Boolean(existing.is_admin || bootstrap.has(email)) };
39
+ return { ...existing, email, display_name: String(user.name || email || subject), is_admin: Boolean(existing.is_admin || bootstrapAdmin) };
39
40
  }
40
41
  const id = await stableId(`${provider}:${subject}`);
41
- 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();
42
+ 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), bootstrapAdmin ? 1 : 0).run();
42
43
  await ensureDefaultTenantMembership(db, id);
43
44
  return await db.prepare(`SELECT * FROM ${AUTH_USER_TABLE} WHERE id=?`).bind(id).first();
44
45
  }
@@ -137,7 +138,52 @@ export async function hasScope(env, user, scope, { who = "system:read" } = {}) {
137
138
  export async function listAuthorizationUsers(env, { who = "system:read" } = {}) {
138
139
  const db = createD1(env, { who });
139
140
  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();
140
- return Promise.all(results.map(async (user) => ({ ...user, scopes: (await listUserGrants(env, user.id, { who })).map((grant) => grant.scope_name) })));
141
+ return Promise.all(results.map(async (user) => ({ ...user, scopes: (await listUserGrants(env, user.id, { who })).map((grant) => grant.scope_name), tenants: await listUserTenants(env, user.id, { who }) })));
142
+ }
143
+
144
+ export async function listAuthorizationTenants(env, { who = "system:read" } = {}) {
145
+ const db = createD1(env, { who });
146
+ const { results } = await db.prepare(`SELECT t.id,t.name,t.created_at,t.updated_at,COUNT(ut.user_id) AS user_count FROM auth_tenants t LEFT JOIN auth_user_tenants ut ON ut.tenant_id=t.id GROUP BY t.id ORDER BY t.name COLLATE NOCASE`).all();
147
+ return results || [];
148
+ }
149
+
150
+ export async function getAuthorizationTenant(env, tenantId, { who = "system:read" } = {}) {
151
+ const db = createD1(env, { who });
152
+ return db.prepare("SELECT id,name,created_at,updated_at FROM auth_tenants WHERE id=?").bind(String(tenantId)).first();
153
+ }
154
+
155
+ export async function createAuthorizationTenant(env, tenantId, name, { who = "system:update" } = {}) {
156
+ const id = String(tenantId || "").trim();
157
+ const tenantName = String(name || "").trim();
158
+ if (!/^[a-z0-9][a-z0-9_-]*$/.test(id)) throw new Error("Tenant id must contain lowercase letters, numbers, hyphens, or underscores.");
159
+ if (!tenantName) throw new Error("Tenant name is required.");
160
+ const db = createD1(env, { who });
161
+ await db.prepare("INSERT INTO auth_tenants (id,name) VALUES (?,?)").bind(id, tenantName).run();
162
+ return getAuthorizationTenant(env, id, { who });
163
+ }
164
+
165
+ export async function updateAuthorizationTenant(env, tenantId, name, { who = "system:update" } = {}) {
166
+ const tenantName = String(name || "").trim();
167
+ if (!tenantName) throw new Error("Tenant name is required.");
168
+ const db = createD1(env, { who });
169
+ await db.prepare("UPDATE auth_tenants SET name=?,updated_at=CURRENT_TIMESTAMP WHERE id=?").bind(tenantName, String(tenantId)).run();
170
+ return getAuthorizationTenant(env, tenantId, { who });
171
+ }
172
+
173
+ export async function replaceUserTenants(env, userId, tenantIds, { who = "system:update" } = {}) {
174
+ const db = createD1(env, { who });
175
+ const requested = [...new Set((tenantIds || []).map((id) => String(id).trim()).filter(Boolean))];
176
+ if (requested.length) {
177
+ const placeholders = requested.map(() => "?").join(",");
178
+ const { results } = await db.prepare(`SELECT id FROM auth_tenants WHERE id IN (${placeholders})`).bind(...requested).all();
179
+ const found = new Set((results || []).map((row) => row.id));
180
+ if (found.size !== requested.length) throw new Error("One or more tenants do not exist.");
181
+ }
182
+ await db.batch([
183
+ db.prepare("DELETE FROM auth_user_tenants WHERE user_id=?").bind(userId),
184
+ ...requested.map((tenantId) => db.prepare("INSERT INTO auth_user_tenants (user_id,tenant_id) VALUES (?,?)").bind(userId, tenantId)),
185
+ ]);
186
+ return listUserTenants(env, userId, { who });
141
187
  }
142
188
 
143
189
  export async function getAuthorizationUser(env, userId, { who = "system:read" } = {}) {
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.7";
5
+ export const BASE_VERSION = "4.1.1";
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 CHANGED
@@ -36,11 +36,14 @@ export function normalizeDataResources(resources = []) {
36
36
  if (writableColumns.some((column) => !columns.includes(column))) throw new TypeError(`Data resource ${resource.name} references an unwritable column`);
37
37
  const columnScopes = Object.fromEntries(Object.entries(resource.columnScopes || {}).map(([column, scopes]) => [String(column), Array.isArray(scopes) ? scopes.map(String) : [String(scopes)]]));
38
38
  if (Object.keys(columnScopes).some((column) => !columns.includes(column) || columnScopes[column].some((scope) => !/^[a-z0-9]+(?::[a-z0-9-]+)+$/.test(scope)))) throw new TypeError(`Data resource ${resource.name} has invalid column scopes`);
39
+ if (!Array.isArray(resource.readableColumns) || !resource.readableColumns.length) throw new TypeError(`Data resource ${resource.name} requires explicit readableColumns`);
40
+ const readableColumns = [...new Set(resource.readableColumns.map(String))];
41
+ if (readableColumns.some((column) => !columns.includes(column))) throw new TypeError(`Data resource ${resource.name} references an unreadable column`);
39
42
  const operations = [...new Set((resource.operations || ["read", ...(writableColumns.length ? ["create", "update", "delete"] : [])]).map((operation) => String(operation).toLowerCase()))];
40
43
  if (!operations.length || operations.some((operation) => !DATA_OPERATIONS.includes(operation)) || !operations.includes("read")) throw new TypeError(`Data resource ${name} has invalid operations`);
41
44
  const publicRead = resource.publicRead && typeof resource.publicRead === "object" ? { column: String(resource.publicRead.column || ""), value: resource.publicRead.value } : Boolean(resource.publicRead);
42
45
  if (publicRead && typeof publicRead === "object" && (!/^[a-z][a-z0-9_]*$/.test(publicRead.column) || !columns.includes(publicRead.column))) throw new TypeError(`Public resource ${resource.name} requires a selected visibility column`);
43
- return { ...resource, name, table: String(resource.table), scope, columns, idColumn, ownerColumn, tenantColumn, filterableColumns, orderableColumns, writableColumns, columnScopes, operations, publicRead };
46
+ return { ...resource, name, table: String(resource.table), scope, columns, readableColumns, idColumn, ownerColumn, tenantColumn, filterableColumns, orderableColumns, writableColumns, columnScopes, operations, publicRead };
44
47
  });
45
48
  }
46
49
 
@@ -74,7 +77,7 @@ export function createDataReader(env, { resources = [], context } = {}) {
74
77
  if (cursor !== undefined && cursor !== null) { predicates.push(`${quote(resource.idColumn)}>?`); bindings.push(cursor); }
75
78
  const safeLimit = Math.min(Math.max(Number(limit) || 100, 1), 1000);
76
79
  const safeOffset = Math.max(Number(offset) || 0, 0);
77
- let sql = `SELECT ${resource.columns.map(quote).join(",")} FROM ${quote(resource.table)}${predicates.length ? ` WHERE ${predicates.join(" AND ")}` : ""} LIMIT ${safeLimit} OFFSET ${safeOffset}`;
80
+ let sql = `SELECT ${resource.readableColumns.map(quote).join(",")} FROM ${quote(resource.table)}${predicates.length ? ` WHERE ${predicates.join(" AND ")}` : ""} LIMIT ${safeLimit} OFFSET ${safeOffset}`;
78
81
  if (orderBy) {
79
82
  const [column, direction = "ASC"] = String(orderBy).split(/\s+/, 2);
80
83
  if (!resource.orderableColumns.includes(column)) throw new TypeError(`Column ${column} cannot order ${resource.name}`);
package/src/index.js CHANGED
@@ -2,19 +2,50 @@
2
2
  * Lean, opinionated Worker composition for Cloudflare sites.
3
3
  * Site code owns domain routes and data; this owns lifecycle and edge concerns.
4
4
  */
5
- import { createImpersonationToken, ensureScopes, ensureSubscriptionManifest, ensureUser, getAuthorizationUser, hasScope, listAuthorizationScopes, listAuthorizationUsers, listGroups, listTenantSubscriptions, listUserGroups, listUserGrants, replaceUserGroups, replaceUserGrants, SubscriptionError } from "./authorization.js";
5
+ import { createAuthorizationTenant, createImpersonationToken, ensureScopes, ensureSubscriptionManifest, ensureUser, getAuthorizationTenant, getAuthorizationUser, hasScope, listAuthorizationScopes, listAuthorizationTenants, listAuthorizationUsers, listGroups, listTenantSubscriptions, listUserGroups, listUserTenants, listUserGrants, replaceUserGroups, replaceUserTenants, replaceUserGrants, SubscriptionError, updateAuthorizationTenant } from "./authorization.js";
6
6
  import { getCircuitBreaker, evaluateCircuitBreaker, listCircuitBreakers, listHealthchecks, listFeatureCatalog, listFeatureHealth, registerFeatureManifests, requestActor, setCircuitBreaker, updateHealthcheck } from "./core.js";
7
7
  import { createDataReader, DataScopeError, normalizeDataResources, requestDataContext } from "./data.js";
8
8
  export * from "./core.js";
9
9
  export * from "./data.js";
10
10
  export * from "./authorization.js";
11
+ const featureRegistrationPromises = new WeakMap();
12
+
13
+ export function ensureFeatureManifests(env, features = [], { who = "system:update" } = {}) {
14
+ if (!env || typeof env !== "object" || !env.DB) return Promise.resolve();
15
+ const key = features.map((feature) => String(feature?.name || feature?.id || "feature")).join("|");
16
+ let registrations = featureRegistrationPromises.get(env.DB);
17
+ if (!registrations) {
18
+ registrations = new Map();
19
+ featureRegistrationPromises.set(env.DB, registrations);
20
+ }
21
+ let promise = registrations.get(key);
22
+ if (!promise) {
23
+ promise = (async () => {
24
+ await registerFeatureManifests(env, features, { who });
25
+ const breakers = await listCircuitBreakers(env, { who });
26
+ await Promise.all(breakers.filter(Boolean).map((breaker) => evaluateCircuitBreaker(env, breaker.id, { who })));
27
+ })();
28
+ registrations.set(key, promise);
29
+ promise.catch(() => registrations.delete(key));
30
+ }
31
+ return promise;
32
+ }
33
+
11
34
  export function createWorker({ fetch, scheduled, auth, authorize, scopes = [], subscriptionManifest = [], scopeRoutes = [], middleware = [], features = [], dataResources = [], publicTenantId = null, health, boot, metrics, security = true, adminPage, siteAdminPage }) {
12
35
  if (typeof fetch !== "function") throw new TypeError("createWorker requires a fetch handler");
13
36
  const provider = auth || features.find((feature) => typeof feature?.getUser === "function");
14
37
  const registeredDataResources = normalizeDataResources([...dataResources, ...features.flatMap((feature) => Array.isArray(feature?.dataResources) ? feature.dataResources : [])]);
38
+ const featureRoutes = features.flatMap((feature) => Array.isArray(feature?.routes) ? [async (request, env, ctx, next, state) => {
39
+ for (const route of feature.routes) {
40
+ const matches = typeof route?.match === "function" ? await route.match(request, env, state) : route?.path === new URL(request.url).pathname;
41
+ if (matches && typeof route.handle === "function") return route.handle({ request, env, ctx, state, next });
42
+ }
43
+ return next();
44
+ }] : []);
15
45
  const chain = [
16
46
  (request, env, ctx, next, state) => adminBoundary(request, env, ctx, next, state, { provider, authorize, scopes, scopeRoutes, features, adminPage, siteAdminPage }),
17
47
  ...features.flatMap((feature) => feature?.middleware ? [feature.middleware.bind(feature)] : []),
48
+ ...featureRoutes,
18
49
  ...middleware,
19
50
  ...(auth ? [(request, env, ctx, next) => auth(request, env, ctx, next)] : []),
20
51
  ].filter(Boolean);
@@ -27,7 +58,7 @@ export function createWorker({ fetch, scheduled, auth, authorize, scopes = [], s
27
58
  const state = Object.create(null);
28
59
  if (provider?.getUser) state.user = await provider.getUser(request, env).catch(() => null);
29
60
  state.data = createDataReader(env, { resources: registeredDataResources, context: () => requestDataContext(env, { state, request, publicTenantId }) });
30
- 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)));
61
+ 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?.(ensureFeatureManifests(env, features).catch((error) => console.error("[EventLog] feature manifest registration failed", error)));
31
62
  const dispatch = async (index, currentRequest = request) => {
32
63
  const layer = chain[index];
33
64
  if (!layer) {
@@ -76,6 +107,10 @@ async function adminBoundary(request, env, ctx, next, state, { provider, authori
76
107
  } else {
77
108
  return new Response("Unsupported AUTH_STRATEGY", { status: 500, headers: { "Cache-Control": "no-store" } });
78
109
  }
110
+ if (["POST", "PUT", "PATCH", "DELETE"].includes(request.method) && url.pathname.startsWith("/api/")) {
111
+ const origin = request.headers.get("Origin");
112
+ if (!origin || (() => { try { return new URL(origin).origin !== url.origin; } catch { return true; } })()) return Response.json({ error: "A same-origin request is required." }, { status: 403, headers: { "Cache-Control": "no-store" } });
113
+ }
79
114
  await ensureScopes(env, scopes, { who: state.user?.auth_strategy === "http_basic" ? "user:admin" : `user:${state.user?.sub || "unknown"}` });
80
115
  state.authUser = await ensureUser(env, state.user, { who: state.user?.auth_strategy === "http_basic" ? "user:admin" : `user:${state.user?.sub || "unknown"}` });
81
116
  state.requestedBy = requestActor(state);
@@ -117,11 +152,16 @@ function requiredScopeFor(pathname, routes) {
117
152
 
118
153
  async function authorizationApi(request, env, url, state, features = []) {
119
154
  const grantsMatch = url.pathname.match(/\/api\/admin\/users\/([^/]+)\/scopes$/);
120
- 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.startsWith("/api/admin/impersonate") || 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);
155
+ const platformPath = url.pathname === "/api/admin/users" || url.pathname === "/api/admin/tenants" || url.pathname.startsWith("/api/admin/tenants/") || url.pathname === "/api/admin/scopes" || url.pathname === "/api/admin/groups" || url.pathname.startsWith("/api/admin/users/") || url.pathname.startsWith("/api/admin/impersonate") || 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);
121
156
  if (!platformPath) return null;
122
157
  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" } });
123
158
  if (url.pathname === "/api/admin/impersonate/clear" && request.method === "POST") return new Response(JSON.stringify({ ok: true }), { headers: { "content-type": "application/json; charset=utf-8", "Set-Cookie": "__Host-cfgenai_impersonation=; Max-Age=0; Path=/; Secure; HttpOnly; SameSite=Lax" } });
124
159
  if (url.pathname === "/api/admin/users" && request.method === "GET") return Response.json({ users: await listAuthorizationUsers(env, { who: requestActor(state) }) });
160
+ if (url.pathname === "/api/admin/tenants" && request.method === "GET") return Response.json({ tenants: await listAuthorizationTenants(env, { who: requestActor(state) }) });
161
+ if (url.pathname === "/api/admin/tenants" && request.method === "POST") { const body = await request.json().catch(() => null); if (!body?.id || !body?.name) return Response.json({ error: "id and name are required" }, { status: 400 }); try { return Response.json({ tenant: await createAuthorizationTenant(env, body.id, body.name, { who: requestActor(state) }) }, { status: 201 }); } catch (error) { return Response.json({ error: error.message }, { status: 400 }); } }
162
+ const tenantMatch = url.pathname.match(/^\/api\/admin\/tenants\/([^/]+)$/);
163
+ if (tenantMatch && request.method === "GET") return Response.json({ tenant: await getAuthorizationTenant(env, decodeURIComponent(tenantMatch[1]), { who: requestActor(state) }) });
164
+ if (tenantMatch && request.method === "PUT") { const body = await request.json().catch(() => null); if (!body?.name) return Response.json({ error: "name is required" }, { status: 400 }); return Response.json({ tenant: await updateAuthorizationTenant(env, decodeURIComponent(tenantMatch[1]), body.name, { who: requestActor(state) }) }); }
125
165
  const impersonateMatch = url.pathname.match(/^\/api\/admin\/users\/([^/]+)\/impersonate$/);
126
166
  if (impersonateMatch && request.method === "POST") {
127
167
  const target = decodeURIComponent(impersonateMatch[1]);
@@ -144,6 +184,9 @@ async function authorizationApi(request, env, url, state, features = []) {
144
184
  const groupsMatch = url.pathname.match(/\/api\/admin\/users\/([^/]+)\/groups$/);
145
185
  if (groupsMatch && request.method === "GET") return Response.json({ groups: await listUserGroups(env, decodeURIComponent(groupsMatch[1]), { who: requestActor(state) }) });
146
186
  if (groupsMatch && request.method === "PUT") { const body = await request.json().catch(() => null); if (!body || !Array.isArray(body.groups)) return Response.json({ error: "groups must be an array" }, { status: 400 }); return Response.json({ groups: await replaceUserGroups(env, decodeURIComponent(groupsMatch[1]), body.groups, state.authUser && state.authUser.id, { who: requestActor(state) }) }); }
187
+ const tenantsMatch = url.pathname.match(/\/api\/admin\/users\/([^/]+)\/tenants$/);
188
+ if (tenantsMatch && request.method === "GET") return Response.json({ tenants: await listUserTenants(env, decodeURIComponent(tenantsMatch[1]), { who: requestActor(state) }) });
189
+ if (tenantsMatch && request.method === "PUT") { const body = await request.json().catch(() => null); if (!body || !Array.isArray(body.tenants)) return Response.json({ error: "tenants must be an array" }, { status: 400 }); return Response.json({ tenants: await replaceUserTenants(env, decodeURIComponent(tenantsMatch[1]), body.tenants, { who: requestActor(state) }) }); }
147
190
  if (grantsMatch && request.method === "GET") return Response.json({ grants: await listUserGrants(env, decodeURIComponent(grantsMatch[1]), { who: requestActor(state) }) });
148
191
  if (grantsMatch && request.method === "PUT") {
149
192
  const body = await request.json().catch(() => null);
package/src/ui/index.js CHANGED
@@ -20,10 +20,10 @@ export class CfScopeBadge extends HTMLElement {
20
20
  }
21
21
 
22
22
  export class CfUserManagement extends HTMLElement {
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."; } }
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, tenants, and scopes…</p><table hidden part="table"><thead><tr><th>User</th><th>Tenants</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."; } }
24
24
  get status() { return this.shadowRoot.querySelector(".status"); }
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"}`; }
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; }
25
+ async load() { const [usersResponse, scopesResponse, tenantsResponse] = await Promise.all([fetch("/api/admin/users", { credentials: "same-origin" }), fetch("/api/admin/scopes", { credentials: "same-origin" }), fetch("/api/admin/tenants", { credentials: "same-origin" })]); if (!usersResponse.ok || !scopesResponse.ok || !tenantsResponse.ok) throw new Error("Unable to load user access."); const users = (await usersResponse.json()).users || []; const scopes = (await scopesResponse.json()).scopes || []; const tenants = (await tenantsResponse.json()).tenants || []; const body = this.shadowRoot.querySelector("tbody"); body.replaceChildren(...users.map((user) => this.row(user, scopes, tenants))); this.shadowRoot.querySelector("table").hidden = false; this.status.textContent = `${users.length} user${users.length === 1 ? "" : "s"}`; }
26
+ row(user, scopes, tenants) { const row = document.createElement("tr"); const identity = document.createElement("td"); identity.textContent = `${user.display_name || user.email || "Unnamed user"} (${user.email || "no email"})`; const tenantCell = document.createElement("td"); const tenantList = document.createElement("div"); tenantList.className = "scope-list"; const currentTenants = new Set((user.tenants || []).map((tenant) => tenant.id)); for (const tenant of tenants) { const label = document.createElement("label"); const input = document.createElement("input"); input.type = "checkbox"; input.value = tenant.id; input.checked = currentTenants.has(tenant.id); const text = document.createElement("span"); text.textContent = tenant.name; text.title = tenant.id; label.append(input, text); tenantList.append(label); } tenantCell.append(tenantList); 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 selectedScopes = [...list.querySelectorAll("input:checked")].map((input) => input.value); const selectedTenants = [...tenantList.querySelectorAll("input:checked")].map((input) => input.value); const [scopeResponse, tenantResponse] = await Promise.all([fetch(`/api/admin/users/${encodeURIComponent(user.id)}/scopes`, { method: "PUT", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scopes: selectedScopes }) }), fetch(`/api/admin/users/${encodeURIComponent(user.id)}/tenants`, { method: "PUT", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tenants: selectedTenants }) })]); button.disabled = false; this.status.textContent = scopeResponse.ok && tenantResponse.ok ? "Access saved." : "Unable to save access."; }); action.append(button); row.append(identity, tenantCell, grants, action); return row; }
27
27
  }
28
28
 
29
29
  if (!customElements.get("cf-admin-shell")) customElements.define("cf-admin-shell", CfAdminShell);