@agilesyndrome/cf-genai-base 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRACT.md +1 -1
- package/README.md +12 -1
- package/migrations/0003_auth_groups.sql +3 -0
- package/package.json +2 -2
- package/src/authorization.js +6 -0
- package/src/core.js +36 -0
- package/src/index.js +37 -8
- package/src/ui/groups.js +2 -0
- package/src/ui/index.js +2 -1
package/CONTRACT.md
CHANGED
|
@@ -16,7 +16,7 @@ is optional and must use `ctx.waitUntil` for background work.
|
|
|
16
16
|
- Admin routes use `AUTH_STRATEGY`; omitted or empty means `http_basic`. Basic auth accepts username `admin` and the value of `ADMIN_TOKEN` (with `admin_token` supported for compatibility). Missing token means all admin routes return 401.
|
|
17
17
|
- `AUTH_STRATEGY=oauth` delegates identity establishment to the configured auth provider and uses `authorize` for admin policy.
|
|
18
18
|
- `scopes` registers an application scope manifest. `scopeRoutes` associates route prefixes or match functions with required scopes.
|
|
19
|
-
- Base provides `/api/admin/users`, `/api/admin/scopes`, and `/api/admin/users/:id/scopes` for platform administrators when the authorization
|
|
19
|
+
- Base provides `/api/admin/users`, `/api/admin/scopes`, `/api/admin/groups`, `/api/admin/status`, `/api/admin/features`, `/api/admin/healthchecks`, `/api/admin/circuit-breakers`, and `/api/admin/users/:id/scopes|groups` for platform administrators when the authorization and core migrations are installed. `GET /api/admin/features` returns the installed runtime feature manifests, package names and versions, per-feature health rollups, healthchecks, and circuit breakers. The browser route `GET /admin/features` renders that catalog. Feature manifests may provide `name`, `displayName`, `packageName`, and `version`. The exported UI includes users, scopes, groups, healthchecks, and circuit-breaker catalogs.
|
|
20
20
|
- Public APIs must be explicitly listed in provider-specific auth configuration.
|
|
21
21
|
- Mutating `/api/*` requests require a same-origin `Origin` header.
|
|
22
22
|
|
package/README.md
CHANGED
|
@@ -39,4 +39,15 @@ site initializer to fail closed when its Cloudflare configuration is incomplete.
|
|
|
39
39
|
|
|
40
40
|
Apply `migrations/0002_core.sql` after the authorization migration. The package exports `registerHealthcheck`, `updateHealthcheck`, `registerCircuitBreaker`, `setCircuitBreaker`, and `evaluateCircuitBreaker` from `/cf-genai-base`. Healthchecks use `red`, `yellow` (unknown/transient), or `green`; breakers use `off`, `tripped`, or `on`, with `any` or `all` healthcheck evaluation. Automated evaluation may only move `on` to `tripped`, or self-healing `tripped` to `on`; admin API writes are the human control plane for the `off` state.
|
|
41
41
|
|
|
42
|
-
Admin APIs are `GET /api/admin/healthchecks`, `PUT /api/admin/healthchecks/:id`, `GET /api/admin/circuit-breakers`, `GET|PUT /api/admin/circuit-breakers/:id`. Feature manifests may expose `healthchecks` and `circuitBreakers
|
|
42
|
+
Admin APIs are `GET /api/admin/healthchecks`, `PUT /api/admin/healthchecks/:id`, `GET /api/admin/circuit-breakers`, `GET|PUT /api/admin/circuit-breakers/:id`, and `GET /api/admin/features`. The browser route `/admin/features` renders the same feature catalog for administrators. The catalog lists each installed runtime feature, its `packageName` and `version`, its most severe healthcheck state, all feature healthchecks, and its circuit breakers (including the feature roll-up breaker). Feature manifests may expose `healthchecks` and `circuitBreakers`; add `displayName`, `packageName`, and `version` to make the installation identity explicit. Use `createD1(env, { who })` for downstream D1 calls; it emits EventLog and AuditLog console records with the requesting actor.
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
## User administration
|
|
46
|
+
|
|
47
|
+
Use the selected D1 target (local by default) to inspect and update users:
|
|
48
|
+
|
|
49
|
+
cf-genai user list --target local
|
|
50
|
+
cf-genai user get someone.com --target staging
|
|
51
|
+
cf-genai user update someone.com --roles admin --target production
|
|
52
|
+
|
|
53
|
+
`user:get` also reports scopes and groups. The user update command resolves an email, subject, or internal id and supports `admin` or `none` roles. Production commands should be run through the repository credentials wrapper and reviewed as an administrative change.
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS auth_groups (name TEXT PRIMARY KEY, display_name TEXT NOT NULL, description TEXT NOT NULL DEFAULT "", created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);
|
|
2
|
+
CREATE TABLE IF NOT EXISTS auth_user_groups (user_id TEXT NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE, group_name TEXT NOT NULL REFERENCES auth_groups(name) ON DELETE CASCADE, granted_by TEXT, granted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, group_name));
|
|
3
|
+
CREATE INDEX IF NOT EXISTS auth_user_groups_group_idx ON auth_user_groups(group_name);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agilesyndrome/cf-genai-base",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./src/index.js",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
},
|
|
29
29
|
"homepage": "https://github.com/agilesyndrome/cf-genai-base#readme",
|
|
30
30
|
"scripts": {
|
|
31
|
-
"check": "node --check src/index.js",
|
|
31
|
+
"check": "node --check src/index.js && node --check src/core.js && node --check src/authorization.js",
|
|
32
32
|
"test": "node --test tests/*.test.mjs",
|
|
33
33
|
"build": "npm run check && npm test && npm pack --dry-run"
|
|
34
34
|
}
|
package/src/authorization.js
CHANGED
|
@@ -56,6 +56,12 @@ export async function listAuthorizationScopes(env, { who = "system:read" } = {})
|
|
|
56
56
|
return results;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
export async function listGroups(env, { who = "system:read" } = {}) { const db = createD1(env, { who }); const result = await db.prepare("SELECT name,display_name,description,created_at,updated_at FROM auth_groups ORDER BY display_name COLLATE NOCASE").all(); return result.results || []; }
|
|
60
|
+
|
|
61
|
+
export async function listUserGroups(env, userId, { who = "system:read" } = {}) { const db = createD1(env, { who }); const result = await db.prepare("SELECT group_name,granted_at FROM auth_user_groups WHERE user_id=? ORDER BY group_name").bind(userId).all(); return result.results || []; }
|
|
62
|
+
|
|
63
|
+
export async function replaceUserGroups(env, userId, groups, grantedBy, { who = "system:read" } = {}) { const db = createD1(env, { who }); await db.batch([db.prepare("DELETE FROM auth_user_groups WHERE user_id=?").bind(userId), ...[...new Set(groups)].map((group) => db.prepare("INSERT INTO auth_user_groups (user_id,group_name,granted_by) VALUES (?,?,?)").bind(userId, group, grantedBy || null))]); return listUserGroups(env, userId, { who }); }
|
|
64
|
+
|
|
59
65
|
export async function listUserGrants(env, userId, { who = "system:read" } = {}) {
|
|
60
66
|
const db = createD1(env, { who });
|
|
61
67
|
const { results } = await db.prepare(`SELECT scope_name,granted_at FROM ${AUTH_GRANT_TABLE} WHERE user_id=? ORDER BY scope_name`).bind(userId).all();
|
package/src/core.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export const HEALTHCHECK_STATES = ["red", "yellow", "green"];
|
|
2
2
|
export const CIRCUIT_BREAKER_STATES = ["off", "tripped", "on"];
|
|
3
3
|
export const HEALTHCHECK_MODES = ["any", "all"];
|
|
4
|
+
export const BASE_PACKAGE_NAME = "@agilesyndrome/cf-genai-base";
|
|
5
|
+
export const BASE_VERSION = "1.0.3";
|
|
4
6
|
|
|
5
7
|
export function eventLog(level, event, details = {}) {
|
|
6
8
|
const method = ["debug", "info", "warn", "error"].includes(level) ? level : "info";
|
|
@@ -143,3 +145,37 @@ export async function listFeatureHealth(env, { who = "system:read" } = {}) {
|
|
|
143
145
|
for (const check of checks) { const name = check.feature; if (!features[name] || severity[check.state] > severity[features[name].state]) features[name] = { feature: name, state: check.state, healthchecks: 0 }; features[name].healthchecks += 1; }
|
|
144
146
|
return Object.values(features).sort((a, b) => a.feature.localeCompare(b.feature));
|
|
145
147
|
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
export function normalizeFeatureManifest(feature = {}) {
|
|
151
|
+
const source = feature && typeof feature === "object" ? feature : {};
|
|
152
|
+
const manifest = source.manifest && typeof source.manifest === "object" ? source.manifest : source;
|
|
153
|
+
const name = String(source.name || source.id || manifest.name || "feature");
|
|
154
|
+
const packageName = source.packageName || source.package_name || source.package || manifest.packageName || manifest.package_name || manifest.package;
|
|
155
|
+
const version = source.version || source.packageVersion || source.package_version || manifest.version;
|
|
156
|
+
return {
|
|
157
|
+
feature: name,
|
|
158
|
+
display_name: String(source.displayName || source.display_name || manifest.displayName || manifest.display_name || name),
|
|
159
|
+
package_name: packageName ? String(packageName) : null,
|
|
160
|
+
version: version ? String(version) : null,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function listFeatureCatalog(env, features = [], { who = "system:read" } = {}) {
|
|
165
|
+
const [healthchecks, circuitBreakers] = await Promise.all([listHealthchecks(env, { who }), listCircuitBreakers(env, { who })]);
|
|
166
|
+
const manifests = new Map([["base", { feature: "base", display_name: "Base platform", package_name: BASE_PACKAGE_NAME, version: BASE_VERSION }]]);
|
|
167
|
+
for (const feature of features) {
|
|
168
|
+
const manifest = normalizeFeatureManifest(feature);
|
|
169
|
+
manifests.set(manifest.feature, manifest);
|
|
170
|
+
}
|
|
171
|
+
for (const item of healthchecks) if (!manifests.has(item.feature)) manifests.set(item.feature, normalizeFeatureManifest({ name: item.feature }));
|
|
172
|
+
for (const item of circuitBreakers) if (!manifests.has(item.feature)) manifests.set(item.feature, normalizeFeatureManifest({ name: item.feature }));
|
|
173
|
+
const severity = { green: 0, yellow: 1, red: 2 };
|
|
174
|
+
const state = (items) => items.reduce((current, item) => severity[item.state] > severity[current] ? item.state : current, "green");
|
|
175
|
+
return [...manifests.values()].sort((a, b) => a.feature.localeCompare(b.feature)).map((manifest) => {
|
|
176
|
+
const featureHealthchecks = healthchecks.filter((item) => item.feature === manifest.feature);
|
|
177
|
+
const featureBreakers = circuitBreakers.filter((item) => item.feature === manifest.feature);
|
|
178
|
+
const rollup = featureBreakers.find((item) => item.name === "rollup") || null;
|
|
179
|
+
return { ...manifest, health: featureHealthchecks.length ? state(featureHealthchecks) : "yellow", healthchecks: featureHealthchecks, circuit_breakers: featureBreakers, circuit_breaker: rollup };
|
|
180
|
+
});
|
|
181
|
+
}
|
package/src/index.js
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
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 { ensureScopes, ensureUser, hasScope, listAuthorizationScopes, listAuthorizationUsers, listUserGrants, replaceUserGrants } from "./authorization.js";
|
|
6
|
-
import { getCircuitBreaker, evaluateCircuitBreaker, listCircuitBreakers, listHealthchecks, listFeatureHealth, registerFeatureManifests, requestActor, setCircuitBreaker, updateHealthcheck } from "./core.js";
|
|
5
|
+
import { ensureScopes, ensureUser, hasScope, listAuthorizationScopes, listAuthorizationUsers, listGroups, listUserGroups, listUserGrants, replaceUserGroups, replaceUserGrants } from "./authorization.js";
|
|
6
|
+
import { getCircuitBreaker, evaluateCircuitBreaker, listCircuitBreakers, listHealthchecks, listFeatureCatalog, listFeatureHealth, registerFeatureManifests, requestActor, setCircuitBreaker, updateHealthcheck } from "./core.js";
|
|
7
7
|
export * from "./core.js";
|
|
8
8
|
export function createWorker({ fetch, scheduled, auth, authorize, scopes = [], scopeRoutes = [], middleware = [], features = [], health, boot, metrics, security = true }) {
|
|
9
9
|
if (typeof fetch !== "function") throw new TypeError("createWorker requires a fetch handler");
|
|
10
10
|
const provider = auth || features.find((feature) => typeof feature?.getUser === "function");
|
|
11
11
|
const chain = [
|
|
12
|
-
(request, env, ctx, next, state) => adminBoundary(request, env, ctx, next, state, { provider, authorize, scopes, scopeRoutes }),
|
|
12
|
+
(request, env, ctx, next, state) => adminBoundary(request, env, ctx, next, state, { provider, authorize, scopes, scopeRoutes, features }),
|
|
13
13
|
...features.flatMap((feature) => feature?.middleware ? [feature.middleware.bind(feature)] : []),
|
|
14
14
|
...middleware,
|
|
15
15
|
...(auth ? [(request, env, ctx, next) => auth(request, env, ctx, next)] : []),
|
|
@@ -47,7 +47,7 @@ export function createWorker({ fetch, scheduled, auth, authorize, scopes = [], s
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
|
|
50
|
-
async function adminBoundary(request, env, ctx, next, state, { provider, authorize, scopes, scopeRoutes }) {
|
|
50
|
+
async function adminBoundary(request, env, ctx, next, state, { provider, authorize, scopes, scopeRoutes, features }) {
|
|
51
51
|
const url = new URL(request.url);
|
|
52
52
|
if (!isAdminPath(url.pathname)) return next(request);
|
|
53
53
|
const strategy = String(env?.AUTH_STRATEGY || "http_basic").trim().toLowerCase();
|
|
@@ -70,8 +70,13 @@ async function adminBoundary(request, env, ctx, next, state, { provider, authori
|
|
|
70
70
|
if (!scopeAllowed || (authorize && state.user.auth_strategy !== "http_basic" && !(await authorize({ request, url, user: state.user, env, ctx, state })))) {
|
|
71
71
|
return url.pathname.startsWith("/api/") ? Response.json({ error: "Administrator access is required." }, { status: 403, headers: { "Cache-Control": "no-store" } }) : new Response("Administrator access is required.", { status: 403, headers: { "Cache-Control": "no-store" } });
|
|
72
72
|
}
|
|
73
|
-
const platformResponse = await authorizationApi(request, env, url, state);
|
|
74
|
-
|
|
73
|
+
const platformResponse = await authorizationApi(request, env, url, state, features);
|
|
74
|
+
if (platformResponse) return platformResponse;
|
|
75
|
+
if (url.pathname === "/admin/features" && request.method === "GET") {
|
|
76
|
+
if (!(state.user.auth_strategy === "http_basic" || (state.authUser && state.authUser.is_admin))) return new Response("Administrator access is required.", { status: 403, headers: { "Cache-Control": "no-store" } });
|
|
77
|
+
return featureCatalogPage(env, features, state);
|
|
78
|
+
}
|
|
79
|
+
return next(request);
|
|
75
80
|
}
|
|
76
81
|
|
|
77
82
|
function requiredScopeFor(pathname, routes) {
|
|
@@ -79,14 +84,16 @@ function requiredScopeFor(pathname, routes) {
|
|
|
79
84
|
return route && route.scope ? route.scope : null;
|
|
80
85
|
}
|
|
81
86
|
|
|
82
|
-
async function authorizationApi(request, env, url, state) {
|
|
87
|
+
async function authorizationApi(request, env, url, state, features = []) {
|
|
83
88
|
const grantsMatch = url.pathname.match(/\/api\/admin\/users\/([^/]+)\/scopes$/);
|
|
84
|
-
const platformPath = url.pathname === "/api/admin/users" || url.pathname === "/api/admin/scopes" || url.pathname === "/api/admin/status" || url.pathname === "/api/admin/healthchecks" || url.pathname === "/api/admin/circuit-breakers" || url.pathname.startsWith("/api/admin/healthchecks/") || url.pathname.startsWith("/api/admin/circuit-breakers/") || Boolean(grantsMatch);
|
|
89
|
+
const platformPath = url.pathname === "/api/admin/users" || url.pathname === "/api/admin/scopes" || url.pathname === "/api/admin/groups" || url.pathname.startsWith("/api/admin/users/") || url.pathname === "/api/admin/status" || url.pathname === "/api/admin/features" || url.pathname === "/api/admin/healthchecks" || url.pathname === "/api/admin/circuit-breakers" || url.pathname.startsWith("/api/admin/healthchecks/") || url.pathname.startsWith("/api/admin/circuit-breakers/") || Boolean(grantsMatch);
|
|
85
90
|
if (!platformPath) return null;
|
|
86
91
|
if (!(state.user.auth_strategy === "http_basic" || (state.authUser && state.authUser.is_admin))) return Response.json({ error: "Administrator access is required." }, { status: 403, headers: { "Cache-Control": "no-store" } });
|
|
87
92
|
if (url.pathname === "/api/admin/users" && request.method === "GET") return Response.json({ users: await listAuthorizationUsers(env, { who: requestActor(state) }) });
|
|
88
93
|
if (url.pathname === "/api/admin/scopes" && request.method === "GET") return Response.json({ scopes: await listAuthorizationScopes(env, { who: requestActor(state) }) });
|
|
89
94
|
if (url.pathname === "/api/admin/status" && request.method === "GET") return Response.json({ features: await listFeatureHealth(env, { who: requestActor(state) }) });
|
|
95
|
+
if (url.pathname === "/api/admin/features" && request.method === "GET") return Response.json({ features: await listFeatureCatalog(env, features, { who: requestActor(state) }) });
|
|
96
|
+
if (url.pathname === "/api/admin/groups" && request.method === "GET") return Response.json({ groups: await listGroups(env, { who: requestActor(state) }) });
|
|
90
97
|
if (url.pathname === "/api/admin/healthchecks" && request.method === "GET") return Response.json({ healthchecks: await listHealthchecks(env, { who: requestActor(state) }) });
|
|
91
98
|
if (url.pathname === "/api/admin/circuit-breakers" && request.method === "GET") return Response.json({ circuit_breakers: await listCircuitBreakers(env, { who: requestActor(state) }) });
|
|
92
99
|
const healthcheckMatch = url.pathname.match(/\/api\/admin\/healthchecks\/([^/]+)$/);
|
|
@@ -94,6 +101,9 @@ async function authorizationApi(request, env, url, state) {
|
|
|
94
101
|
const breakerMatch = url.pathname.match(/\/api\/admin\/circuit-breakers\/([^/]+)$/);
|
|
95
102
|
if (breakerMatch && request.method === "GET") return Response.json({ circuit_breaker: await getCircuitBreaker(env, decodeURIComponent(breakerMatch[1]), { who: requestActor(state) }) });
|
|
96
103
|
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 }); }
|
|
104
|
+
const groupsMatch = url.pathname.match(/\/api\/admin\/users\/([^/]+)\/groups$/);
|
|
105
|
+
if (groupsMatch && request.method === "GET") return Response.json({ groups: await listUserGroups(env, decodeURIComponent(groupsMatch[1]), { who: requestActor(state) }) });
|
|
106
|
+
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) }) }); }
|
|
97
107
|
if (grantsMatch && request.method === "GET") return Response.json({ grants: await listUserGrants(env, decodeURIComponent(grantsMatch[1]), { who: requestActor(state) }) });
|
|
98
108
|
if (grantsMatch && request.method === "PUT") {
|
|
99
109
|
const body = await request.json().catch(() => null);
|
|
@@ -185,3 +195,22 @@ async function track(env, event, properties, { tokenEnv, host }) {
|
|
|
185
195
|
console.error("[metrics] delivery failed", error);
|
|
186
196
|
}
|
|
187
197
|
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
async function featureCatalogPage(env, features, state) {
|
|
201
|
+
const catalog = await listFeatureCatalog(env, features, { who: requestActor(state) });
|
|
202
|
+
return new Response(featureCatalogMarkup(catalog), { headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" } });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function featureCatalogMarkup(catalog) {
|
|
206
|
+
const rows = catalog.map((item) => {
|
|
207
|
+
const checks = item.healthchecks.length ? "<ul>" + item.healthchecks.map((check) => "<li><strong>" + escapeHtml(check.display_name) + "</strong>: " + escapeHtml(check.state) + "</li>").join("") + "</ul>" : "<span>None registered</span>";
|
|
208
|
+
const breakers = item.circuit_breakers.length ? "<ul>" + item.circuit_breakers.map((breaker) => "<li><strong>" + escapeHtml(breaker.display_name) + "</strong>: " + escapeHtml(breaker.state) + "</li>").join("") + "</ul>" : "<span>None registered</span>";
|
|
209
|
+
const packageLabel = item.package_name ? escapeHtml(item.package_name) : "Unknown package";
|
|
210
|
+
const versionLabel = item.version ? escapeHtml(item.version) : "Unknown version";
|
|
211
|
+
return "<tr><td><strong>" + escapeHtml(item.display_name) + "</strong><br><code>" + escapeHtml(item.feature) + "</code></td><td>" + packageLabel + "<br>" + versionLabel + "</td><td><span class=\"state state-" + escapeHtml(item.health) + "\">" + escapeHtml(item.health) + "</span></td><td>" + (item.circuit_breaker ? escapeHtml(item.circuit_breaker.state) : "None") + "</td><td>" + checks + "</td><td>" + breakers + "</td></tr>";
|
|
212
|
+
}).join("");
|
|
213
|
+
return "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Features</title><style>body{font:15px/1.45 system-ui,sans-serif;color:#20231f;background:#f7f7f5;margin:0;padding:2rem}main{max-width:1200px;margin:auto;background:#fff;padding:1.5rem;border:1px solid #d8ddd5;border-radius:.6rem}nav{display:flex;gap:1rem;margin-bottom:1.5rem}a{color:#2f6f52}table{width:100%;border-collapse:collapse}th,td{padding:.7rem;border-bottom:1px solid #d8ddd5;text-align:left;vertical-align:top}th{font-size:.8rem;color:#687067;text-transform:uppercase}ul{margin:.25rem 0;padding-left:1.2rem}code{color:#687067}.state{font-weight:700}.state-green{color:#26734d}.state-yellow{color:#9a6b00}.state-red{color:#b3261e}</style></head><body><main><nav><a href=\"/admin\">Admin</a><a href=\"/admin/features\" aria-current=\"page\">Features</a><a href=\"/admin/users\">Users</a><a href=\"/admin/groups\">Groups</a></nav><h1>Installed features</h1><p>Runtime modules, package versions, healthchecks, and circuit breakers.</p><table><thead><tr><th>Feature</th><th>Package/version</th><th>Health</th><th>Roll-up breaker</th><th>Healthchecks</th><th>Circuit breakers</th></tr></thead><tbody>" + rows + "</tbody></table></main></body></html>";
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function escapeHtml(value) { return String(value ?? "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll(String.fromCharCode(39), "'"); }
|
package/src/ui/groups.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export class CfGroupCatalog extends HTMLElement { async connectedCallback() { this.innerHTML = "<section class=\"card\"><h2>User groups</h2><p class=\"status\">Loading</p><div class=\"list\"></div></section>"; const response = await fetch("/api/admin/groups", { credentials: "same-origin" }); const groups = (await response.json()).groups || []; const list = this.querySelector(".list"); list.replaceChildren(...groups.map((group) => { const row = document.createElement("div"); row.textContent = group.display_name + " — " + group.name; return row; })); this.querySelector(".status").textContent = groups.length + " groups"; } }
|
|
2
|
+
if (!customElements.get("cf-group-catalog")) customElements.define("cf-group-catalog", CfGroupCatalog);
|
package/src/ui/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
export * from "./groups.js";
|
|
1
2
|
const styles = `:host { --cf-ui-bg:#fff; --cf-ui-surface:#f7f7f5; --cf-ui-text:#20231f; --cf-ui-muted:#687067; --cf-ui-border:#d8ddd5; --cf-ui-primary:#2f6f52; color:var(--cf-ui-text); font:15px/1.45 system-ui,sans-serif } *,*::before,*::after{box-sizing:border-box}.shell{display:grid;gap:1rem}.nav{display:flex;flex-wrap:wrap;gap:.5rem;border-bottom:1px solid var(--cf-ui-border);padding-bottom:.75rem}.nav a{color:var(--cf-ui-text);padding:.45rem .7rem;border-radius:.4rem;text-decoration:none}.nav a:hover,.nav a[aria-current=page]{background:var(--cf-ui-surface);color:var(--cf-ui-primary)}.card{background:var(--cf-ui-bg);border:1px solid var(--cf-ui-border);border-radius:.6rem;padding:1rem;overflow:auto}table{width:100%;border-collapse:collapse}th,td{padding:.65rem;border-bottom:1px solid var(--cf-ui-border);text-align:left;vertical-align:top}th{color:var(--cf-ui-muted);font-size:.8rem;text-transform:uppercase;letter-spacing:.04em}button{border:1px solid var(--cf-ui-border);border-radius:.4rem;background:var(--cf-ui-bg);color:inherit;padding:.45rem .65rem;cursor:pointer}.scope-list{display:grid;gap:.3rem;min-width:14rem}.scope-list label{display:flex;gap:.4rem;align-items:center}.status{color:var(--cf-ui-muted);min-height:1.4em}`;
|
|
2
3
|
|
|
3
4
|
export class CfAdminShell extends HTMLElement {
|
|
4
5
|
connectedCallback() {
|
|
5
6
|
const active = this.getAttribute("active") || "";
|
|
6
|
-
this.attachShadow({ mode: "open" }).innerHTML = `<style>${styles}</style><div class="shell"><nav class="nav" part="navigation"><a href="/admin" ${active === "home" ? 'aria-current="page"' : ""}>Admin</a><a href="/admin/users" ${active === "users" ? 'aria-current="page"' : ""}>Users</a><a href="/admin/scopes" ${active === "scopes" ? 'aria-current="page"' : ""}>Scopes</a><a href="/admin/healthchecks">Healthchecks</a><a href="/admin/circuit-breakers">Circuit breakers</a></nav><slot></slot></div>`;
|
|
7
|
+
this.attachShadow({ mode: "open" }).innerHTML = `<style>${styles}</style><div class="shell"><nav class="nav" part="navigation"><a href="/admin" ${active === "home" ? 'aria-current="page"' : ""}>Admin</a><a href="/admin/users" ${active === "users" ? 'aria-current="page"' : ""}>Users</a><a href="/admin/scopes" ${active === "scopes" ? 'aria-current="page"' : ""}>Scopes</a><a href="/admin/features">Features</a><a href="/admin/healthchecks">Healthchecks</a><a href="/admin/circuit-breakers">Circuit breakers</a><a href="/admin/groups">Groups</a></nav><slot></slot></div>`;
|
|
7
8
|
}
|
|
8
9
|
}
|
|
9
10
|
|