@agilesyndrome/cf-genai-base 0.1.5 → 1.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 +11 -2
- package/README.md +15 -2
- package/migrations/0001_authorization.sql +32 -0
- package/migrations/0002_core.sql +45 -0
- package/package.json +8 -3
- package/src/authorization.js +79 -0
- package/src/core.js +145 -0
- package/src/index.js +97 -2
- package/src/ui/index.js +62 -0
- package/src/ui/styles.css +9 -0
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?, scheduled?, security? })` owns the Worker lifecycle. 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. 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
|
|
@@ -12,7 +12,12 @@ is optional and must use `ctx.waitUntil` for background work.
|
|
|
12
12
|
- `GET /health` returns `{ ok, version, build_number }` and is cache-disabled.
|
|
13
13
|
- `GET /api/me` returns `{ user: null | { sub, email, name, ...roles } }`.
|
|
14
14
|
- `/auth/login`, `/auth/callback`, and `/auth/logout` are reserved for auth.
|
|
15
|
-
-
|
|
15
|
+
- `/admin` and `/admin/*` are browser admin routes; `/api/admin` and `/api/admin/*` are admin API routes.
|
|
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
|
+
- `AUTH_STRATEGY=oauth` delegates identity establishment to the configured auth provider and uses `authorize` for admin policy.
|
|
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 migration is installed.
|
|
20
|
+
- Public APIs must be explicitly listed in provider-specific auth configuration.
|
|
16
21
|
- Mutating `/api/*` requests require a same-origin `Origin` header.
|
|
17
22
|
|
|
18
23
|
## Environment and bindings
|
|
@@ -32,6 +37,10 @@ Standard bindings:
|
|
|
32
37
|
|
|
33
38
|
Build metadata is optional: `BUILD_SHA` and `BUILD_NUMBER`.
|
|
34
39
|
|
|
40
|
+
The package includes `migrations/0001_authorization.sql`; each site must apply
|
|
41
|
+
the equivalent migration to its own D1 database before enabling the generic
|
|
42
|
+
user/scope APIs.
|
|
43
|
+
|
|
35
44
|
D1 migrations are committed with the site, applied by Wrangler, and are the
|
|
36
45
|
source of truth for schema changes. R2 stores binary data; metadata and access
|
|
37
46
|
control remain in D1.
|
package/README.md
CHANGED
|
@@ -2,11 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
Opinionated startup boilerplate for small Cloudflare Workers.
|
|
4
4
|
|
|
5
|
-
The base is
|
|
6
|
-
queries, R2 keys, and scheduled jobs. `createWorker` composes ordered feature middleware, normalizes uncaught failures, and applies baseline response headers.
|
|
5
|
+
The base owns the shared security boundary as well as Worker lifecycle concerns. It reserves `/admin` and `/api/admin` routes, authenticates them using `AUTH_STRATEGY` (default `http_basic`, or `oauth` when an auth provider is supplied), and applies the optional `authorize` policy. Sites still own their router, HTML, D1 queries, R2 keys, and scheduled jobs.
|
|
7
6
|
Use D1 bindings for durable application data and R2 bindings for binary assets;
|
|
8
7
|
do not put either into module-level state.
|
|
9
8
|
|
|
9
|
+
Base also provides provider-neutral authorization helpers and browser components
|
|
10
|
+
through `@agilesyndrome/cf-genai-base/authorization` and
|
|
11
|
+
`@agilesyndrome/cf-genai-base/ui`. Applications declare their scope manifest,
|
|
12
|
+
while base owns the user, scope, and grant records plus the generic user-access
|
|
13
|
+
API. The UI components are themeable with CSS custom properties and do not
|
|
14
|
+
contain application-specific components.
|
|
15
|
+
|
|
10
16
|
```js
|
|
11
17
|
import { createWorker, healthResponse } from "@agilesyndrome/cf-genai-base";
|
|
12
18
|
|
|
@@ -27,3 +33,10 @@ Features expose `middleware(request, env, ctx, next, state)` and may short-circu
|
|
|
27
33
|
requests, and optionally deliver server-side PostHog events. Use
|
|
28
34
|
`assertBoot(env, { bindings: ["DB"], required: ["AUTH_SESSION_SECRET"] })` in a
|
|
29
35
|
site initializer to fail closed when its Cloudflare configuration is incomplete.
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
## Core operational services
|
|
39
|
+
|
|
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
|
+
|
|
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`. Use `createD1(env, { who })` for downstream D1 calls; it emits EventLog and AuditLog console records with the requesting actor.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS auth_users (
|
|
2
|
+
id TEXT PRIMARY KEY,
|
|
3
|
+
provider TEXT NOT NULL,
|
|
4
|
+
subject TEXT NOT NULL,
|
|
5
|
+
email TEXT NOT NULL DEFAULT '',
|
|
6
|
+
display_name TEXT NOT NULL DEFAULT '',
|
|
7
|
+
is_admin INTEGER NOT NULL DEFAULT 0 CHECK (is_admin IN (0,1)),
|
|
8
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
9
|
+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
10
|
+
UNIQUE(provider, subject)
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
CREATE INDEX IF NOT EXISTS auth_users_email_idx ON auth_users(email COLLATE NOCASE);
|
|
14
|
+
|
|
15
|
+
CREATE TABLE IF NOT EXISTS auth_scopes (
|
|
16
|
+
name TEXT PRIMARY KEY,
|
|
17
|
+
label TEXT NOT NULL,
|
|
18
|
+
description TEXT NOT NULL DEFAULT '',
|
|
19
|
+
system INTEGER NOT NULL DEFAULT 0 CHECK (system IN (0,1)),
|
|
20
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
21
|
+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
CREATE TABLE IF NOT EXISTS auth_user_scopes (
|
|
25
|
+
user_id TEXT NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE,
|
|
26
|
+
scope_name TEXT NOT NULL REFERENCES auth_scopes(name) ON DELETE CASCADE,
|
|
27
|
+
granted_by TEXT,
|
|
28
|
+
granted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
29
|
+
PRIMARY KEY (user_id, scope_name)
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
CREATE INDEX IF NOT EXISTS auth_user_scopes_scope_idx ON auth_user_scopes(scope_name);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
CREATE TABLE IF NOT EXISTS core_healthchecks (
|
|
2
|
+
id TEXT PRIMARY KEY,
|
|
3
|
+
feature TEXT NOT NULL,
|
|
4
|
+
component TEXT NOT NULL,
|
|
5
|
+
display_name TEXT NOT NULL,
|
|
6
|
+
state TEXT NOT NULL DEFAULT 'yellow' CHECK (state IN ('red', 'yellow', 'green')),
|
|
7
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
8
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
9
|
+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
10
|
+
UNIQUE(feature, component)
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
CREATE INDEX IF NOT EXISTS core_healthchecks_state_idx ON core_healthchecks(state);
|
|
14
|
+
|
|
15
|
+
CREATE TABLE IF NOT EXISTS core_circuit_breakers (
|
|
16
|
+
id TEXT PRIMARY KEY,
|
|
17
|
+
feature TEXT NOT NULL,
|
|
18
|
+
name TEXT NOT NULL,
|
|
19
|
+
display_name TEXT NOT NULL,
|
|
20
|
+
state TEXT NOT NULL DEFAULT 'off' CHECK (state IN ('off', 'tripped', 'on')),
|
|
21
|
+
healthcheck_mode TEXT NOT NULL DEFAULT 'any' CHECK (healthcheck_mode IN ('any', 'all')),
|
|
22
|
+
allow_self_healing INTEGER NOT NULL DEFAULT 0 CHECK (allow_self_healing IN (0, 1)),
|
|
23
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
24
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
25
|
+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
26
|
+
UNIQUE(feature, name)
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
CREATE TABLE IF NOT EXISTS core_circuit_breaker_healthchecks (
|
|
30
|
+
circuit_breaker_id TEXT NOT NULL REFERENCES core_circuit_breakers(id) ON DELETE CASCADE,
|
|
31
|
+
healthcheck_id TEXT NOT NULL REFERENCES core_healthchecks(id) ON DELETE CASCADE,
|
|
32
|
+
PRIMARY KEY(circuit_breaker_id, healthcheck_id)
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
CREATE INDEX IF NOT EXISTS core_circuit_breaker_healthchecks_healthcheck_idx
|
|
36
|
+
ON core_circuit_breaker_healthchecks(healthcheck_id);
|
|
37
|
+
|
|
38
|
+
CREATE TABLE IF NOT EXISTS core_circuit_breaker_dependencies (
|
|
39
|
+
circuit_breaker_id TEXT NOT NULL REFERENCES core_circuit_breakers(id) ON DELETE CASCADE,
|
|
40
|
+
dependency_id TEXT NOT NULL REFERENCES core_circuit_breakers(id) ON DELETE CASCADE,
|
|
41
|
+
PRIMARY KEY(circuit_breaker_id, dependency_id)
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
CREATE INDEX IF NOT EXISTS core_circuit_breaker_dependencies_dependency_idx
|
|
45
|
+
ON core_circuit_breaker_dependencies(dependency_id);
|
package/package.json
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agilesyndrome/cf-genai-base",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
|
-
".": "./src/index.js"
|
|
6
|
+
".": "./src/index.js",
|
|
7
|
+
"./authorization": "./src/authorization.js",
|
|
8
|
+
"./core": "./src/core.js",
|
|
9
|
+
"./ui": "./src/ui/index.js",
|
|
10
|
+
"./ui/styles.css": "./src/ui/styles.css"
|
|
7
11
|
},
|
|
8
12
|
"description": "Lean Worker lifecycle and security helpers for Cloudflare sites.",
|
|
9
13
|
"license": "MIT",
|
|
@@ -13,6 +17,7 @@
|
|
|
13
17
|
},
|
|
14
18
|
"files": [
|
|
15
19
|
"src",
|
|
20
|
+
"migrations",
|
|
16
21
|
"README.md",
|
|
17
22
|
"CONTRACT.md",
|
|
18
23
|
"LICENSE"
|
|
@@ -24,7 +29,7 @@
|
|
|
24
29
|
"homepage": "https://github.com/agilesyndrome/cf-genai-base#readme",
|
|
25
30
|
"scripts": {
|
|
26
31
|
"check": "node --check src/index.js",
|
|
27
|
-
"test": "node --
|
|
32
|
+
"test": "node --test tests/*.test.mjs",
|
|
28
33
|
"build": "npm run check && npm test && npm pack --dry-run"
|
|
29
34
|
}
|
|
30
35
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { createD1 } from "./core.js";
|
|
2
|
+
|
|
3
|
+
export const AUTH_USER_TABLE = "auth_users";
|
|
4
|
+
export const AUTH_SCOPE_TABLE = "auth_scopes";
|
|
5
|
+
export const AUTH_GRANT_TABLE = "auth_user_scopes";
|
|
6
|
+
|
|
7
|
+
export function normalizeScopes(scopes = []) {
|
|
8
|
+
return scopes.map((scope) => typeof scope === "string" ? { name: scope, label: scope, description: "", system: false } : scope)
|
|
9
|
+
.filter((scope) => scope && /^[a-z0-9]+(?::[a-z0-9-]+)+$/.test(String(scope.name || "")))
|
|
10
|
+
.map((scope) => ({ name: String(scope.name), label: String(scope.label || scope.name), description: String(scope.description || ""), system: Boolean(scope.system) }));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function ensureScopes(env, scopes = [], { who = "system:read" } = {}) {
|
|
14
|
+
if (!env?.DB) return;
|
|
15
|
+
const db = createD1(env, { who });
|
|
16
|
+
for (const scope of normalizeScopes(scopes)) {
|
|
17
|
+
await db.prepare(`INSERT INTO ${AUTH_SCOPE_TABLE} (name,label,description,system) VALUES (?,?,?,?) ON CONFLICT(name) DO UPDATE SET label=excluded.label,description=excluded.description,system=excluded.system`).bind(scope.name, scope.label, scope.description, scope.system ? 1 : 0).run();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function ensureUser(env, user, { who = "system:read" } = {}) {
|
|
22
|
+
if (!env?.DB || !user?.sub) return null;
|
|
23
|
+
const db = createD1(env, { who });
|
|
24
|
+
const provider = String(user.auth_strategy || "oauth");
|
|
25
|
+
const subject = String(user.sub);
|
|
26
|
+
const email = String(user.email || "").trim().toLowerCase();
|
|
27
|
+
const existing = await db.prepare(`SELECT * FROM ${AUTH_USER_TABLE} WHERE provider=? AND subject=?`).bind(provider, subject).first();
|
|
28
|
+
const bootstrap = new Set(String(env.AUTH_ADMIN_EMAILS || env.ADMIN_EMAILS || "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean));
|
|
29
|
+
if (existing) {
|
|
30
|
+
await db.prepare(`UPDATE ${AUTH_USER_TABLE} SET email=?,display_name=?,is_admin=CASE WHEN is_admin=1 OR ? THEN 1 ELSE 0 END,updated_at=CURRENT_TIMESTAMP WHERE id=?`).bind(email, String(user.name || email || subject), bootstrap.has(email) ? 1 : 0, existing.id).run();
|
|
31
|
+
return { ...existing, email, display_name: String(user.name || email || subject), is_admin: Boolean(existing.is_admin || bootstrap.has(email)) };
|
|
32
|
+
}
|
|
33
|
+
const id = await stableId(`${provider}:${subject}`);
|
|
34
|
+
await db.prepare(`INSERT INTO ${AUTH_USER_TABLE} (id,provider,subject,email,display_name,is_admin) VALUES (?,?,?,?,?,?) ON CONFLICT(provider,subject) DO NOTHING`).bind(id, provider, subject, email, String(user.name || email || subject), bootstrap.has(email) ? 1 : 0).run();
|
|
35
|
+
return await db.prepare(`SELECT * FROM ${AUTH_USER_TABLE} WHERE id=?`).bind(id).first();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function hasScope(env, user, scope, { who = "system:read" } = {}) {
|
|
39
|
+
const db = createD1(env, { who });
|
|
40
|
+
if (user?.auth_strategy === "http_basic") return true;
|
|
41
|
+
const authUser = await ensureUser(env, user, { who });
|
|
42
|
+
if (!authUser) return false;
|
|
43
|
+
if (Boolean(authUser.is_admin)) return true;
|
|
44
|
+
return Boolean(await db.prepare(`SELECT 1 FROM ${AUTH_GRANT_TABLE} WHERE user_id=? AND scope_name=?`).bind(authUser.id, scope).first());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function listAuthorizationUsers(env, { who = "system:read" } = {}) {
|
|
48
|
+
const db = createD1(env, { who });
|
|
49
|
+
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();
|
|
50
|
+
return Promise.all(results.map(async (user) => ({ ...user, scopes: (await listUserGrants(env, user.id, { who })).map((grant) => grant.scope_name) })));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function listAuthorizationScopes(env, { who = "system:read" } = {}) {
|
|
54
|
+
const db = createD1(env, { who });
|
|
55
|
+
const { results } = await db.prepare(`SELECT name,label,description,system FROM ${AUTH_SCOPE_TABLE} ORDER BY name`).all();
|
|
56
|
+
return results;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function listUserGrants(env, userId, { who = "system:read" } = {}) {
|
|
60
|
+
const db = createD1(env, { who });
|
|
61
|
+
const { results } = await db.prepare(`SELECT scope_name,granted_at FROM ${AUTH_GRANT_TABLE} WHERE user_id=? ORDER BY scope_name`).bind(userId).all();
|
|
62
|
+
return results;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function replaceUserGrants(env, userId, scopes, grantedBy, { who = "system:read" } = {}) {
|
|
66
|
+
const valid = new Set((await listAuthorizationScopes(env, { who })).map((scope) => scope.name));
|
|
67
|
+
const db = createD1(env, { who });
|
|
68
|
+
const requested = [...new Set(scopes)].filter((scope) => valid.has(scope));
|
|
69
|
+
await db.batch([
|
|
70
|
+
db.prepare(`DELETE FROM ${AUTH_GRANT_TABLE} WHERE user_id=?`).bind(userId),
|
|
71
|
+
...requested.map((scope) => db.prepare(`INSERT INTO ${AUTH_GRANT_TABLE} (user_id,scope_name,granted_by) VALUES (?,?,?)`).bind(userId, scope, grantedBy || null))
|
|
72
|
+
]);
|
|
73
|
+
return requested;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function stableId(value) {
|
|
77
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
78
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("").slice(0, 32);
|
|
79
|
+
}
|
package/src/core.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
export const HEALTHCHECK_STATES = ["red", "yellow", "green"];
|
|
2
|
+
export const CIRCUIT_BREAKER_STATES = ["off", "tripped", "on"];
|
|
3
|
+
export const HEALTHCHECK_MODES = ["any", "all"];
|
|
4
|
+
|
|
5
|
+
export function eventLog(level, event, details = {}) {
|
|
6
|
+
const method = ["debug", "info", "warn", "error"].includes(level) ? level : "info";
|
|
7
|
+
console[method](`[EventLog] ${event}`, details);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function auditLog({ who = "system", operation, resource, details = {} }) {
|
|
11
|
+
console.info(`[AuditLog] ${who}:${operation} ${resource}`, details);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function requestActor(state = {}) {
|
|
15
|
+
if (state.requestedBy) return String(state.requestedBy);
|
|
16
|
+
if (state.authUser?.id) return `user:${state.authUser.id}`;
|
|
17
|
+
if (state.user?.auth_strategy === "http_basic") return "user:admin";
|
|
18
|
+
if (state.user?.sub) return `user:${state.user.sub}`;
|
|
19
|
+
return "system:read";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createD1(env, { who = "system:read" } = {}) {
|
|
23
|
+
if (!env?.DB) throw new Error("A DB binding is required");
|
|
24
|
+
const db = env.DB;
|
|
25
|
+
return {
|
|
26
|
+
prepare(sql) {
|
|
27
|
+
const statement = db.prepare(sql);
|
|
28
|
+
return new Proxy(statement, { get(target, property) {
|
|
29
|
+
const value = target[property];
|
|
30
|
+
if (typeof value !== "function" || !["run", "first", "all", "raw"].includes(property)) return typeof value === "function" ? value.bind(target) : value;
|
|
31
|
+
return async (...args) => {
|
|
32
|
+
auditD1(who, sql);
|
|
33
|
+
return value.apply(target, args);
|
|
34
|
+
};
|
|
35
|
+
}});
|
|
36
|
+
},
|
|
37
|
+
async batch(statements) {
|
|
38
|
+
auditD1(who, "BATCH");
|
|
39
|
+
return db.batch(statements);
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function auditD1(who, sql) {
|
|
45
|
+
const operation = String(sql).trim().match(/^(SELECT|INSERT|UPDATE|DELETE|REPLACE|WITH)/i)?.[1]?.toLowerCase() || "execute";
|
|
46
|
+
eventLog("debug", "d1.operation", { who, operation });
|
|
47
|
+
auditLog({ who, operation, resource: "d1" });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function normalizeHealthcheck(input = {}) {
|
|
51
|
+
const state = String(input.state || "yellow").toLowerCase();
|
|
52
|
+
if (!HEALTHCHECK_STATES.includes(state)) throw new Error("Healthcheck state must be red, yellow, or green");
|
|
53
|
+
if (!input.feature || !input.component || !input.displayName) throw new Error("Healthchecks require feature, component, and displayName");
|
|
54
|
+
return { feature: String(input.feature), component: String(input.component), displayName: String(input.displayName), state, metadata: input.metadata || {} };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function normalizeCircuitBreaker(input = {}) {
|
|
58
|
+
const state = String(input.state || "off").toLowerCase();
|
|
59
|
+
const healthcheckMode = String(input.healthcheckMode || "any").toLowerCase();
|
|
60
|
+
if (!CIRCUIT_BREAKER_STATES.includes(state)) throw new Error("Circuit breaker state must be off, tripped, or on");
|
|
61
|
+
if (!HEALTHCHECK_MODES.includes(healthcheckMode)) throw new Error("Circuit breaker healthcheckMode must be any or all");
|
|
62
|
+
if (!input.feature || !input.name || !input.displayName) throw new Error("Circuit breakers require feature, name, and displayName");
|
|
63
|
+
return { feature: String(input.feature), name: String(input.name), displayName: String(input.displayName), state, healthcheckMode, allowSelfHealing: Boolean(input.allowSelfHealing), healthchecks: Array.isArray(input.healthchecks) ? input.healthchecks.map(String) : [], dependsOnCircuitBreakers: Array.isArray(input.dependsOnCircuitBreakers || input.dependencies) ? (input.dependsOnCircuitBreakers || input.dependencies).map(String) : [], metadata: input.metadata || {} };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function registerHealthcheck(env, input, { who = "system:read" } = {}) {
|
|
67
|
+
const item = normalizeHealthcheck(input);
|
|
68
|
+
const db = createD1(env, { who });
|
|
69
|
+
const id = String(input.id || `${item.feature}:${item.component}`);
|
|
70
|
+
await db.prepare(`INSERT INTO core_healthchecks (id,feature,component,display_name,state,metadata_json) VALUES (?,?,?,?,?,?) ON CONFLICT(feature,component) DO UPDATE SET display_name=excluded.display_name,metadata_json=excluded.metadata_json,updated_at=CURRENT_TIMESTAMP`).bind(id, item.feature, item.component, item.displayName, item.state, JSON.stringify(item.metadata)).run();
|
|
71
|
+
return getHealthcheck(env, id, { who });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function updateHealthcheck(env, id, state, { who = "system:read" } = {}) {
|
|
75
|
+
if (!HEALTHCHECK_STATES.includes(String(state).toLowerCase())) throw new Error("Healthcheck state must be red, yellow, or green");
|
|
76
|
+
const item = await getHealthcheck(env, id, { who });
|
|
77
|
+
if (!item) return null;
|
|
78
|
+
const db = createD1(env, { who });
|
|
79
|
+
await db.prepare("UPDATE core_healthchecks SET state=?,updated_at=CURRENT_TIMESTAMP WHERE id=?").bind(String(state).toLowerCase(), id).run();
|
|
80
|
+
auditLog({ who, operation: "update", resource: `feature:${item.feature} component:${item.component} ${String(state).toLowerCase()}` });
|
|
81
|
+
return getHealthcheck(env, id, { who });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function getHealthcheck(env, id, { who = "system:read" } = {}) { return (await createD1(env, { who }).prepare("SELECT * FROM core_healthchecks WHERE id=?").bind(id).first()) || null; }
|
|
85
|
+
export async function listHealthchecks(env, { who = "system:read" } = {}) { return (await createD1(env, { who }).prepare("SELECT * FROM core_healthchecks ORDER BY feature,component").bind().all()).results || []; }
|
|
86
|
+
|
|
87
|
+
export async function registerCircuitBreaker(env, input, { who = "system:read" } = {}) {
|
|
88
|
+
const item = normalizeCircuitBreaker(input);
|
|
89
|
+
const db = createD1(env, { who });
|
|
90
|
+
const id = String(input.id || `${item.feature}:${item.name}`);
|
|
91
|
+
await db.prepare(`INSERT INTO core_circuit_breakers (id,feature,name,display_name,state,healthcheck_mode,allow_self_healing,metadata_json) VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(feature,name) DO UPDATE SET display_name=excluded.display_name,healthcheck_mode=excluded.healthcheck_mode,allow_self_healing=excluded.allow_self_healing,metadata_json=excluded.metadata_json,updated_at=CURRENT_TIMESTAMP`).bind(id, item.feature, item.name, item.displayName, item.state, item.healthcheckMode, item.allowSelfHealing ? 1 : 0, JSON.stringify(item.metadata)).run();
|
|
92
|
+
await db.batch([db.prepare("DELETE FROM core_circuit_breaker_healthchecks WHERE circuit_breaker_id=?").bind(id), db.prepare("DELETE FROM core_circuit_breaker_dependencies WHERE circuit_breaker_id=?").bind(id), ...item.healthchecks.map((healthcheckId) => db.prepare("INSERT OR IGNORE INTO core_circuit_breaker_healthchecks (circuit_breaker_id,healthcheck_id) VALUES (?,?)").bind(id, healthcheckId)), ...item.dependsOnCircuitBreakers.map((dependencyId) => db.prepare("INSERT OR IGNORE INTO core_circuit_breaker_dependencies (circuit_breaker_id,dependency_id) VALUES (?,?)").bind(id, dependencyId))]);
|
|
93
|
+
return getCircuitBreaker(env, id, { who });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function getCircuitBreaker(env, id, { who = "system:read" } = {}) { const db = createD1(env, { who }); const breaker = await db.prepare("SELECT * FROM core_circuit_breakers WHERE id=?").bind(id).first(); if (!breaker) return null; breaker.healthchecks = (await db.prepare("SELECT healthcheck_id FROM core_circuit_breaker_healthchecks WHERE circuit_breaker_id=?").bind(id).all()).results.map((row) => row.healthcheck_id) ; breaker.depends_on_circuit_breakers = (await db.prepare("SELECT dependency_id FROM core_circuit_breaker_dependencies WHERE circuit_breaker_id=?").bind(id).all()).results.map((row) => row.dependency_id); return breaker; }
|
|
97
|
+
export async function listCircuitBreakers(env, { who = "system:read" } = {}) { const rows = (await createD1(env, { who }).prepare("SELECT * FROM core_circuit_breakers ORDER BY feature,name").bind().all()).results || []; return Promise.all(rows.map((row) => getCircuitBreaker(env, row.id, { who }))); }
|
|
98
|
+
|
|
99
|
+
export async function setCircuitBreaker(env, id, state, { who = "system:read", automated = false } = {}) {
|
|
100
|
+
const next = String(state).toLowerCase();
|
|
101
|
+
if (!CIRCUIT_BREAKER_STATES.includes(next)) throw new Error("Circuit breaker state must be off, tripped, or on");
|
|
102
|
+
const current = await getCircuitBreaker(env, id, { who });
|
|
103
|
+
if (!current) return null;
|
|
104
|
+
if (automated && !((current.state === "on" && next === "tripped") || (current.state === "tripped" && next === "on" && Boolean(current.allow_self_healing)))) return current;
|
|
105
|
+
if (automated && next === "off") return current;
|
|
106
|
+
await createD1(env, { who }).prepare("UPDATE core_circuit_breakers SET state=?,updated_at=CURRENT_TIMESTAMP WHERE id=?").bind(next, id).run();
|
|
107
|
+
auditLog({ who, operation: "update", resource: `feature:${current.feature} circuit_breaker:${current.name} ${next}` });
|
|
108
|
+
return getCircuitBreaker(env, id, { who });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function evaluateCircuitBreaker(env, id, { who = "system:read" } = {}) {
|
|
112
|
+
const breaker = await getCircuitBreaker(env, id, { who });
|
|
113
|
+
if (!breaker) return null;
|
|
114
|
+
const db = createD1(env, { who });
|
|
115
|
+
const rows = (await db.prepare("SELECT h.state FROM core_healthchecks h JOIN core_circuit_breaker_healthchecks b ON b.healthcheck_id=h.id WHERE b.circuit_breaker_id=?").bind(id).all()).results || [];
|
|
116
|
+
const failing = rows.map((row) => row.state === "red");
|
|
117
|
+
const dependencies = (await db.prepare("SELECT b.state FROM core_circuit_breakers b JOIN core_circuit_breaker_dependencies d ON d.dependency_id=b.id WHERE d.circuit_breaker_id=?").bind(id).all()).results || [];
|
|
118
|
+
const dependencyFailed = dependencies.some((row) => row.state === "tripped");
|
|
119
|
+
const shouldTrip = dependencyFailed || (failing.length > 0 && (breaker.healthcheck_mode === "all" ? failing.every(Boolean) : failing.some(Boolean)));
|
|
120
|
+
if (breaker.state === "on" && shouldTrip) return setCircuitBreaker(env, id, "tripped", { who, automated: true });
|
|
121
|
+
if (breaker.state === "tripped" && !shouldTrip && breaker.allow_self_healing) return setCircuitBreaker(env, id, "on", { who, automated: true });
|
|
122
|
+
return breaker;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
export async function registerFeatureManifests(env, features = [], { who = "system:read" } = {}) {
|
|
127
|
+
for (const feature of features) {
|
|
128
|
+
const name = String(feature?.name || feature?.id || "feature");
|
|
129
|
+
const declaredResult = typeof feature?.healthcheck === "function" ? await feature.healthcheck(env, { who }) : (feature?.healthchecks || feature?.healthChecks || []);
|
|
130
|
+
const declaredHealthchecks = Array.isArray(declaredResult) ? declaredResult : [declaredResult];
|
|
131
|
+
const healthchecks = [];
|
|
132
|
+
const breakers = [];
|
|
133
|
+
for (const healthcheck of declaredHealthchecks) healthchecks.push(await registerHealthcheck(env, { ...healthcheck, feature: healthcheck.feature || name }, { who }));
|
|
134
|
+
for (const breaker of feature?.circuitBreakers || feature?.circuit_breakers || []) breakers.push(await registerCircuitBreaker(env, { ...breaker, feature: breaker.feature || name }, { who }));
|
|
135
|
+
await registerCircuitBreaker(env, { id: `${name}:rollup`, feature: name, name: "rollup", displayName: `${name} feature`, state: "on", allowSelfHealing: true, healthchecks: healthchecks.filter(Boolean).map((item) => item.id), dependsOnCircuitBreakers: breakers.filter(Boolean).map((item) => item.id) }, { who });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function listFeatureHealth(env, { who = "system:read" } = {}) {
|
|
140
|
+
const checks = await listHealthchecks(env, { who });
|
|
141
|
+
const severity = { green: 0, yellow: 1, red: 2 };
|
|
142
|
+
const features = {};
|
|
143
|
+
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
|
+
return Object.values(features).sort((a, b) => a.feature.localeCompare(b.feature));
|
|
145
|
+
}
|
package/src/index.js
CHANGED
|
@@ -2,9 +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
|
-
|
|
5
|
+
import { ensureScopes, ensureUser, hasScope, listAuthorizationScopes, listAuthorizationUsers, listUserGrants, replaceUserGrants } from "./authorization.js";
|
|
6
|
+
import { getCircuitBreaker, listCircuitBreakers, listHealthchecks, listFeatureHealth, registerFeatureManifests, requestActor, setCircuitBreaker, updateHealthcheck } from "./core.js";
|
|
7
|
+
export * from "./core.js";
|
|
8
|
+
export function createWorker({ fetch, scheduled, auth, authorize, scopes = [], scopeRoutes = [], middleware = [], features = [], health, boot, metrics, security = true }) {
|
|
6
9
|
if (typeof fetch !== "function") throw new TypeError("createWorker requires a fetch handler");
|
|
10
|
+
const provider = auth || features.find((feature) => typeof feature?.getUser === "function");
|
|
7
11
|
const chain = [
|
|
12
|
+
(request, env, ctx, next, state) => adminBoundary(request, env, ctx, next, state, { provider, authorize, scopes, scopeRoutes }),
|
|
8
13
|
...features.flatMap((feature) => feature?.middleware ? [feature.middleware.bind(feature)] : []),
|
|
9
14
|
...middleware,
|
|
10
15
|
...(auth ? [(request, env, ctx, next) => auth(request, env, ctx, next)] : []),
|
|
@@ -15,12 +20,14 @@ export function createWorker({ fetch, scheduled, auth, middleware = [], features
|
|
|
15
20
|
if (boot) await boot(env, { request, ctx });
|
|
16
21
|
const url = new URL(request.url);
|
|
17
22
|
const state = Object.create(null);
|
|
23
|
+
if (env?.DB && features.some((feature) => typeof feature?.healthcheck === "function" || feature?.healthchecks?.length || feature?.healthChecks?.length || feature?.circuitBreakers?.length || feature?.circuit_breakers?.length)) ctx?.waitUntil?.(registerFeatureManifests(env, features, { who: "system:update" }).catch((error) => console.error("[EventLog] feature manifest registration failed", error)));
|
|
18
24
|
const dispatch = async (index, currentRequest = request) => {
|
|
19
25
|
const layer = chain[index];
|
|
20
26
|
if (!layer) {
|
|
21
27
|
if (url.pathname === "/health" || url.pathname === "/api/health") {
|
|
22
28
|
const details = health ? await health(env, { request: currentRequest, ctx, state }) : {};
|
|
23
|
-
|
|
29
|
+
const featureHealth = env?.DB ? await listFeatureHealth(env, { who: "system:read" }).catch(() => []) : [];
|
|
30
|
+
return healthResponse(env, featureHealth.length ? { ...details, features: featureHealth } : details);
|
|
24
31
|
}
|
|
25
32
|
return fetch(currentRequest, env, ctx, state);
|
|
26
33
|
}
|
|
@@ -39,6 +46,94 @@ export function createWorker({ fetch, scheduled, auth, middleware = [], features
|
|
|
39
46
|
};
|
|
40
47
|
}
|
|
41
48
|
|
|
49
|
+
|
|
50
|
+
async function adminBoundary(request, env, ctx, next, state, { provider, authorize, scopes, scopeRoutes }) {
|
|
51
|
+
const url = new URL(request.url);
|
|
52
|
+
if (!isAdminPath(url.pathname)) return next(request);
|
|
53
|
+
const strategy = String(env?.AUTH_STRATEGY || "http_basic").trim().toLowerCase();
|
|
54
|
+
if (strategy === "http_basic") {
|
|
55
|
+
const user = basicUser(request, env);
|
|
56
|
+
if (!user) return adminUnauthorized(request);
|
|
57
|
+
state.user = user;
|
|
58
|
+
} else if (strategy === "oauth") {
|
|
59
|
+
const user = provider?.getUser ? await provider.getUser(request, env) : null;
|
|
60
|
+
if (!user) return oauthUnauthorized(request, url);
|
|
61
|
+
state.user = user;
|
|
62
|
+
} else {
|
|
63
|
+
return new Response("Unsupported AUTH_STRATEGY", { status: 500, headers: { "Cache-Control": "no-store" } });
|
|
64
|
+
}
|
|
65
|
+
await ensureScopes(env, scopes, { who: state.user?.auth_strategy === "http_basic" ? "user:admin" : `user:${state.user?.sub || "unknown"}` });
|
|
66
|
+
state.authUser = await ensureUser(env, state.user, { who: state.user?.auth_strategy === "http_basic" ? "user:admin" : `user:${state.user?.sub || "unknown"}` });
|
|
67
|
+
state.requestedBy = requestActor(state);
|
|
68
|
+
const requiredScope = requiredScopeFor(url.pathname, scopeRoutes);
|
|
69
|
+
const scopeAllowed = !requiredScope || await hasScope(env, state.user, requiredScope, { who: requestActor(state) });
|
|
70
|
+
if (!scopeAllowed || (authorize && state.user.auth_strategy !== "http_basic" && !(await authorize({ request, url, user: state.user, env, ctx, state })))) {
|
|
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
|
+
}
|
|
73
|
+
const platformResponse = await authorizationApi(request, env, url, state);
|
|
74
|
+
return platformResponse || next(request);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function requiredScopeFor(pathname, routes) {
|
|
78
|
+
const route = routes.find((entry) => typeof entry.match === "function" ? entry.match(pathname) : pathname === entry.path || pathname.startsWith(String(entry.path || "") + "/"));
|
|
79
|
+
return route && route.scope ? route.scope : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function authorizationApi(request, env, url, state) {
|
|
83
|
+
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);
|
|
85
|
+
if (!platformPath) return null;
|
|
86
|
+
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
|
+
if (url.pathname === "/api/admin/users" && request.method === "GET") return Response.json({ users: await listAuthorizationUsers(env, { who: requestActor(state) }) });
|
|
88
|
+
if (url.pathname === "/api/admin/scopes" && request.method === "GET") return Response.json({ scopes: await listAuthorizationScopes(env, { who: requestActor(state) }) });
|
|
89
|
+
if (url.pathname === "/api/admin/status" && request.method === "GET") return Response.json({ features: await listFeatureHealth(env, { who: requestActor(state) }) });
|
|
90
|
+
if (url.pathname === "/api/admin/healthchecks" && request.method === "GET") return Response.json({ healthchecks: await listHealthchecks(env, { who: requestActor(state) }) });
|
|
91
|
+
if (url.pathname === "/api/admin/circuit-breakers" && request.method === "GET") return Response.json({ circuit_breakers: await listCircuitBreakers(env, { who: requestActor(state) }) });
|
|
92
|
+
const healthcheckMatch = url.pathname.match(/\/api\/admin\/healthchecks\/([^/]+)$/);
|
|
93
|
+
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 }); }
|
|
94
|
+
const breakerMatch = url.pathname.match(/\/api\/admin\/circuit-breakers\/([^/]+)$/);
|
|
95
|
+
if (breakerMatch && request.method === "GET") return Response.json({ circuit_breaker: await getCircuitBreaker(env, decodeURIComponent(breakerMatch[1]), { who: requestActor(state) }) });
|
|
96
|
+
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 }); }
|
|
97
|
+
if (grantsMatch && request.method === "GET") return Response.json({ grants: await listUserGrants(env, decodeURIComponent(grantsMatch[1]), { who: requestActor(state) }) });
|
|
98
|
+
if (grantsMatch && request.method === "PUT") {
|
|
99
|
+
const body = await request.json().catch(() => null);
|
|
100
|
+
if (!body || !Array.isArray(body.scopes)) return Response.json({ error: "scopes must be an array" }, { status: 400 });
|
|
101
|
+
const grants = await replaceUserGrants(env, decodeURIComponent(grantsMatch[1]), body.scopes, state.authUser && state.authUser.id, { who: requestActor(state) });
|
|
102
|
+
return Response.json({ grants });
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isAdminPath(pathname) {
|
|
108
|
+
return pathname === "/admin" || pathname.startsWith("/admin/") || pathname === "/api/admin" || pathname.startsWith("/api/admin/");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function basicUser(request, env) {
|
|
112
|
+
const token = String(env?.ADMIN_TOKEN || env?.admin_token || "");
|
|
113
|
+
if (!token) return null;
|
|
114
|
+
const header = request.headers.get("Authorization") || "";
|
|
115
|
+
if (!header.toLowerCase().startsWith("basic ")) return null;
|
|
116
|
+
let decoded;
|
|
117
|
+
try { decoded = atob(header.slice(6).trim()); } catch { return null; }
|
|
118
|
+
const separator = decoded.indexOf(":");
|
|
119
|
+
if (separator < 0) return null;
|
|
120
|
+
if (!constantTimeEqual(decoded.slice(0, separator), "admin") || !constantTimeEqual(decoded.slice(separator + 1), token)) return null;
|
|
121
|
+
return { sub: "basic:admin", email: "", name: "admin", roles: ["admin"], auth_strategy: "http_basic" };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function adminUnauthorized(request) {
|
|
125
|
+
const headers = { "Cache-Control": "no-store", "WWW-Authenticate": "Basic realm=\"admin\", charset=\"UTF-8\"" };
|
|
126
|
+
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 });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function oauthUnauthorized(request, url) {
|
|
130
|
+
if (url.pathname.startsWith("/api/")) return Response.json({ error: "Authentication is required." }, { status: 401, headers: { "Cache-Control": "no-store" } });
|
|
131
|
+
return Response.redirect(url.origin + "/auth/login?return_to=" + encodeURIComponent(safeReturnTo(url.pathname + url.search)), 302);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function safeReturnTo(value) { return value?.startsWith("/") && !value.startsWith("//") && !value.startsWith("/auth/") ? value : "/"; }
|
|
135
|
+
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; }
|
|
136
|
+
|
|
42
137
|
export function validateBoot(env, { bindings = [], required = [] } = {}) {
|
|
43
138
|
const missingBindings = bindings.filter((name) => !env?.[name]);
|
|
44
139
|
const missingValues = required.filter((name) => !env?.[name] || String(env[name]).startsWith("replace-with-"));
|
package/src/ui/index.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
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
|
+
export class CfAdminShell extends HTMLElement {
|
|
4
|
+
connectedCallback() {
|
|
5
|
+
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
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class CfScopeBadge extends HTMLElement {
|
|
11
|
+
connectedCallback() { this.attachShadow({ mode: "open" }).innerHTML = `<style>${styles}.badge{display:inline-block;border:1px solid var(--cf-ui-border);border-radius:999px;padding:.15rem .5rem;color:var(--cf-ui-primary);background:var(--cf-ui-surface);font-size:.85rem}</style><span class="badge" part="badge"></span>`; this.shadowRoot.querySelector(".badge").textContent = this.getAttribute("scope") || this.textContent || ""; }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class CfUserManagement extends HTMLElement {
|
|
15
|
+
async connectedCallback() { this.attachShadow({ mode: "open" }).innerHTML = `<style>${styles}</style><section class="card" part="panel"><h2>User access</h2><p class="status" part="status">Loading users and scopes…</p><table hidden part="table"><thead><tr><th>User</th><th>Scopes</th><th>Save</th></tr></thead><tbody></tbody></table></section>`; try { await this.load(); } catch (error) { this.status.textContent = error.message || "Unable to load access data."; } }
|
|
16
|
+
get status() { return this.shadowRoot.querySelector(".status"); }
|
|
17
|
+
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"}`; }
|
|
18
|
+
row(user, scopes) { const row = document.createElement("tr"); const identity = document.createElement("td"); identity.textContent = `${user.display_name || user.email || "Unnamed user"} (${user.email || "no email"})`; const grants = document.createElement("td"); const list = document.createElement("div"); list.className = "scope-list"; for (const scope of scopes) { const label = document.createElement("label"); const input = document.createElement("input"); input.type = "checkbox"; input.value = scope.name; input.checked = Boolean((user.scopes || []).includes(scope.name)); label.append(input, document.createTextNode(scope.label || scope.name)); list.append(label); } grants.append(list); const action = document.createElement("td"); const button = document.createElement("button"); button.textContent = "Save"; button.addEventListener("click", async () => { button.disabled = true; const selected = [...list.querySelectorAll("input:checked")].map((input) => input.value); const response = await fetch(`/api/admin/users/${encodeURIComponent(user.id)}/scopes`, { method: "PUT", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scopes: selected }) }); button.disabled = false; this.status.textContent = response.ok ? "Access saved." : "Unable to save access."; }); action.append(button); row.append(identity, grants, action); return row; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!customElements.get("cf-admin-shell")) customElements.define("cf-admin-shell", CfAdminShell);
|
|
22
|
+
if (!customElements.get("cf-scope-badge")) customElements.define("cf-scope-badge", CfScopeBadge);
|
|
23
|
+
if (!customElements.get("cf-user-management")) customElements.define("cf-user-management", CfUserManagement);
|
|
24
|
+
|
|
25
|
+
export class CfScopeCatalog extends HTMLElement {
|
|
26
|
+
async connectedCallback() {
|
|
27
|
+
this.attachShadow({ mode: "open" }).innerHTML = `<style>${styles}</style><section class="card" part="panel"><h2>Available scopes</h2><p class="status" part="status">Loading scopes…</p><div class="scope-list" hidden part="list"></div></section>`;
|
|
28
|
+
try {
|
|
29
|
+
const response = await fetch("/api/admin/scopes", { credentials: "same-origin" });
|
|
30
|
+
if (!response.ok) throw new Error("Unable to load scopes.");
|
|
31
|
+
const scopes = (await response.json()).scopes || [];
|
|
32
|
+
const list = this.shadowRoot.querySelector(".scope-list");
|
|
33
|
+
list.replaceChildren(...scopes.map((scope) => {
|
|
34
|
+
const item = document.createElement("div");
|
|
35
|
+
const badge = document.createElement("cf-scope-badge");
|
|
36
|
+
badge.setAttribute("scope", scope.name);
|
|
37
|
+
const description = document.createElement("span");
|
|
38
|
+
description.textContent = scope.description || scope.label || scope.name;
|
|
39
|
+
item.append(badge, description);
|
|
40
|
+
return item;
|
|
41
|
+
}));
|
|
42
|
+
list.hidden = false;
|
|
43
|
+
this.shadowRoot.querySelector(".status").textContent = `${scopes.length} scope${scopes.length === 1 ? "" : "s"}`;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
this.shadowRoot.querySelector(".status").textContent = error.message || "Unable to load scopes.";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!customElements.get("cf-scope-catalog")) customElements.define("cf-scope-catalog", CfScopeCatalog);
|
|
51
|
+
if (!customElements.get("cf-scope-catalog")) customElements.define("cf-scope-catalog", CfScopeCatalog);
|
|
52
|
+
|
|
53
|
+
export class CfHealthcheckCatalog extends HTMLElement {
|
|
54
|
+
async connectedCallback() { this.attachShadow({ mode: "open" }).innerHTML = `<style>${styles}</style><section class="card"><h2>Healthchecks</h2><p class="status">Loading…</p><div class="list" hidden></div></section>`; try { const response = await fetch("/api/admin/healthchecks", { credentials: "same-origin" }); if (!response.ok) throw new Error("Unable to load healthchecks."); const items = (await response.json()).healthchecks || []; const list = this.shadowRoot.querySelector(".list"); list.replaceChildren(...items.map((item) => { const row = document.createElement("div"); row.textContent = `${item.display_name} — ${item.feature}/${item.component}: ${item.state}`; list.append(row); return row; })); list.hidden = false; this.shadowRoot.querySelector(".status").textContent = `${items.length} healthcheck${items.length === 1 ? "" : "s"}`; } catch (error) { this.shadowRoot.querySelector(".status").textContent = error.message; } }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class CfCircuitBreakerCatalog extends HTMLElement {
|
|
58
|
+
async connectedCallback() { this.attachShadow({ mode: "open" }).innerHTML = `<style>${styles}</style><section class="card"><h2>Circuit breakers</h2><p class="status">Loading…</p><div class="list" hidden></div></section>`; try { const response = await fetch("/api/admin/circuit-breakers", { credentials: "same-origin" }); if (!response.ok) throw new Error("Unable to load circuit breakers."); const items = (await response.json()).circuit_breakers || []; const list = this.shadowRoot.querySelector(".list"); list.replaceChildren(...items.map((item) => { const row = document.createElement("div"); row.textContent = `${item.display_name} — ${item.feature}/${item.name}: ${item.state}`; list.append(row); return row; })); list.hidden = false; this.shadowRoot.querySelector(".status").textContent = `${items.length} circuit breaker${items.length === 1 ? "" : "s"}`; } catch (error) { this.shadowRoot.querySelector(".status").textContent = error.message; } }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!customElements.get("cf-healthcheck-catalog")) customElements.define("cf-healthcheck-catalog", CfHealthcheckCatalog);
|
|
62
|
+
if (!customElements.get("cf-circuit-breaker-catalog")) customElements.define("cf-circuit-breaker-catalog", CfCircuitBreakerCatalog);
|