@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.
Files changed (52) hide show
  1. package/CONTRACT.md +24 -7
  2. package/README.md +59 -25
  3. package/changelog.md +15 -0
  4. package/migrations/0006_jobs.sql +38 -0
  5. package/package.json +15 -5
  6. package/src/admin/index.js +1 -0
  7. package/src/admin/routes.js +64 -0
  8. package/src/api/client.js +8 -0
  9. package/src/api/contracts.js +24 -0
  10. package/src/api/index.js +7 -0
  11. package/src/api/jobs.js +37 -0
  12. package/src/api/testing.js +13 -0
  13. package/src/app.js +5 -0
  14. package/src/auth/constants.js +9 -0
  15. package/src/auth/encoding.js +6 -0
  16. package/src/auth/groups.js +5 -0
  17. package/src/auth/impersonation.js +15 -0
  18. package/src/auth/index.js +7 -0
  19. package/src/auth/scopes.js +32 -0
  20. package/src/auth/subscriptions.js +25 -0
  21. package/src/auth/tenants.js +34 -0
  22. package/src/auth/users.js +31 -0
  23. package/src/{core.js → core/circuits.js} +51 -98
  24. package/src/core/constants.js +5 -0
  25. package/src/core/d1.js +54 -0
  26. package/src/core/event-hub.js +29 -0
  27. package/src/core/events.js +63 -0
  28. package/src/core/identity.js +39 -0
  29. package/src/core/index.js +7 -0
  30. package/src/core/jobs.js +185 -0
  31. package/src/core/security.js +71 -0
  32. package/src/data/context.js +16 -0
  33. package/src/data/index.js +3 -0
  34. package/src/data/reader.js +63 -0
  35. package/src/data/resources.js +42 -0
  36. package/src/index.js +10 -289
  37. package/src/repository.js +63 -0
  38. package/src/runtime/features.js +17 -0
  39. package/src/runtime/health.js +17 -0
  40. package/src/runtime/worker.js +45 -0
  41. package/src/ui/index.js +1 -87
  42. package/src/ui/react/admin-access.jsx +29 -0
  43. package/src/ui/react/admin-catalogs.jsx +39 -0
  44. package/src/ui/react/admin-shell.jsx +17 -0
  45. package/src/ui/react/foundation.jsx +40 -0
  46. package/src/ui/react/index.jsx +6 -0
  47. package/src/ui/react/jobs.jsx +63 -0
  48. package/src/ui/react/live-events.jsx +55 -0
  49. package/src/ui/styles.css +34 -0
  50. package/src/authorization.js +0 -188
  51. package/src/data.js +0 -239
  52. package/src/ui/groups.js +0 -2
