@agilesyndrome/cf-genai-base 4.1.2 → 5.0.0
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 +24 -7
- package/README.md +59 -25
- package/changelog.md +15 -0
- package/migrations/0006_jobs.sql +38 -0
- package/package.json +15 -5
- package/src/admin/index.js +1 -0
- package/src/admin/routes.js +64 -0
- package/src/api/client.js +8 -0
- package/src/api/contracts.js +24 -0
- package/src/api/index.js +7 -0
- package/src/api/jobs.js +37 -0
- package/src/api/testing.js +13 -0
- package/src/app.js +5 -0
- package/src/auth/constants.js +9 -0
- package/src/auth/encoding.js +6 -0
- package/src/auth/groups.js +5 -0
- package/src/auth/impersonation.js +15 -0
- package/src/auth/index.js +7 -0
- package/src/auth/scopes.js +32 -0
- package/src/auth/subscriptions.js +25 -0
- package/src/auth/tenants.js +34 -0
- package/src/auth/users.js +31 -0
- package/src/{core.js → core/circuits.js} +51 -98
- package/src/core/constants.js +5 -0
- package/src/core/d1.js +54 -0
- package/src/core/event-hub.js +29 -0
- package/src/core/events.js +63 -0
- package/src/core/identity.js +39 -0
- package/src/core/index.js +7 -0
- package/src/core/jobs.js +185 -0
- package/src/core/security.js +71 -0
- package/src/data/context.js +16 -0
- package/src/data/index.js +3 -0
- package/src/data/reader.js +63 -0
- package/src/data/resources.js +42 -0
- package/src/index.js +10 -289
- package/src/repository.js +63 -0
- package/src/runtime/features.js +17 -0
- package/src/runtime/health.js +17 -0
- package/src/runtime/worker.js +45 -0
- package/src/ui/index.js +1 -87
- package/src/ui/react/admin-access.jsx +29 -0
- package/src/ui/react/admin-catalogs.jsx +39 -0
- package/src/ui/react/admin-shell.jsx +17 -0
- package/src/ui/react/foundation.jsx +40 -0
- package/src/ui/react/index.jsx +6 -0
- package/src/ui/react/jobs.jsx +63 -0
- package/src/ui/react/live-events.jsx +55 -0
- package/src/ui/styles.css +34 -0
- package/src/authorization.js +0 -188
- package/src/data.js +0 -239
- package/src/ui/groups.js +0 -2
package/src/index.js
CHANGED
|
@@ -1,291 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Public package surface. Runtime composition and platform services live in
|
|
3
|
+
* focused modules under ./runtime, ./core, ./auth, ./api, ./admin, and ./ui.
|
|
4
4
|
*/
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
export
|
|
9
|
-
export * from "./
|
|
10
|
-
export * from "./
|
|
11
|
-
|
|
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
|
-
|
|
34
|
-
export function createWorker({ fetch, scheduled, auth, authorize, scopes = [], subscriptionManifest = [], scopeRoutes = [], middleware = [], features = [], dataResources = [], publicTenantId = null, health, boot, metrics, security = true, adminPage, siteAdminPage }) {
|
|
35
|
-
if (typeof fetch !== "function") throw new TypeError("createWorker requires a fetch handler");
|
|
36
|
-
const provider = auth || features.find((feature) => typeof feature?.getUser === "function");
|
|
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
|
-
}] : []);
|
|
45
|
-
const chain = [
|
|
46
|
-
(request, env, ctx, next, state) => adminBoundary(request, env, ctx, next, state, { provider, authorize, scopes, scopeRoutes, features, adminPage, siteAdminPage }),
|
|
47
|
-
...features.flatMap((feature) => feature?.middleware ? [feature.middleware.bind(feature)] : []),
|
|
48
|
-
...featureRoutes,
|
|
49
|
-
...middleware,
|
|
50
|
-
...(auth ? [(request, env, ctx, next) => auth(request, env, ctx, next)] : []),
|
|
51
|
-
].filter(Boolean);
|
|
52
|
-
return {
|
|
53
|
-
async fetch(request, env, ctx) {
|
|
54
|
-
try {
|
|
55
|
-
if (boot) await boot(env, { request, ctx });
|
|
56
|
-
if (subscriptionManifest.length) await ensureSubscriptionManifest(env, subscriptionManifest, { who: "system:update" });
|
|
57
|
-
const url = new URL(request.url);
|
|
58
|
-
const state = Object.create(null);
|
|
59
|
-
if (provider?.getUser) state.user = await provider.getUser(request, env).catch(() => null);
|
|
60
|
-
state.data = createDataReader(env, { resources: registeredDataResources, context: () => requestDataContext(env, { state, request, publicTenantId }) });
|
|
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)));
|
|
62
|
-
const dispatch = async (index, currentRequest = request) => {
|
|
63
|
-
const layer = chain[index];
|
|
64
|
-
if (!layer) {
|
|
65
|
-
if (url.pathname === "/health" || url.pathname === "/api/health") {
|
|
66
|
-
const details = health ? await health(env, { request: currentRequest, ctx, state }) : {};
|
|
67
|
-
const featureHealth = env?.DB ? await listFeatureHealth(env, { who: "system:read" }).catch(() => []) : [];
|
|
68
|
-
return healthResponse(env, featureHealth.length ? { ...details, features: featureHealth } : details);
|
|
69
|
-
}
|
|
70
|
-
if (url.pathname === "/api/tenant" && currentRequest.method === "GET") {
|
|
71
|
-
const context = await state.data.context();
|
|
72
|
-
if (!context.userId) return Response.json({ error: "Authentication is required." }, { status: 401 });
|
|
73
|
-
if (context.invalidTenant) return Response.json({ error: "The requested tenant is not available." }, { status: 400 });
|
|
74
|
-
return Response.json({ tenant: context.tenantId ? { id: context.tenantId, name: context.tenants?.find((tenant) => tenant.id === context.tenantId)?.name || null } : null, tenants: context.tenants || [] });
|
|
75
|
-
}
|
|
76
|
-
return fetch(currentRequest, env, ctx, state);
|
|
77
|
-
}
|
|
78
|
-
if (typeof layer !== "function") throw new TypeError("Worker middleware must be a function");
|
|
79
|
-
return layer(currentRequest, env, ctx, (nextRequest = currentRequest) => dispatch(index + 1, nextRequest), state);
|
|
80
|
-
};
|
|
81
|
-
const response = await dispatch(0);
|
|
82
|
-
if (metrics) metrics.request(request, response, env, ctx);
|
|
83
|
-
return security ? secureResponse(response) : response;
|
|
84
|
-
} catch (error) {
|
|
85
|
-
console.error("[worker] request failed", error);
|
|
86
|
-
if (error instanceof DataScopeError || error instanceof SubscriptionError) return secureResponse(Response.json({ error: error.message }, { status: 403, headers: { "Cache-Control": "no-store" } }));
|
|
87
|
-
return secureResponse(Response.json({ error: "Internal server error" }, { status: 500, headers: { "Cache-Control": "no-store" } }));
|
|
88
|
-
}
|
|
89
|
-
},
|
|
90
|
-
...(scheduled ? { scheduled } : {}),
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
async function adminBoundary(request, env, ctx, next, state, { provider, authorize, scopes, scopeRoutes, features, adminPage, siteAdminPage }) {
|
|
96
|
-
const url = new URL(request.url);
|
|
97
|
-
if (!isAdminPath(url.pathname)) return next(request);
|
|
98
|
-
const strategy = String(env?.AUTH_STRATEGY || "http_basic").trim().toLowerCase();
|
|
99
|
-
if (strategy === "http_basic") {
|
|
100
|
-
const user = basicUser(request, env);
|
|
101
|
-
if (!user) return adminUnauthorized(request);
|
|
102
|
-
state.user = user;
|
|
103
|
-
} else if (strategy === "oauth") {
|
|
104
|
-
const user = provider?.getUser ? await provider.getUser(request, env) : null;
|
|
105
|
-
if (!user) return oauthUnauthorized(request, url);
|
|
106
|
-
state.user = user;
|
|
107
|
-
} else {
|
|
108
|
-
return new Response("Unsupported AUTH_STRATEGY", { status: 500, headers: { "Cache-Control": "no-store" } });
|
|
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
|
-
}
|
|
114
|
-
await ensureScopes(env, scopes, { who: state.user?.auth_strategy === "http_basic" ? "user:admin" : `user:${state.user?.sub || "unknown"}` });
|
|
115
|
-
state.authUser = await ensureUser(env, state.user, { who: state.user?.auth_strategy === "http_basic" ? "user:admin" : `user:${state.user?.sub || "unknown"}` });
|
|
116
|
-
state.requestedBy = requestActor(state);
|
|
117
|
-
const requiredScope = requiredScopeFor(url.pathname, scopeRoutes);
|
|
118
|
-
const scopeAllowed = !requiredScope || await hasScope(env, state.user, requiredScope, { who: requestActor(state) });
|
|
119
|
-
if (!scopeAllowed || (authorize && state.user.auth_strategy !== "http_basic" && !(await authorize({ request, url, user: state.user, env, ctx, state })))) {
|
|
120
|
-
return url.pathname.startsWith("/api/") ? Response.json({ error: "Administrator access is required." }, { status: 403, headers: { "Cache-Control": "no-store" } }) : new Response("Administrator access is required.", { status: 403, headers: { "Cache-Control": "no-store" } });
|
|
121
|
-
}
|
|
122
|
-
const platformResponse = await authorizationApi(request, env, url, state, features);
|
|
123
|
-
if (platformResponse) return platformResponse;
|
|
124
|
-
if (request.method === "GET" && isSiteAdminPage(url.pathname) && typeof siteAdminPage === "function") {
|
|
125
|
-
const response = await siteAdminPage({ request, env, url, state, features });
|
|
126
|
-
if (response) return response;
|
|
127
|
-
}
|
|
128
|
-
if (request.method === "GET" && isPlatformAdminPage(url.pathname) && typeof adminPage === "function") {
|
|
129
|
-
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" } });
|
|
130
|
-
const response = await adminPage({ request, env, url, state, features });
|
|
131
|
-
if (response) return response;
|
|
132
|
-
}
|
|
133
|
-
if (url.pathname === "/admin/features" && request.method === "GET") {
|
|
134
|
-
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" } });
|
|
135
|
-
return featureCatalogPage(env, features, state);
|
|
136
|
-
}
|
|
137
|
-
return next(request);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function isPlatformAdminPage(pathname) {
|
|
141
|
-
return ["/admin/users", "/admin/scopes", "/admin/groups", "/admin/features", "/admin/healthchecks", "/admin/circuit-breakers"].includes(pathname);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function isSiteAdminPage(pathname) {
|
|
145
|
-
return pathname === "/admin/site" || pathname.startsWith("/admin/site/");
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function requiredScopeFor(pathname, routes) {
|
|
149
|
-
const route = routes.find((entry) => typeof entry.match === "function" ? entry.match(pathname) : pathname === entry.path || pathname.startsWith(String(entry.path || "") + "/"));
|
|
150
|
-
return route && route.scope ? route.scope : null;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
async function authorizationApi(request, env, url, state, features = []) {
|
|
154
|
-
const grantsMatch = url.pathname.match(/\/api\/admin\/users\/([^/]+)\/scopes$/);
|
|
155
|
-
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);
|
|
156
|
-
if (!platformPath) return null;
|
|
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" } });
|
|
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" } });
|
|
159
|
-
if (url.pathname === "/api/admin/users" && request.method === "GET") return Response.json({ users: await listAuthorizationUsers(env, { who: requestActor(state) }) });
|
|
160
|
-
const impersonateMatch = url.pathname.match(/^\/api\/admin\/users\/([^/]+)\/impersonate$/);
|
|
161
|
-
if (impersonateMatch && request.method === "POST") {
|
|
162
|
-
const target = decodeURIComponent(impersonateMatch[1]);
|
|
163
|
-
const targetUser = await getAuthorizationUser(env, target, { who: requestActor(state) });
|
|
164
|
-
if (!targetUser) return Response.json({ error: "User not found." }, { status: 404 });
|
|
165
|
-
const token = await createImpersonationToken(env, state.authUser?.id || state.user?.sub || "admin", targetUser.id);
|
|
166
|
-
return new Response(JSON.stringify({ ok: true, user: { id: targetUser.id, email: targetUser.email, display_name: targetUser.display_name }, expires_in: 900 }), { headers: { "content-type": "application/json; charset=utf-8", "Set-Cookie": `__Host-cfgenai_impersonation=${token}; Max-Age=900; Path=/; Secure; HttpOnly; SameSite=Lax` } });
|
|
167
|
-
}
|
|
168
|
-
if (url.pathname === "/api/admin/scopes" && request.method === "GET") return Response.json({ scopes: await listAuthorizationScopes(env, { who: requestActor(state) }) });
|
|
169
|
-
if (url.pathname === "/api/admin/status" && request.method === "GET") return Response.json({ features: await listFeatureHealth(env, { who: requestActor(state) }) });
|
|
170
|
-
if (url.pathname === "/api/admin/features" && request.method === "GET") return Response.json({ features: await listFeatureCatalog(env, features, { who: requestActor(state) }) });
|
|
171
|
-
if (url.pathname === "/api/admin/groups" && request.method === "GET") return Response.json({ groups: await listGroups(env, { who: requestActor(state) }) });
|
|
172
|
-
if (url.pathname === "/api/admin/healthchecks" && request.method === "GET") return Response.json({ healthchecks: await listHealthchecks(env, { who: requestActor(state) }) });
|
|
173
|
-
if (url.pathname === "/api/admin/circuit-breakers" && request.method === "GET") return Response.json({ circuit_breakers: await listCircuitBreakers(env, { who: requestActor(state) }) });
|
|
174
|
-
const healthcheckMatch = url.pathname.match(/\/api\/admin\/healthchecks\/([^/]+)$/);
|
|
175
|
-
if (healthcheckMatch && request.method === "PUT") { const body = await request.json().catch(() => null); if (!body?.state) return Response.json({ error: "state is required" }, { status: 400 }); const healthcheck = await updateHealthcheck(env, decodeURIComponent(healthcheckMatch[1]), body.state, { who: requestActor(state) }); return healthcheck ? Response.json({ healthcheck }) : Response.json({ error: "Healthcheck not found" }, { status: 404 }); }
|
|
176
|
-
const breakerMatch = url.pathname.match(/\/api\/admin\/circuit-breakers\/([^/]+)$/);
|
|
177
|
-
if (breakerMatch && request.method === "GET") return Response.json({ circuit_breaker: await getCircuitBreaker(env, decodeURIComponent(breakerMatch[1]), { who: requestActor(state) }) });
|
|
178
|
-
if (breakerMatch && request.method === "PUT") { const body = await request.json().catch(() => null); if (!body?.state) return Response.json({ error: "state is required" }, { status: 400 }); const breaker = await setCircuitBreaker(env, decodeURIComponent(breakerMatch[1]), body.state, { who: requestActor(state) }); return breaker ? Response.json({ circuit_breaker: breaker }) : Response.json({ error: "Circuit breaker not found" }, { status: 404 }); }
|
|
179
|
-
const groupsMatch = url.pathname.match(/\/api\/admin\/users\/([^/]+)\/groups$/);
|
|
180
|
-
if (groupsMatch && request.method === "GET") return Response.json({ groups: await listUserGroups(env, decodeURIComponent(groupsMatch[1]), { who: requestActor(state) }) });
|
|
181
|
-
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) }) }); }
|
|
182
|
-
if (grantsMatch && request.method === "GET") return Response.json({ grants: await listUserGrants(env, decodeURIComponent(grantsMatch[1]), { who: requestActor(state) }) });
|
|
183
|
-
if (grantsMatch && request.method === "PUT") {
|
|
184
|
-
const body = await request.json().catch(() => null);
|
|
185
|
-
if (!body || !Array.isArray(body.scopes)) return Response.json({ error: "scopes must be an array" }, { status: 400 });
|
|
186
|
-
const grants = await replaceUserGrants(env, decodeURIComponent(grantsMatch[1]), body.scopes, state.authUser && state.authUser.id, { who: requestActor(state) });
|
|
187
|
-
return Response.json({ grants });
|
|
188
|
-
}
|
|
189
|
-
return null;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function isAdminPath(pathname) {
|
|
193
|
-
return pathname === "/admin" || pathname.startsWith("/admin/") || pathname === "/api/admin" || pathname.startsWith("/api/admin/");
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function basicUser(request, env) {
|
|
197
|
-
const token = String(env?.ADMIN_TOKEN || env?.admin_token || "");
|
|
198
|
-
if (!token) return null;
|
|
199
|
-
const header = request.headers.get("Authorization") || "";
|
|
200
|
-
if (!header.toLowerCase().startsWith("basic ")) return null;
|
|
201
|
-
let decoded;
|
|
202
|
-
try { decoded = atob(header.slice(6).trim()); } catch { return null; }
|
|
203
|
-
const separator = decoded.indexOf(":");
|
|
204
|
-
if (separator < 0) return null;
|
|
205
|
-
if (!constantTimeEqual(decoded.slice(0, separator), "admin") || !constantTimeEqual(decoded.slice(separator + 1), token)) return null;
|
|
206
|
-
return { sub: "basic:admin", email: "", name: "admin", roles: ["admin"], auth_strategy: "http_basic" };
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function adminUnauthorized(request) {
|
|
210
|
-
const headers = { "Cache-Control": "no-store", "WWW-Authenticate": "Basic realm=\"admin\", charset=\"UTF-8\"" };
|
|
211
|
-
return new URL(request.url).pathname.startsWith("/api/") ? Response.json({ error: "Authentication is required." }, { status: 401, headers }) : new Response("Authentication is required.", { status: 401, headers });
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function oauthUnauthorized(request, url) {
|
|
215
|
-
if (url.pathname.startsWith("/api/")) return Response.json({ error: "Authentication is required." }, { status: 401, headers: { "Cache-Control": "no-store" } });
|
|
216
|
-
return Response.redirect(url.origin + "/auth/login?return_to=" + encodeURIComponent(safeReturnTo(url.pathname + url.search)), 302);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function safeReturnTo(value) { return value?.startsWith("/") && !value.startsWith("//") && !value.startsWith("/auth/") ? value : "/"; }
|
|
220
|
-
function constantTimeEqual(a, b) { const aa = new TextEncoder().encode(a), bb = new TextEncoder().encode(b); let n = aa.length ^ bb.length; for (let i = 0; i < Math.max(aa.length, bb.length); i++) n |= (aa[i] || 0) ^ (bb[i] || 0); return n === 0; }
|
|
221
|
-
|
|
222
|
-
export function validateBoot(env, { bindings = [], required = [] } = {}) {
|
|
223
|
-
const missingBindings = bindings.filter((name) => !env?.[name]);
|
|
224
|
-
const missingValues = required.filter((name) => !env?.[name] || String(env[name]).startsWith("replace-with-"));
|
|
225
|
-
return { ok: missingBindings.length === 0 && missingValues.length === 0, missingBindings, missingValues };
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
export function assertBoot(env, spec = {}) {
|
|
229
|
-
const result = validateBoot(env, spec);
|
|
230
|
-
if (!result.ok) throw new Error(`Worker boot validation failed: ${[...result.missingBindings, ...result.missingValues].join(", ")}`);
|
|
231
|
-
return result;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
export function secureResponse(response) {
|
|
235
|
-
const headers = new Headers(response.headers);
|
|
236
|
-
headers.set("X-Content-Type-Options", "nosniff");
|
|
237
|
-
headers.set("X-Frame-Options", "DENY");
|
|
238
|
-
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
239
|
-
headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()");
|
|
240
|
-
headers.set("Cross-Origin-Opener-Policy", "same-origin");
|
|
241
|
-
headers.set("Strict-Transport-Security", "max-age=31536000");
|
|
242
|
-
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
export function methodNotAllowed(allow = "GET") {
|
|
246
|
-
return new Response("Method Not Allowed", { status: 405, headers: { Allow: allow } });
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
export function healthResponse(env, details = {}) {
|
|
250
|
-
return Response.json({ ok: true, version: String(env.BUILD_SHA || "unknown").slice(0, 7), build_number: env.BUILD_NUMBER ? String(env.BUILD_NUMBER) : null, ...details }, { headers: { "Cache-Control": "no-store" } });
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
export function createMetrics({ tokenEnv = "POSTHOG_TOKEN", host = "https://us.i.posthog.com" } = {}) {
|
|
254
|
-
return {
|
|
255
|
-
request(request, response, env, ctx) {
|
|
256
|
-
if (!env?.[tokenEnv] || !ctx?.waitUntil || new URL(request.url).pathname === "/health") return;
|
|
257
|
-
const event = response.status >= 500 ? "server_error" : "request";
|
|
258
|
-
ctx.waitUntil(track(env, event, { path: new URL(request.url).pathname, method: request.method, status: response.status }, { tokenEnv, host }));
|
|
259
|
-
},
|
|
260
|
-
track: (env, event, properties, ctx) => ctx?.waitUntil?.(track(env, event, properties, { tokenEnv, host })),
|
|
261
|
-
};
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
async function track(env, event, properties, { tokenEnv, host }) {
|
|
265
|
-
try {
|
|
266
|
-
const token = String(env?.[tokenEnv] || "");
|
|
267
|
-
if (!token) return;
|
|
268
|
-
await fetch(`${host.replace(/\/+$/, "")}/capture/`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ api_key: token, event, properties: { ...properties, distinct_id: properties?.distinct_id || "anonymous" } }) });
|
|
269
|
-
} catch (error) {
|
|
270
|
-
console.error("[metrics] delivery failed", error);
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
async function featureCatalogPage(env, features, state) {
|
|
276
|
-
const catalog = await listFeatureCatalog(env, features, { who: requestActor(state) });
|
|
277
|
-
return new Response(featureCatalogMarkup(catalog), { headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" } });
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
function featureCatalogMarkup(catalog) {
|
|
281
|
-
const rows = catalog.map((item) => {
|
|
282
|
-
const checks = item.healthchecks.length ? "<ul>" + item.healthchecks.map((check) => "<li><strong>" + escapeHtml(check.display_name) + "</strong>: " + escapeHtml(check.state) + "</li>").join("") + "</ul>" : "<span>None registered</span>";
|
|
283
|
-
const breakers = item.circuit_breakers.length ? "<ul>" + item.circuit_breakers.map((breaker) => "<li><strong>" + escapeHtml(breaker.display_name) + "</strong>: " + escapeHtml(breaker.state) + "</li>").join("") + "</ul>" : "<span>None registered</span>";
|
|
284
|
-
const packageLabel = item.package_name ? escapeHtml(item.package_name) : "Unknown package";
|
|
285
|
-
const versionLabel = item.version ? escapeHtml(item.version) : "Unknown version";
|
|
286
|
-
return "<tr><td><strong>" + escapeHtml(item.display_name) + "</strong><br><code>" + escapeHtml(item.feature) + "</code></td><td>" + packageLabel + "<br>" + versionLabel + "</td><td><span class=\"state state-" + escapeHtml(item.health) + "\">" + escapeHtml(item.health) + "</span></td><td>" + (item.circuit_breaker ? escapeHtml(item.circuit_breaker.state) : "None") + "</td><td>" + checks + "</td><td>" + breakers + "</td></tr>";
|
|
287
|
-
}).join("");
|
|
288
|
-
return "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Features</title><style>body{font:15px/1.45 system-ui,sans-serif;color:#20231f;background:#f7f7f5;margin:0;padding:2rem}main{max-width:1200px;margin:auto;background:#fff;padding:1.5rem;border:1px solid #d8ddd5;border-radius:.6rem}nav{display:flex;gap:1rem;margin-bottom:1.5rem}a{color:#2f6f52}table{width:100%;border-collapse:collapse}th,td{padding:.7rem;border-bottom:1px solid #d8ddd5;text-align:left;vertical-align:top}th{font-size:.8rem;color:#687067;text-transform:uppercase}ul{margin:.25rem 0;padding-left:1.2rem}code{color:#687067}.state{font-weight:700}.state-green{color:#26734d}.state-yellow{color:#9a6b00}.state-red{color:#b3261e}</style></head><body><main><nav><a href=\"/admin\">Admin</a><a href=\"/admin/features\" aria-current=\"page\">Features</a><a href=\"/admin/users\">Users</a><a href=\"/admin/groups\">Groups</a></nav><h1>Installed features</h1><p>Runtime modules, package versions, healthchecks, and circuit breakers.</p><table><thead><tr><th>Feature</th><th>Package/version</th><th>Health</th><th>Roll-up breaker</th><th>Healthchecks</th><th>Circuit breakers</th></tr></thead><tbody>" + rows + "</tbody></table></main></body></html>";
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
function escapeHtml(value) { return String(value ?? "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll(String.fromCharCode(39), "'"); }
|
|
5
|
+
export { createWorker } from "./runtime/worker.js";
|
|
6
|
+
export { ensureFeatureManifests } from "./runtime/features.js";
|
|
7
|
+
export { assertBoot, validateBoot, methodNotAllowed, healthResponse } from "./runtime/health.js";
|
|
8
|
+
export { defineApp } from "./app.js";
|
|
9
|
+
export * from "./core/index.js";
|
|
10
|
+
export * from "./data/index.js";
|
|
11
|
+
export * from "./auth/index.js";
|
|
12
|
+
export { secureResponse } from "./core/security.js";
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { auditLog } from "./core/events.js";
|
|
2
|
+
|
|
3
|
+
export class RepositoryError extends Error {
|
|
4
|
+
constructor(message, status = 500) { super(message); this.name = "RepositoryError"; this.status = status; }
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function defineRepository({ name, resource, scope = "tenant", relations = {}, hooks = {}, resourceDefinition = null } = {}) {
|
|
8
|
+
if (!/^[a-z][a-z0-9_-]*$/.test(String(name || ""))) throw new TypeError("Repositories require a safe name");
|
|
9
|
+
if (!resource) throw new TypeError(`Repository ${name} requires a data resource`);
|
|
10
|
+
return { name: String(name), resource: String(resource), scope, relations: { ...relations }, hooks: { ...hooks }, resourceDefinition };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createRepositories(env, definitions = [], { context } = {}) {
|
|
14
|
+
const specs = new Map(definitions.map((definition) => {
|
|
15
|
+
const spec = defineRepository(definition);
|
|
16
|
+
return [spec.name, spec];
|
|
17
|
+
}));
|
|
18
|
+
const repositories = new Map();
|
|
19
|
+
const get = (name) => {
|
|
20
|
+
const repository = repositories.get(String(name));
|
|
21
|
+
if (!repository) throw new RepositoryError(`Unknown repository: ${name}`, 500);
|
|
22
|
+
return repository;
|
|
23
|
+
};
|
|
24
|
+
for (const spec of specs.values()) repositories.set(spec.name, repository(spec));
|
|
25
|
+
return Object.fromEntries([...repositories].map(([name, value]) => [name, value]));
|
|
26
|
+
|
|
27
|
+
function repository(spec) {
|
|
28
|
+
const reader = () => {
|
|
29
|
+
const data = env?.data;
|
|
30
|
+
if (!data?.[spec.scope]) throw new RepositoryError(`Data scope ${spec.scope} is unavailable`, 500);
|
|
31
|
+
return data[spec.scope];
|
|
32
|
+
};
|
|
33
|
+
const invoke = async (operation, value) => {
|
|
34
|
+
const hook = spec.hooks[operation];
|
|
35
|
+
return typeof hook === "function" ? hook(value, { env, context, repository: api }) : value;
|
|
36
|
+
};
|
|
37
|
+
const api = {
|
|
38
|
+
name: spec.name,
|
|
39
|
+
resource: spec.resource,
|
|
40
|
+
async list(options) { return invoke("list", await reader().list(spec.resource, options)); },
|
|
41
|
+
async page(options) { return invoke("page", await reader().page(spec.resource, options)); },
|
|
42
|
+
async count(options) { return reader().count(spec.resource, options); },
|
|
43
|
+
async find(id) { return invoke("find", await reader().get(spec.resource, id)); },
|
|
44
|
+
async findOne(where, options = {}) { return invoke("findOne", (await reader().list(spec.resource, { ...options, where, limit: 1 }))[0] || null); },
|
|
45
|
+
async insert(values) { const result = await reader().insert(spec.resource, await invoke("beforeInsert", values)); auditLog({ who: "repository", operation: "create", resource: spec.name }); return invoke("afterInsert", result); },
|
|
46
|
+
async update(id, changes) { const result = await reader().update(spec.resource, id, await invoke("beforeUpdate", changes)); auditLog({ who: "repository", operation: "update", resource: spec.name }); return invoke("afterUpdate", result); },
|
|
47
|
+
async updateWhere(where, changes) { return reader().updateWhere(spec.resource, where, await invoke("beforeUpdate", changes)); },
|
|
48
|
+
async delete(id) { const result = await reader().delete(spec.resource, id); auditLog({ who: "repository", operation: "delete", resource: spec.name }); return result; },
|
|
49
|
+
async deleteWhere(where) { return reader().deleteWhere(spec.resource, where); },
|
|
50
|
+
async link(relation, record) {
|
|
51
|
+
const relationSpec = spec.relations[relation];
|
|
52
|
+
if (!relationSpec) throw new RepositoryError(`Unknown relation ${spec.name}.${relation}`, 500);
|
|
53
|
+
const target = get(relationSpec.repository);
|
|
54
|
+
const localKey = relationSpec.localKey || "id";
|
|
55
|
+
const foreignKey = relationSpec.foreignKey || `${spec.name}_id`;
|
|
56
|
+
const value = record?.[localKey];
|
|
57
|
+
if (value === undefined || value === null) return relationSpec.many === false ? null : [];
|
|
58
|
+
return relationSpec.many === false ? target.findOne({ [foreignKey]: value }) : target.list({ where: { [foreignKey]: value, ...(relationSpec.where || {}) }, ...(relationSpec.options || {}) });
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
return api;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { evaluateCircuitBreaker, listCircuitBreakers, registerFeatureManifests } from "../core/circuits.js";
|
|
2
|
+
|
|
3
|
+
const featureRegistrationPromises = new WeakMap();
|
|
4
|
+
|
|
5
|
+
export function ensureFeatureManifests(env, features = [], { who = "system:update" } = {}) {
|
|
6
|
+
if (!env || typeof env !== "object" || !env.DB) return Promise.resolve();
|
|
7
|
+
const key = features.map((feature) => String(feature?.name || feature?.id || "feature")).join("|");
|
|
8
|
+
let registrations = featureRegistrationPromises.get(env.DB);
|
|
9
|
+
if (!registrations) { registrations = new Map(); featureRegistrationPromises.set(env.DB, registrations); }
|
|
10
|
+
let promise = registrations.get(key);
|
|
11
|
+
if (!promise) {
|
|
12
|
+
promise = (async () => { await registerFeatureManifests(env, features, { who }); const breakers = await listCircuitBreakers(env, { who }); await Promise.all(breakers.filter(Boolean).map((breaker) => evaluateCircuitBreaker(env, breaker.id, { who }))); })();
|
|
13
|
+
registrations.set(key, promise);
|
|
14
|
+
promise.catch(() => registrations.delete(key));
|
|
15
|
+
}
|
|
16
|
+
return promise;
|
|
17
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function validateBoot(env, { bindings = [], required = [] } = {}) {
|
|
2
|
+
const missingBindings = bindings.filter((name) => !env?.[name]);
|
|
3
|
+
const missingValues = required.filter((name) => !env?.[name] || String(env[name]).startsWith("replace-with-"));
|
|
4
|
+
return { ok: missingBindings.length === 0 && missingValues.length === 0, missingBindings, missingValues };
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function assertBoot(env, spec = {}) {
|
|
8
|
+
const result = validateBoot(env, spec);
|
|
9
|
+
if (!result.ok) throw new Error(`Worker boot validation failed: ${[...result.missingBindings, ...result.missingValues].join(", ")}`);
|
|
10
|
+
return result;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function methodNotAllowed(allow = "GET") { return new Response("Method Not Allowed", { status: 405, headers: { Allow: allow } }); }
|
|
14
|
+
|
|
15
|
+
export function healthResponse(env, details = {}) {
|
|
16
|
+
return Response.json({ ok: true, version: String(env.BUILD_SHA || "unknown").slice(0, 7), build_number: env.BUILD_NUMBER ? String(env.BUILD_NUMBER) : null, ...details }, { headers: { "Cache-Control": "no-store" } });
|
|
17
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { ensureSubscriptionManifest, SubscriptionError } from "../auth/index.js";
|
|
2
|
+
import { auditedD1, createEventHandler, listFeatureHealth, requestActor, requestContext, secureResponse } from "../core/index.js";
|
|
3
|
+
import { jobResponse, liveEventsResponse } from "../api/jobs.js";
|
|
4
|
+
import { createRepositories } from "../repository.js";
|
|
5
|
+
import { dispatchRoutes } from "../api/contracts.js";
|
|
6
|
+
import { defineApp } from "../app.js";
|
|
7
|
+
import { createDataReader } from "../data/reader.js";
|
|
8
|
+
import { DataScopeError, normalizeDataResources } from "../data/resources.js";
|
|
9
|
+
import { requestDataContext } from "../data/context.js";
|
|
10
|
+
import { adminBoundary } from "../admin/routes.js";
|
|
11
|
+
import { ensureFeatureManifests } from "./features.js";
|
|
12
|
+
import { healthResponse } from "./health.js";
|
|
13
|
+
|
|
14
|
+
export function createWorker({ fetch, scheduled, app = { name: "worker" }, auth, authorize, scopes = [], subscriptionManifest = [], scopeRoutes = [], apiRoutes = [], middleware = [], features = [], dataResources = [], repositories = [], publicTenantId = null, health, boot, security = true, eventHubBinding = "EVENT_HUB" }) {
|
|
15
|
+
if (typeof fetch !== "function") throw new TypeError("createWorker requires a fetch handler");
|
|
16
|
+
const application = defineApp(app); const provider = auth || features.find((feature) => typeof feature?.getUser === "function"); const eventHandler = createEventHandler(features, { eventHubBinding });
|
|
17
|
+
const repositoryDefinitions = [...repositories, ...features.flatMap((feature) => Array.isArray(feature?.repositories) ? feature.repositories : [])];
|
|
18
|
+
const registeredDataResources = normalizeDataResources([...dataResources, ...features.flatMap((feature) => Array.isArray(feature?.dataResources) ? feature.dataResources : []), ...repositoryDefinitions.filter((definition) => definition.resourceDefinition).map((definition) => definition.resourceDefinition)]);
|
|
19
|
+
const featureRoutes = features.flatMap((feature) => Array.isArray(feature?.routes) ? [async (request, env, ctx, next, state) => { for (const route of feature.routes) { const matches = typeof route?.match === "function" ? await route.match(request, env, state) : route?.path === new URL(request.url).pathname; if (matches && typeof route.handle === "function") return route.handle({ request, env, ctx, state, next }); } return next(); }] : []);
|
|
20
|
+
const chain = [(request, env, ctx, next, state) => adminBoundary(request, env, ctx, next, state, { provider, authorize, scopes, scopeRoutes, features }), ...(apiRoutes.length ? [(request, env, ctx, next, state) => dispatchRoutes(request, env, ctx, state, apiRoutes).then((response) => response || next(request))] : []), ...features.flatMap((feature) => feature?.middleware ? [feature.middleware.bind(feature)] : []), ...featureRoutes, ...middleware, ...(auth ? [(request, env, ctx, next) => auth(request, env, ctx, next)] : [])].filter(Boolean);
|
|
21
|
+
return {
|
|
22
|
+
async fetch(request, env, ctx) {
|
|
23
|
+
try {
|
|
24
|
+
if (boot) await boot(env, { request, ctx });
|
|
25
|
+
if (subscriptionManifest.length) await ensureSubscriptionManifest(env, subscriptionManifest, { who: "system:update" });
|
|
26
|
+
const url = new URL(request.url); const state = Object.create(null); state.requestId = crypto.randomUUID();
|
|
27
|
+
if (provider?.getUser) state.user = await provider.getUser(request, env).catch(() => null); if (state.user?.authUser) state.authUser = state.user.authUser;
|
|
28
|
+
state.data = createDataReader(env, { resources: registeredDataResources, context: () => requestDataContext(env, { state, request, publicTenantId }) });
|
|
29
|
+
const requestEnv = Object.create(env || null);
|
|
30
|
+
Object.assign(requestEnv, { app: application, DB: env?.DB ? auditedD1(env.DB, requestActor(state)) : env?.DB, data: state.data, repositories: createRepositories(requestEnv, repositoryDefinitions), user: state.user || null, authUser: state.authUser || null, userId: state.authUser?.id || null, eventHandler: (event, eventEnv, eventCtx) => eventHandler(event, eventEnv || requestEnv, eventCtx || ctx) });
|
|
31
|
+
requestEnv.requestId = state.requestId; state.context = requestContext({ request, env: requestEnv, ctx, state, data: state.data }); requestEnv.context = state.context; requestEnv.event = state.context.event;
|
|
32
|
+
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)));
|
|
33
|
+
const dispatch = async (index, currentRequest = request) => { const layer = chain[index]; if (!layer) { if (url.pathname === "/health" || url.pathname === "/api/health") { const details = health ? await health(requestEnv, { request: currentRequest, ctx, state }) : {}; const featureHealth = requestEnv?.DB ? await listFeatureHealth(requestEnv, { who: "system:read" }).catch(() => []) : []; return healthResponse(requestEnv, featureHealth.length ? { ...details, features: featureHealth } : details); } if (url.pathname === "/api/tenant" && currentRequest.method === "GET") { const context = await state.data.context(); if (!context.userId) return Response.json({ error: "Authentication is required." }, { status: 401 }); if (context.invalidTenant) return Response.json({ error: "The requested tenant is not available." }, { status: 400 }); return Response.json({ tenant: context.tenantId ? { id: context.tenantId, name: context.tenants?.find((tenant) => tenant.id === context.tenantId)?.name || null } : null, tenants: context.tenants || [] }); } return fetch(currentRequest, requestEnv, ctx, state); } if (typeof layer !== "function") throw new TypeError("Worker middleware must be a function"); return layer(currentRequest, requestEnv, ctx, (nextRequest = currentRequest) => dispatch(index + 1, nextRequest), state); };
|
|
34
|
+
const liveResponse = await liveEventsResponse(request, requestEnv, state, eventHubBinding);
|
|
35
|
+
if (liveResponse) return liveResponse;
|
|
36
|
+
const response = await jobResponse(request, requestEnv, state) || await dispatch(0); return withRequestId(security ? secureResponse(response) : response, state.requestId);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.error("[worker] request failed", error); const errorResponse = error instanceof DataScopeError || error instanceof SubscriptionError ? Response.json({ error: error.message }, { status: 403, headers: { "Cache-Control": "no-store" } }) : Response.json({ error: "Internal server error" }, { status: 500, headers: { "Cache-Control": "no-store" } }); return withRequestId(security ? secureResponse(errorResponse) : errorResponse, error?.requestId || crypto.randomUUID());
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
...(scheduled ? { scheduled } : {}),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function withRequestId(response, requestId) { const headers = new Headers(response.headers); headers.set("X-Request-ID", String(requestId)); return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); }
|
package/src/ui/index.js
CHANGED
|
@@ -1,87 +1 @@
|
|
|
1
|
-
export * from "./
|
|
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>`; };
|
|
7
|
-
|
|
8
|
-
export class CfAdminShell extends HTMLElement {
|
|
9
|
-
connectedCallback() {
|
|
10
|
-
const active = this.getAttribute("active") || "";
|
|
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>`;
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export class CfScopeBadge extends HTMLElement {
|
|
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; }
|
|
20
|
-
}
|
|
21
|
-
|
|
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."; } }
|
|
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; }
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
if (!customElements.get("cf-admin-shell")) customElements.define("cf-admin-shell", CfAdminShell);
|
|
30
|
-
if (!customElements.get("cf-scope-badge")) customElements.define("cf-scope-badge", CfScopeBadge);
|
|
31
|
-
if (!customElements.get("cf-user-management")) customElements.define("cf-user-management", CfUserManagement);
|
|
32
|
-
|
|
33
|
-
export class CfScopeCatalog extends HTMLElement {
|
|
34
|
-
async connectedCallback() {
|
|
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>`;
|
|
36
|
-
try {
|
|
37
|
-
const response = await fetch("/api/admin/scopes", { credentials: "same-origin" });
|
|
38
|
-
if (!response.ok) throw new Error("Unable to load scopes.");
|
|
39
|
-
const scopes = (await response.json()).scopes || [];
|
|
40
|
-
const list = this.shadowRoot.querySelector(".scope-list");
|
|
41
|
-
list.replaceChildren(...scopes.map((scope) => {
|
|
42
|
-
const item = document.createElement("div");
|
|
43
|
-
const badge = document.createElement("cf-scope-badge");
|
|
44
|
-
badge.setAttribute("scope", scope.name);
|
|
45
|
-
const description = document.createElement("span");
|
|
46
|
-
description.textContent = ` ${scope.label || scope.description || scope.name}`;
|
|
47
|
-
description.title = scope.label || scope.name;
|
|
48
|
-
item.append(badge, description);
|
|
49
|
-
return item;
|
|
50
|
-
}));
|
|
51
|
-
list.hidden = false;
|
|
52
|
-
this.shadowRoot.querySelector(".status").textContent = `${scopes.length} scope${scopes.length === 1 ? "" : "s"}`;
|
|
53
|
-
} catch (error) {
|
|
54
|
-
this.shadowRoot.querySelector(".status").textContent = error.message || "Unable to load scopes.";
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
if (!customElements.get("cf-scope-catalog")) customElements.define("cf-scope-catalog", CfScopeCatalog);
|
|
60
|
-
|
|
61
|
-
export class CfHealthcheckCatalog extends HTMLElement {
|
|
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
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export class CfCircuitBreakerCatalog extends HTMLElement {
|
|
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
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
if (!customElements.get("cf-healthcheck-catalog")) customElements.define("cf-healthcheck-catalog", CfHealthcheckCatalog);
|
|
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);
|
|
1
|
+
export * from "./react/index.jsx";
|