@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 +3 -2
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/authorization.js +50 -4
- package/src/core.js +1 -1
- package/src/data.js +5 -2
- package/src/index.js +46 -3
- package/src/ui/index.js +3 -3
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
|
-
`
|
|
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
|
|
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
package/src/authorization.js
CHANGED
|
@@ -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),
|
|
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 ||
|
|
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),
|
|
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.
|
|
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.
|
|
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?.(
|
|
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
|
|
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);
|