@@ -0,0 +1,29 @@
1
+ import { useState } from "react";
2
+ import { apiJson } from "../../api/client.js";
3
+ import { ResourceState, useApiResource } from "./foundation.jsx";
4
+
5
+ export function UserManagement() {
6
+ const users = useApiResource("/api/admin/users");
7
+ const scopes = useApiResource("/api/admin/scopes");
8
+ const tenants = useApiResource("/api/admin/tenants");
9
+ const reload = () => Promise.all([users.reload(), scopes.reload(), tenants.reload()]);
10
+ const options = { scopes: scopes.value?.scopes || [], tenants: tenants.value?.tenants || [] };
11
+ return <section className="cf-ui-card"><h1>User access</h1><ResourceState loading={users.loading || scopes.loading || tenants.loading} error={users.error || scopes.error || tenants.error} empty="No users registered.">{(users.value?.users || []).length ? <div className="cf-ui-table-wrap"><table><thead><tr><th>User</th><th>Tenants</th><th>Scopes</th><th>Save</th></tr></thead><tbody>{users.value.users.map((user) => <UserRow key={user.id} user={user} options={options} onSaved={reload} />)}</tbody></table></div> : null}</ResourceState></section>;
12
+ }
13
+
14
+ function UserRow({ user, options, onSaved }) {
15
+ const [selectedScopes, setSelectedScopes] = useStateSet(user.scopes);
16
+ const [selectedTenants, setSelectedTenants] = useStateSet((user.tenants || []).map((tenant) => tenant.id));
17
+ const [saving, setSaving] = useState(false);
18
+ const save = async () => { setSaving(true); try { await Promise.all([apiJson(`/api/admin/users/${encodeURIComponent(user.id)}/scopes`, { scopes: [...selectedScopes] }, { method: "PUT" }), apiJson(`/api/admin/users/${encodeURIComponent(user.id)}/tenants`, { tenants: [...selectedTenants] }, { method: "PUT" })]); await onSaved(); } finally { setSaving(false); } };
19
+ return <tr><th scope="row">{user.display_name || user.email || "Unnamed user"}<small>{user.email || "No email"}</small></th><td><CheckList items={options.tenants} selected={selectedTenants} onChange={setSelectedTenants} valueKey="id" labelKey="name" /></td><td><CheckList items={options.scopes} selected={selectedScopes} onChange={setSelectedScopes} valueKey="name" labelKey="label" /></td><td><button type="button" disabled={saving} onClick={save}>{saving ? "Saving…" : "Save"}</button></td></tr>;
20
+ }
21
+
22
+ function CheckList({ items, selected, onChange, valueKey, labelKey }) {
23
+ return <div className="cf-ui-check-list">{items.map((item) => { const value = item[valueKey]; return <label key={value}><input type="checkbox" checked={selected.has(value)} onChange={(event) => { const next = new Set(selected); if (event.target.checked) next.add(value); else next.delete(value); onChange(next); }} />{item[labelKey] || value}</label>; })}</div>;
24
+ }
25
+
26
+ function useStateSet(initial = []) {
27
+ const [value, setValue] = useState(() => new Set(initial || []));
28
+ return [value, setValue];
29
+ }
@@ -0,0 +1,39 @@
1
+ import { apiJson } from "../../api/client.js";
2
+ import { useLiveEvent } from "./live-events.jsx";
3
+ import { ResourceState, StatusBadge, useApiResource, useMutation } from "./foundation.jsx";
4
+
5
+ export function FeatureCatalog() {
6
+ const resource = useApiResource("/api/admin/features");
7
+ useLiveEvent((event) => ["healthcheck", "circuit"].some((word) => String(event.what || "").includes(word)), resource.reload);
8
+ const features = resource.value?.features || [];
9
+ return <section className="cf-ui-card"><header><h1>Installed features</h1><p>Runtime modules, package versions, healthchecks, and circuit breakers.</p></header><ResourceState {...resource} empty="No features registered.">{features.length ? <div className="cf-ui-table-wrap"><table><thead><tr><th>Feature</th><th>Package/version</th><th>Health</th><th>Roll-up</th><th>Healthchecks</th><th>Breakers</th></tr></thead><tbody>{features.map((item) => <tr key={item.feature}><th scope="row">{item.display_name || item.feature}<small>{item.feature}</small></th><td>{item.package_name || "Unknown package"}<br />{item.version || "Unknown version"}</td><td><StatusBadge state={item.health} /></td><td>{item.circuit_breaker ? <StatusBadge state={item.circuit_breaker.state} /> : "None"}</td><td><CatalogList items={item.healthchecks} /></td><td><CatalogList items={item.circuit_breakers} /></td></tr>)}</tbody></table></div> : null}</ResourceState></section>;
10
+ }
11
+
12
+ export function HealthcheckCatalog() {
13
+ const resource = useApiResource("/api/admin/healthchecks");
14
+ const items = resource.value?.healthchecks || [];
15
+ return <section className="cf-ui-card"><h1>Healthchecks</h1><ResourceState {...resource} empty="No healthchecks registered.">{items.length ? <div className="cf-ui-card-grid">{items.map((item) => <article className="cf-ui-card" key={item.id}><h2>{item.display_name}</h2><StatusBadge state={item.state} /><p>{item.feature} / {item.component}</p></article>)}</div> : null}</ResourceState></section>;
16
+ }
17
+
18
+ export function CircuitBreakerCatalog() {
19
+ const resource = useApiResource("/api/admin/circuit-breakers");
20
+ const items = resource.value?.circuit_breakers || [];
21
+ const update = async (item, state) => { await apiJson(`/api/admin/circuit-breakers/${encodeURIComponent(item.id)}`, { state }, { method: "PUT" }); await resource.reload(); };
22
+ return <section className="cf-ui-card"><h1>Circuit breakers</h1><ResourceState {...resource} empty="No circuit breakers registered.">{items.length ? <div className="cf-ui-list">{items.map((item) => <article className="cf-ui-breaker" key={item.id}><div><h2>{item.display_name}</h2><small>{item.feature}/{item.name}</small></div><StatusBadge state={item.state} /><div className="cf-ui-actions">{["off", "tripped", "on"].map((state) => <button type="button" key={state} disabled={item.state === state} onClick={() => update(item, state)}>{state}</button>)}</div></article>)}</div> : null}</ResourceState></section>;
23
+ }
24
+
25
+ export function ScopeCatalog() {
26
+ const resource = useApiResource("/api/admin/scopes");
27
+ const scopes = resource.value?.scopes || [];
28
+ return <section className="cf-ui-card"><h1>Scopes</h1><ResourceState {...resource} empty="No scopes registered.">{scopes.length ? <ul className="cf-ui-list">{scopes.map((scope) => <li key={scope.name}><strong>{scope.label || scope.name}</strong><small>{scope.name}</small><p>{scope.description}</p></li>)}</ul> : null}</ResourceState></section>;
29
+ }
30
+
31
+ export function GroupCatalog() {
32
+ const resource = useApiResource("/api/admin/groups");
33
+ const groups = resource.value?.groups || [];
34
+ return <section className="cf-ui-card"><h1>Groups</h1><ResourceState {...resource} empty="No groups registered.">{groups.length ? <ul className="cf-ui-list">{groups.map((group) => <li key={group.id || group.name}><strong>{group.display_name || group.name}</strong><small>{group.name}</small></li>)}</ul> : null}</ResourceState></section>;
35
+ }
36
+
37
+ function CatalogList({ items = [] }) {
38
+ return items.length ? <ul className="cf-ui-inline-list">{items.map((item) => <li key={item.id || item.name}><strong>{item.display_name || item.name}</strong>: <StatusBadge state={item.state} /></li>)}</ul> : <span>None registered</span>;
39
+ }
@@ -0,0 +1,17 @@
1
+ const SYSTEM_LINKS = [
2
+ ["Overview", "/admin", "home"],
3
+ ["Users", "/admin/users", "users"],
4
+ ["Scopes", "/admin/scopes", "scopes"],
5
+ ["Groups", "/admin/groups", "groups"],
6
+ ["Features", "/admin/features", "features"],
7
+ ["Healthchecks", "/admin/healthchecks", "healthchecks"],
8
+ ["Circuit breakers", "/admin/circuit-breakers", "circuit-breakers"],
9
+ ];
10
+
11
+ export function AdminShell({ children, active = "", applicationLinks = [], title = "Administration" }) {
12
+ return <div className="cf-ui-admin-shell"><aside className="cf-ui-admin-nav"><p className="cf-ui-admin-title">{title}</p><nav aria-label="Administration"><section><p className="cf-ui-nav-label">Application</p>{applicationLinks.map((link) => <AdminLink key={link.href} link={link} active={active} />)}</section><section><p className="cf-ui-nav-label">System</p>{SYSTEM_LINKS.map(([label, href, key]) => <AdminLink key={href} link={{ label, href, key }} active={active} />)}</section></nav></aside><main className="cf-ui-admin-content">{children}</main></div>;
13
+ }
14
+
15
+ export function AdminLink({ link, active }) {
16
+ return <a href={link.href} aria-current={link.key === active ? "page" : undefined}>{link.label}</a>;
17
+ }
@@ -0,0 +1,40 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import { apiFetch, apiJson } from "../../api/client.js";
3
+
4
+ export function useApiResource(path, { enabled = true, initialValue = null } = {}) {
5
+ const [value, setValue] = useState(initialValue);
6
+ const [loading, setLoading] = useState(Boolean(enabled));
7
+ const [error, setError] = useState(null);
8
+ const reload = useCallback(async () => {
9
+ if (!enabled) return null;
10
+ setLoading(true);
11
+ try { const next = await apiFetch(path); setValue(next); setError(null); return next; }
12
+ catch (cause) { setError(cause); throw cause; }
13
+ finally { setLoading(false); }
14
+ }, [enabled, path]);
15
+ useEffect(() => { reload().catch(() => {}); }, [reload]);
16
+ return { value, setValue, loading, error, reload };
17
+ }
18
+
19
+ export function useMutation(path, { method = "POST" } = {}) {
20
+ const [loading, setLoading] = useState(false);
21
+ const [error, setError] = useState(null);
22
+ const mutate = useCallback(async (payload = {}) => {
23
+ setLoading(true);
24
+ try { const result = await apiJson(path, payload, { method }); setError(null); return result; }
25
+ catch (cause) { setError(cause); throw cause; }
26
+ finally { setLoading(false); }
27
+ }, [method, path]);
28
+ return { mutate, loading, error };
29
+ }
30
+
31
+ export function ResourceState({ loading, error, empty = "Nothing to show.", children }) {
32
+ if (loading) return <p className="cf-ui-status" role="status">Loading…</p>;
33
+ if (error) return <p className="cf-ui-error" role="alert">{error.message || "Unable to load data."}</p>;
34
+ return children || <p className="cf-ui-status">{empty}</p>;
35
+ }
36
+
37
+ export function StatusBadge({ state }) {
38
+ const value = String(state || "unknown").toLowerCase();
39
+ return <span className={`cf-ui-status-badge cf-ui-status-${value}`}>{value}</span>;
40
+ }
@@ -0,0 +1,6 @@
1
+ export * from "./foundation.jsx";
2
+ export * from "./live-events.jsx";
3
+ export * from "./jobs.jsx";
4
+ export * from "./admin-shell.jsx";
5
+ export * from "./admin-catalogs.jsx";
6
+ export * from "./admin-access.jsx";
@@ -0,0 +1,63 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
+ import { apiFetch } from "../../api/client.js";
3
+ import { useLiveEvent, useLiveEvents } from "./live-events.jsx";
4
+
5
+ function jobQuery(filters) {
6
+ const query = new URLSearchParams();
7
+ for (const [key, value] of Object.entries(filters || {})) if (value !== undefined && value !== null && value !== "") query.set(key, value);
8
+ return query.toString() ? `/api/jobs?${query}` : "/api/jobs";
9
+ }
10
+
11
+ export function useJobs(filters = {}) {
12
+ const key = useMemo(() => JSON.stringify(filters), [filters]);
13
+ const path = useMemo(() => jobQuery(JSON.parse(key)), [key]);
14
+ const [jobs, setJobs] = useState([]);
15
+ const [loading, setLoading] = useState(true);
16
+ const [error, setError] = useState(null);
17
+ const { connection } = useLiveEvents();
18
+ const reload = useCallback(async () => {
19
+ setLoading(true);
20
+ try { const result = await apiFetch(path); setJobs(result.jobs || []); setError(null); }
21
+ catch (cause) { setError(cause); }
22
+ finally { setLoading(false); }
23
+ }, [path]);
24
+ useEffect(() => { reload(); }, [reload]);
25
+ useEffect(() => { if (connection === "open") reload(); }, [connection, reload]);
26
+ useLiveEvent((event) => String(event.what || "").startsWith("job."), reload);
27
+ return { jobs, loading, error, reload };
28
+ }
29
+
30
+ export function useJob(jobId) {
31
+ const [job, setJob] = useState(null);
32
+ const [loading, setLoading] = useState(Boolean(jobId));
33
+ const [error, setError] = useState(null);
34
+ const reload = useCallback(async () => {
35
+ if (!jobId) return null;
36
+ setLoading(true);
37
+ try { const result = await apiFetch(`/api/jobs/${encodeURIComponent(jobId)}`); setJob(result.job); setError(null); return result.job; }
38
+ catch (cause) { setError(cause); throw cause; }
39
+ finally { setLoading(false); }
40
+ }, [jobId]);
41
+ useEffect(() => { reload().catch(() => {}); }, [reload]);
42
+ useLiveEvent((event) => event.details?.jobId === jobId, () => { reload().catch(() => {}); });
43
+ return { job, loading, error, reload };
44
+ }
45
+
46
+ export function useJobNotifications({ limit = 20 } = {}) {
47
+ const { jobs, loading, error, reload } = useJobs({ limit });
48
+ const [dismissed, setDismissed] = useState(() => new Set());
49
+ const notifications = jobs.filter((job) => ["succeeded", "failed", "cancelled"].includes(job.status) && !dismissed.has(job.id));
50
+ const dismiss = useCallback((jobId) => setDismissed((current) => new Set([...current, jobId])), []);
51
+ return { notifications, loading, error, dismiss, reload };
52
+ }
53
+
54
+ export function JobNotificationBanner({ job, onDismiss }) {
55
+ const failed = job.status === "failed";
56
+ const cancelled = job.status === "cancelled";
57
+ const message = failed ? `${job.type} failed.` : cancelled ? `${job.type} was cancelled.` : `${job.type} finished.`;
58
+ return <aside className={`cf-ui-job-banner cf-ui-job-${job.status}`} role="status" aria-live="polite"><span>{message}</span><button type="button" onClick={() => onDismiss?.(job.id)} aria-label="Dismiss notification">Dismiss</button></aside>;
59
+ }
60
+
61
+ export function JobNotificationList({ notifications, onDismiss }) {
62
+ return <>{notifications.map((job) => <JobNotificationBanner key={job.id} job={job} onDismiss={onDismiss} />)}</>;
63
+ }
@@ -0,0 +1,55 @@
1
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
2
+
3
+ const LiveEventsContext = createContext(null);
4
+
5
+ export function LiveEventsProvider({ children, url = "/api/events", enabled = true }) {
6
+ const listeners = useRef(new Set());
7
+ const [connection, setConnection] = useState(enabled ? "connecting" : "disabled");
8
+ const subscribe = useCallback((listener) => { listeners.current.add(listener); return () => listeners.current.delete(listener); }, []);
9
+ useEffect(() => {
10
+ if (!enabled || typeof WebSocket === "undefined") { setConnection(enabled ? "unsupported" : "disabled"); return undefined; }
11
+ let socket;
12
+ let timer;
13
+ let stopped = false;
14
+ let attempts = 0;
15
+ const connect = () => {
16
+ if (stopped) return;
17
+ const target = new URL(url, window.location.href);
18
+ target.protocol = target.protocol === "https:" ? "wss:" : "ws:";
19
+ socket = new WebSocket(target);
20
+ socket.onopen = () => { attempts = 0; setConnection("open"); };
21
+ socket.onmessage = ({ data }) => {
22
+ try {
23
+ const payload = JSON.parse(data);
24
+ const event = payload.event || payload;
25
+ if (event?.id) for (const listener of listeners.current) listener(event);
26
+ } catch { /* Ignore malformed transport messages. */ }
27
+ };
28
+ socket.onerror = () => setConnection("error");
29
+ socket.onclose = () => {
30
+ if (stopped) return;
31
+ setConnection("reconnecting");
32
+ const delay = Math.min(30000, 500 * (2 ** attempts++));
33
+ timer = setTimeout(connect, delay);
34
+ };
35
+ };
36
+ connect();
37
+ return () => { stopped = true; clearTimeout(timer); socket?.close(); setConnection("closed"); };
38
+ }, [enabled, url]);
39
+ const value = useMemo(() => ({ connection, subscribe }), [connection, subscribe]);
40
+ return <LiveEventsContext.Provider value={value}>{children}</LiveEventsContext.Provider>;
41
+ }
42
+
43
+ export function useLiveEvents() {
44
+ const value = useContext(LiveEventsContext);
45
+ return value || { connection: "unavailable", subscribe: () => () => {} };
46
+ }
47
+
48
+ export function useLiveEvent(matcher, callback) {
49
+ const { subscribe } = useLiveEvents();
50
+ const matcherRef = useRef(matcher);
51
+ const callbackRef = useRef(callback);
52
+ matcherRef.current = matcher;
53
+ callbackRef.current = callback;
54
+ useEffect(() => subscribe((event) => { if (!matcherRef.current || matcherRef.current(event)) callbackRef.current(event); }), [subscribe]);
55
+ }
package/src/ui/styles.css CHANGED
@@ -7,3 +7,37 @@
7
7
  --cf-ui-primary: #2f6f52;
8
8
  --cf-ui-danger: #a33b32;
9
9
  }
10
+
11
+ .cf-ui-admin-shell { display: grid; grid-template-columns: 220px minmax(0, 1fr); min-height: 100%; gap: 1.5rem; color: var(--cf-ui-text); font: 15px/1.45 system-ui, sans-serif; }
12
+ .cf-ui-admin-nav { padding: 1rem; border-right: 1px solid var(--cf-ui-border); }
13
+ .cf-ui-admin-nav nav, .cf-ui-admin-nav section { display: grid; gap: .2rem; }
14
+ .cf-ui-admin-nav section + section { margin-top: 1.5rem; }
15
+ .cf-ui-admin-nav a { padding: .5rem .65rem; border-radius: .45rem; color: inherit; text-decoration: none; }
16
+ .cf-ui-admin-nav a:hover, .cf-ui-admin-nav a[aria-current="page"] { background: var(--cf-ui-surface); color: var(--cf-ui-primary); }
17
+ .cf-ui-admin-title, .cf-ui-nav-label { margin: 0 0 .5rem; font-weight: 700; }
18
+ .cf-ui-nav-label { color: var(--cf-ui-muted); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; }
19
+ .cf-ui-admin-content { min-width: 0; padding: 2rem 1rem; }
20
+ .cf-ui-card { padding: 1rem; border: 1px solid var(--cf-ui-border); border-radius: .65rem; background: var(--cf-ui-bg); }
21
+ .cf-ui-card + .cf-ui-card { margin-top: 1rem; }
22
+ .cf-ui-card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; }
23
+ .cf-ui-table-wrap { overflow-x: auto; }
24
+ .cf-ui-card table { width: 100%; border-collapse: collapse; }
25
+ .cf-ui-card th, .cf-ui-card td { padding: .7rem; border-bottom: 1px solid var(--cf-ui-border); text-align: left; vertical-align: top; }
26
+ .cf-ui-card th small, .cf-ui-card td small, .cf-ui-list small { display: block; color: var(--cf-ui-muted); font-weight: 400; }
27
+ .cf-ui-status, .cf-ui-card p { color: var(--cf-ui-muted); }
28
+ .cf-ui-error { color: var(--cf-ui-danger); }
29
+ .cf-ui-status-badge { font-weight: 700; text-transform: capitalize; }
30
+ .cf-ui-status-green, .cf-ui-status-on, .cf-ui-job-succeeded { color: #26734d; }
31
+ .cf-ui-status-yellow { color: #9a6b00; }
32
+ .cf-ui-status-red, .cf-ui-status-tripped, .cf-ui-job-failed { color: var(--cf-ui-danger); }
33
+ .cf-ui-status-off, .cf-ui-status-cancelled { color: var(--cf-ui-muted); }
34
+ .cf-ui-list, .cf-ui-inline-list, .cf-ui-check-list { display: grid; gap: .5rem; margin: 0; padding: 0; list-style: none; }
35
+ .cf-ui-inline-list { gap: .2rem; }
36
+ .cf-ui-breaker { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: .75rem; padding: .75rem 0; border-bottom: 1px solid var(--cf-ui-border); }
37
+ .cf-ui-actions { display: flex; flex-wrap: wrap; gap: .35rem; }
38
+ .cf-ui-card button { padding: .45rem .65rem; border: 1px solid var(--cf-ui-border); border-radius: .4rem; background: var(--cf-ui-bg); color: inherit; cursor: pointer; }
39
+ .cf-ui-card button:disabled { cursor: wait; opacity: .6; }
40
+ .cf-ui-check-list label { display: flex; align-items: center; gap: .35rem; }
41
+ .cf-ui-job-banner { display: flex; align-items: center; justify-content: space-between; gap: 1rem; position: fixed; right: 1rem; bottom: 1rem; z-index: 10; max-width: min(32rem, calc(100vw - 2rem)); padding: .8rem 1rem; border: 1px solid var(--cf-ui-border); border-radius: .6rem; background: var(--cf-ui-bg); box-shadow: 0 .5rem 2rem #0002; }
42
+ .cf-ui-job-banner button { border: 0; background: transparent; color: inherit; cursor: pointer; text-decoration: underline; }
43
+ @media (max-width: 720px) { .cf-ui-admin-shell { grid-template-columns: 1fr; } .cf-ui-admin-nav { border-right: 0; border-bottom: 1px solid var(--cf-ui-border); } .cf-ui-breaker { grid-template-columns: 1fr; } }
@@ -1,188 +0,0 @@
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
- export const DEFAULT_TENANT_ID = "easley-family";
7
- export const DEFAULT_TENANT_NAME = "Easley Family";
8
-
9
- export class SubscriptionError extends Error {
10
- constructor(message) { super(message); this.name = "SubscriptionError"; }
11
- }
12
-
13
- export function normalizeScopes(scopes = []) {
14
- return scopes.map((scope) => typeof scope === "string" ? { name: scope, label: scope, description: "", system: false } : scope)
15
- .filter((scope) => scope && /^[a-z0-9]+(?::[a-z0-9-]+)+$/.test(String(scope.name || "")))
16
- .map((scope) => ({ name: String(scope.name), label: String(scope.label || scope.name), description: String(scope.description || ""), system: Boolean(scope.system) }));
17
- }
18
-
19
- export async function ensureScopes(env, scopes = [], { who = "system:read" } = {}) {
20
- if (!env?.DB) return;
21
- const db = createD1(env, { who });
22
- for (const scope of normalizeScopes(scopes)) {
23
- 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();
24
- }
25
- }
26
-
27
- export async function ensureUser(env, user, { who = "system:read" } = {}) {
28
- if (!env?.DB || !user?.sub) return null;
29
- const db = createD1(env, { who });
30
- const provider = String(user.auth_strategy || "oauth");
31
- const subject = String(user.sub);
32
- const email = String(user.email || "").trim().toLowerCase();
33
- const existing = await db.prepare(`SELECT * FROM ${AUTH_USER_TABLE} WHERE provider=? AND subject=?`).bind(provider, subject).first();
34
- const bootstrap = new Set(String(env.AUTH_ADMIN_EMAILS || env.ADMIN_EMAILS || "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean));
35
- const bootstrapAdmin = user.email_verified === true && bootstrap.has(email);
36
- if (existing) {
37
- await db.prepare(`UPDATE ${AUTH_USER_TABLE} SET email=?,display_name=?,is_admin=CASE WHEN is_admin=1 OR ? THEN 1 ELSE 0 END,updated_at=CURRENT_TIMESTAMP WHERE id=?`).bind(email, String(user.name || email || subject), bootstrapAdmin ? 1 : 0, existing.id).run();
38
- await ensureDefaultTenantMembership(db, existing.id);
39
- return { ...existing, email, display_name: String(user.name || email || subject), is_admin: Boolean(existing.is_admin || bootstrapAdmin) };
40
- }
41
- const id = await stableId(`${provider}:${subject}`);
42
- await db.prepare(`INSERT INTO ${AUTH_USER_TABLE} (id,provider,subject,email,display_name,is_admin) VALUES (?,?,?,?,?,?) ON CONFLICT(provider,subject) DO NOTHING`).bind(id, provider, subject, email, String(user.name || email || subject), bootstrapAdmin ? 1 : 0).run();
43
- await ensureDefaultTenantMembership(db, id);
44
- return await db.prepare(`SELECT * FROM ${AUTH_USER_TABLE} WHERE id=?`).bind(id).first();
45
- }
46
-
47
- async function ensureDefaultTenantMembership(db, userId) {
48
- await db.batch([
49
- db.prepare("INSERT OR IGNORE INTO auth_tenants (id,name) VALUES (?,?)").bind(DEFAULT_TENANT_ID, DEFAULT_TENANT_NAME),
50
- db.prepare("INSERT OR IGNORE INTO auth_user_tenants (user_id,tenant_id) VALUES (?,?)").bind(userId, DEFAULT_TENANT_ID)
51
- ]);
52
- }
53
-
54
- export async function listUserTenants(env, userId, { who = "system:read" } = {}) {
55
- const db = createD1(env, { who });
56
- const { results } = await db.prepare(`SELECT t.id,t.name,t.created_at,t.updated_at FROM auth_tenants t JOIN auth_user_tenants ut ON ut.tenant_id=t.id WHERE ut.user_id=? ORDER BY t.name COLLATE NOCASE`).bind(userId).all();
57
- return results || [];
58
- }
59
-
60
- export async function listTenantSubscriptions(env, tenantId, { who = "system:read" } = {}) {
61
- const db = createD1(env, { who });
62
- const { results } = await db.prepare(`SELECT s.id,s.name,s.created_at,s.updated_at FROM auth_subscriptions s JOIN auth_tenant_subscriptions ts ON ts.subscription_id=s.id WHERE ts.tenant_id=? ORDER BY s.name COLLATE NOCASE`).bind(tenantId).all();
63
- return Promise.all((results || []).map(async (subscription) => ({ ...subscription, entitlements: await listSubscriptionEntitlements(env, subscription.id, { who }) })));
64
- }
65
-
66
- export async function listSubscriptionEntitlements(env, subscriptionId, { who = "system:read" } = {}) {
67
- const db = createD1(env, { who });
68
- const { results } = await db.prepare("SELECT entitlement,value_json FROM auth_subscription_entitlements WHERE subscription_id=? ORDER BY entitlement").bind(subscriptionId).all();
69
- return (results || []).map((row) => ({ entitlement: row.entitlement, value: parseJsonValue(row.value_json) }));
70
- }
71
-
72
- export function normalizeSubscriptionManifest(manifest = []) {
73
- return manifest.map((subscription) => ({
74
- id: String(subscription?.id || "").trim(),
75
- name: String(subscription?.name || subscription?.id || "").trim(),
76
- entitlements: Object.fromEntries(Object.entries(subscription?.entitlements || {}).map(([key, value]) => [String(key), value])),
77
- })).filter((subscription) => /^[a-z0-9][a-z0-9_-]*$/.test(subscription.id) && subscription.name);
78
- }
79
-
80
- export async function ensureSubscriptionManifest(env, manifest = [], { who = "system:update" } = {}) {
81
- if (!env?.DB) return;
82
- const db = createD1(env, { who });
83
- for (const subscription of normalizeSubscriptionManifest(manifest)) {
84
- await db.prepare("INSERT INTO auth_subscriptions (id,name) VALUES (?,?) ON CONFLICT(id) DO UPDATE SET name=excluded.name,updated_at=CURRENT_TIMESTAMP").bind(subscription.id, subscription.name).run();
85
- for (const [entitlement, value] of Object.entries(subscription.entitlements)) {
86
- await db.prepare("INSERT INTO auth_subscription_entitlements (subscription_id,entitlement,value_json) VALUES (?,?,?) ON CONFLICT(subscription_id,entitlement) DO UPDATE SET value_json=excluded.value_json,updated_at=CURRENT_TIMESTAMP").bind(subscription.id, entitlement, JSON.stringify(value)).run();
87
- }
88
- }
89
- }
90
-
91
- export async function hasSubscription(env, tenantId, subscriptionId, { who = "system:read" } = {}) {
92
- const db = createD1(env, { who });
93
- return Boolean(await db.prepare("SELECT 1 FROM auth_tenant_subscriptions WHERE tenant_id=? AND subscription_id=?").bind(tenantId, subscriptionId).first());
94
- }
95
-
96
- export async function requireSubscription(env, tenantId, subscriptionId, { who = "system:read" } = {}) {
97
- if (!await hasSubscription(env, tenantId, subscriptionId, { who })) throw new SubscriptionError("Required subscription is not active for this tenant.");
98
- return true;
99
- }
100
-
101
- export async function hasEntitlement(env, tenantId, entitlement, expectedValue, { who = "system:read" } = {}) {
102
- const db = createD1(env, { who });
103
- const rows = await db.prepare("SELECT e.value_json FROM auth_subscription_entitlements e JOIN auth_tenant_subscriptions ts ON ts.subscription_id=e.subscription_id WHERE ts.tenant_id=? AND e.entitlement=?").bind(tenantId, entitlement).all();
104
- return (rows.results || []).some((row) => expectedValue === undefined || deepEqual(parseJsonValue(row.value_json), expectedValue));
105
- }
106
-
107
- export async function requireEntitlement(env, tenantId, entitlement, expectedValue, { who = "system:read" } = {}) {
108
- if (!await hasEntitlement(env, tenantId, entitlement, expectedValue, { who })) throw new SubscriptionError(`Required entitlement is not active: ${entitlement}.`);
109
- return true;
110
- }
111
-
112
- export async function createImpersonationToken(env, adminUserId, targetUserId, { ttlSeconds = 900 } = {}) {
113
- if (!env?.AUTH_SESSION_SECRET) throw new Error("AUTH_SESSION_SECRET is required for impersonation.");
114
- const payload = { adminUserId: String(adminUserId || "admin"), targetUserId: String(targetUserId), exp: Math.floor(Date.now() / 1000) + Math.min(Math.max(Number(ttlSeconds) || 900, 60), 3600) };
115
- const encoded = base64url(new TextEncoder().encode(JSON.stringify(payload)));
116
- return `${encoded}.${await signValue(encoded, env?.AUTH_SESSION_SECRET || "")}`;
117
- }
118
-
119
- export async function verifyImpersonationToken(token, env) {
120
- if (!env?.AUTH_SESSION_SECRET) return null;
121
- const [encoded, signature] = String(token || "").split(".");
122
- if (!encoded || !signature || !constantTimeEqual(signature, await signValue(encoded, env?.AUTH_SESSION_SECRET || ""))) return null;
123
- try {
124
- const payload = JSON.parse(new TextDecoder().decode(base64urlDecode(encoded)));
125
- return payload.exp > Date.now() / 1000 && payload.targetUserId ? payload : null;
126
- } catch { return null; }
127
- }
128
-
129
- export async function hasScope(env, user, scope, { who = "system:read" } = {}) {
130
- const db = createD1(env, { who });
131
- if (user?.auth_strategy === "http_basic") return true;
132
- const authUser = await ensureUser(env, user, { who });
133
- if (!authUser) return false;
134
- if (Boolean(authUser.is_admin)) return true;
135
- return Boolean(await db.prepare(`SELECT 1 FROM ${AUTH_GRANT_TABLE} WHERE user_id=? AND scope_name=?`).bind(authUser.id, scope).first());
136
- }
137
-
138
- export async function listAuthorizationUsers(env, { who = "system:read" } = {}) {
139
- const db = createD1(env, { who });
140
- const { results } = await db.prepare(`SELECT id,email,display_name,provider,subject,is_admin,created_at,updated_at FROM ${AUTH_USER_TABLE} ORDER BY email COLLATE NOCASE`).all();
141
- return Promise.all(results.map(async (user) => ({ ...user, scopes: (await listUserGrants(env, user.id, { who })).map((grant) => grant.scope_name) })));
142
- }
143
-
144
- export async function getAuthorizationUser(env, userId, { who = "system:read" } = {}) {
145
- const db = createD1(env, { who });
146
- return db.prepare(`SELECT id,email,display_name,provider,subject,is_admin,created_at,updated_at FROM ${AUTH_USER_TABLE} WHERE id=?`).bind(userId).first();
147
- }
148
-
149
- export async function listAuthorizationScopes(env, { who = "system:read" } = {}) {
150
- const db = createD1(env, { who });
151
- const { results } = await db.prepare(`SELECT name,label,description,system FROM ${AUTH_SCOPE_TABLE} ORDER BY name`).all();
152
- return results;
153
- }
154
-
155
- 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 || []; }
156
-
157
- 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 || []; }
158
-
159
- 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 }); }
160
-
161
- export async function listUserGrants(env, userId, { who = "system:read" } = {}) {
162
- const db = createD1(env, { who });
163
- const { results } = await db.prepare(`SELECT scope_name,granted_at FROM ${AUTH_GRANT_TABLE} WHERE user_id=? ORDER BY scope_name`).bind(userId).all();
164
- return results;
165
- }
166
-
167
- export async function replaceUserGrants(env, userId, scopes, grantedBy, { who = "system:read" } = {}) {
168
- const valid = new Set((await listAuthorizationScopes(env, { who })).map((scope) => scope.name));
169
- const db = createD1(env, { who });
170
- const requested = [...new Set(scopes)].filter((scope) => valid.has(scope));
171
- await db.batch([
172
- db.prepare(`DELETE FROM ${AUTH_GRANT_TABLE} WHERE user_id=?`).bind(userId),
173
- ...requested.map((scope) => db.prepare(`INSERT INTO ${AUTH_GRANT_TABLE} (user_id,scope_name,granted_by) VALUES (?,?,?)`).bind(userId, scope, grantedBy || null))
174
- ]);
175
- return requested;
176
- }
177
-
178
- async function stableId(value) {
179
- const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
180
- return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("").slice(0, 32);
181
- }
182
-
183
- function parseJsonValue(value) { try { return JSON.parse(value); } catch { return value; } }
184
- function deepEqual(left, right) { return JSON.stringify(left) === JSON.stringify(right); }
185
- async function signValue(value, secret) { const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(String(secret)), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value)); return base64url(new Uint8Array(signature)); }
186
- function base64url(bytes) { return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); }
187
- function base64urlDecode(value) { const padded = value.replaceAll("-", "+").replaceAll("_", "/") + "=".repeat((4 - value.length % 4) % 4); return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0)); }
188
- function constantTimeEqual(left, right) { const a = new TextEncoder().encode(String(left)), b = new TextEncoder().encode(String(right)); let result = a.length ^ b.length; for (let index = 0; index < Math.max(a.length, b.length); index += 1) result |= (a[index] || 0) ^ (b[index] || 0); return result === 0; }