@agilesyndrome/cf-genai-base 4.1.3 → 5.0.1

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 (63) hide show
  1. package/CLI.md +121 -0
  2. package/CONTRACT.md +26 -9
  3. package/README.md +105 -22
  4. package/bin/cf-genai.js +11 -0
  5. package/changelog.md +25 -0
  6. package/migrations/0006_jobs.sql +38 -0
  7. package/package.json +27 -7
  8. package/src/admin/index.js +1 -0
  9. package/src/admin/routes.js +64 -0
  10. package/src/api/client.js +8 -0
  11. package/src/api/contracts.js +24 -0
  12. package/src/api/index.js +7 -0
  13. package/src/api/jobs.js +37 -0
  14. package/src/api/testing.js +13 -0
  15. package/src/app.js +5 -0
  16. package/src/auth/constants.js +9 -0
  17. package/src/auth/encoding.js +6 -0
  18. package/src/auth/groups.js +5 -0
  19. package/src/auth/impersonation.js +15 -0
  20. package/src/auth/index.js +8 -0
  21. package/src/auth/oauth-single.js +193 -0
  22. package/src/auth/scopes.js +32 -0
  23. package/src/auth/subscriptions.js +25 -0
  24. package/src/auth/tenants.js +34 -0
  25. package/src/auth/users.js +31 -0
  26. package/src/cli/cli.js +133 -0
  27. package/src/cli/d1.js +165 -0
  28. package/src/cli/operations.js +34 -0
  29. package/src/cli/project.js +560 -0
  30. package/src/cli/status.js +46 -0
  31. package/src/cli/tenants.js +16 -0
  32. package/src/cli/users.js +34 -0
  33. package/src/cli/version.js +54 -0
  34. package/src/{core.js → core/circuits.js} +51 -98
  35. package/src/core/constants.js +5 -0
  36. package/src/core/d1.js +54 -0
  37. package/src/core/event-hub.js +29 -0
  38. package/src/core/events.js +63 -0
  39. package/src/core/identity.js +39 -0
  40. package/src/core/index.js +7 -0
  41. package/src/core/jobs.js +185 -0
  42. package/src/core/security.js +71 -0
  43. package/src/data/context.js +16 -0
  44. package/src/data/index.js +3 -0
  45. package/src/data/reader.js +63 -0
  46. package/src/data/resources.js +42 -0
  47. package/src/index.js +10 -297
  48. package/src/repository.js +63 -0
  49. package/src/runtime/features.js +17 -0
  50. package/src/runtime/health.js +17 -0
  51. package/src/runtime/worker.js +45 -0
  52. package/src/ui/index.js +1 -87
  53. package/src/ui/react/admin-access.jsx +29 -0
  54. package/src/ui/react/admin-catalogs.jsx +39 -0
  55. package/src/ui/react/admin-shell.jsx +17 -0
  56. package/src/ui/react/foundation.jsx +40 -0
  57. package/src/ui/react/index.jsx +6 -0
  58. package/src/ui/react/jobs.jsx +63 -0
  59. package/src/ui/react/live-events.jsx +55 -0
  60. package/src/ui/styles.css +34 -0
  61. package/src/authorization.js +0 -233
  62. package/src/data.js +0 -239
  63. package/src/ui/groups.js +0 -2
@@ -0,0 +1,24 @@
1
+ import { json, requestIdentity, sameOrigin } from "../core/index.js";
2
+ import { hasScope } from "../auth/index.js";
3
+
4
+ export function defineRoute({ method = "GET", path, auth = "public", scope = null, csrf = false, handler } = {}) {
5
+ if (!path || typeof handler !== "function") throw new TypeError("API routes require path and handler");
6
+ return { method: String(method).toUpperCase(), path: String(path), auth, scope, csrf, handler };
7
+ }
8
+
9
+ export function matchRoute(request, route) {
10
+ const url = new URL(request.url); const methods = Array.isArray(route.method) ? route.method : [route.method];
11
+ return methods.map((method) => String(method).toUpperCase()).includes(request.method.toUpperCase()) && (typeof route.path === "function" ? route.path(url.pathname, request) : route.path === url.pathname);
12
+ }
13
+
14
+ export async function dispatchRoutes(request, env, ctx, state, routes = []) {
15
+ for (const route of routes) if (matchRoute(request, route)) {
16
+ const identity = requestIdentity(state);
17
+ if (route.auth === "user" && !identity.isAuthenticated) return json({ error: "Authentication is required." }, 401);
18
+ if (route.auth === "admin" && !identity.isAdmin) return json({ error: "Administrator access is required." }, 403);
19
+ if (route.csrf && !sameOrigin(request)) return json({ error: "A same-origin request is required." }, 403);
20
+ if (route.scope && !(await hasScope(env, state?.user || state?.authUser, route.scope, { who: identity.who }))) return json({ error: "Required scope is missing." }, 403);
21
+ return route.handler({ request, env, ctx, state, route, identity });
22
+ }
23
+ return null;
24
+ }
@@ -0,0 +1,7 @@
1
+ export { Event, emitEvent, json, readJson, readJsonClone, requireAdmin, requireFeatureCircuit, requireUser, sameOrigin, secureJson, secureText, userId, requestContext, requestIdentity } from "../core/index.js";
2
+ export { defineRepository, createRepositories, RepositoryError } from "../repository.js";
3
+ export { defineRoute, matchRoute, dispatchRoutes } from "./contracts.js";
4
+ export { apiFetch, apiJson } from "./client.js";
5
+ export { jobResponse, liveEventsResponse } from "./jobs.js";
6
+ export { defineApp } from "../app.js";
7
+ export { assertSecurityHeaders, assertJsonError } from "./testing.js";
@@ -0,0 +1,37 @@
1
+ import { getJob, listJobEvents, listJobs } from "../core/jobs.js";
2
+ import { requestIdentity } from "../core/identity.js";
3
+ import { secureJson } from "../core/security.js";
4
+
5
+ export async function jobResponse(request, env, state) {
6
+ const pathname = new URL(request.url).pathname;
7
+ if (pathname !== "/api/jobs" && !pathname.startsWith("/api/jobs/")) return null;
8
+ if (request.method !== "GET") return secureJson({ error: "Method not allowed" }, 405, { Allow: "GET" });
9
+ const identity = requestIdentity(state);
10
+ const ownerId = identity.authUser?.id || state?.user?.sub || (state?.user?.auth_strategy === "http_basic" ? "basic:admin" : null);
11
+ if (!ownerId) return secureJson({ error: "Authentication is required." }, 401);
12
+ const url = new URL(request.url);
13
+ const match = url.pathname.match(/^\/api\/jobs\/([^/]+)(?:\/events)?$/);
14
+ const isEvents = url.pathname.endsWith("/events");
15
+ if (!match) {
16
+ const jobs = await listJobs(env, { ownerId, type: url.searchParams.get("type") || undefined, status: url.searchParams.get("status") || undefined, limit: url.searchParams.get("limit") || 50 }, { who: identity.who });
17
+ return secureJson({ jobs });
18
+ }
19
+ const job = await getJob(env, decodeURIComponent(match[1]), { who: identity.who });
20
+ if (!job || (job.ownerId !== ownerId && !identity.isAdmin)) return secureJson({ error: "Job not found" }, 404);
21
+ if (isEvents) return secureJson({ events: await listJobEvents(env, job.id, { who: identity.who }) });
22
+ return secureJson({ job });
23
+ }
24
+
25
+ export async function liveEventsResponse(request, env, state, bindingName = "EVENT_HUB") {
26
+ const url = new URL(request.url);
27
+ if (url.pathname !== "/api/events") return null;
28
+ const identity = requestIdentity(state);
29
+ const ownerId = identity.authUser?.id || state?.user?.sub || (state?.user?.auth_strategy === "http_basic" ? "basic:admin" : null);
30
+ if (!ownerId) return secureJson({ error: "Authentication is required." }, 401);
31
+ const namespace = env?.[bindingName];
32
+ if (!namespace || typeof namespace.idFromName !== "function") return secureJson({ error: "Live events are not configured." }, 501);
33
+ if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") return secureJson({ error: "WebSocket upgrade required." }, 426, { Upgrade: "websocket" });
34
+ const room = `user:${ownerId}`;
35
+ const stub = namespace.get(namespace.idFromName(room));
36
+ return stub.fetch("https://cf-genai-event-hub/connect", { headers: { Upgrade: "websocket" } });
37
+ }
@@ -0,0 +1,13 @@
1
+ export function assertSecurityHeaders(response) {
2
+ for (const header of ["Content-Security-Policy", "X-Content-Type-Options", "X-Frame-Options", "Referrer-Policy", "Permissions-Policy", "Strict-Transport-Security"]) {
3
+ if (!response?.headers?.get(header)) throw new Error(`Missing security header: ${header}`);
4
+ }
5
+ return response;
6
+ }
7
+
8
+ export async function assertJsonError(response, status) {
9
+ if (response.status !== status) throw new Error(`Expected ${status}, received ${response.status}`);
10
+ const payload = await response.clone().json();
11
+ if (!payload?.error) throw new Error("Expected a JSON error envelope");
12
+ return payload;
13
+ }
package/src/app.js ADDED
@@ -0,0 +1,5 @@
1
+ export function defineApp({ name, ui = true, api = true, admin = true, features = [], repositories = [] } = {}) {
2
+ if (!/^[a-z][a-z0-9-]*$/.test(String(name || ""))) throw new TypeError("Apps require a safe name");
3
+ if (!ui && !api) throw new TypeError("An app must expose ui or api");
4
+ return { name: String(name), ui: Boolean(ui), api: Boolean(api), admin: Boolean(admin), features: [...features], repositories: [...repositories] };
5
+ }
@@ -0,0 +1,9 @@
1
+ export const AUTH_USER_TABLE = "auth_users";
2
+ export const AUTH_SCOPE_TABLE = "auth_scopes";
3
+ export const AUTH_GRANT_TABLE = "auth_user_scopes";
4
+ export const DEFAULT_TENANT_ID = "easley-family";
5
+ export const DEFAULT_TENANT_NAME = "Easley Family";
6
+
7
+ export class SubscriptionError extends Error {
8
+ constructor(message) { super(message); this.name = "SubscriptionError"; }
9
+ }
@@ -0,0 +1,6 @@
1
+ export function parseJsonValue(value) { try { return JSON.parse(value); } catch { return value; } }
2
+ export function deepEqual(left, right) { return JSON.stringify(left) === JSON.stringify(right); }
3
+ export 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)); }
4
+ export function base64url(bytes) { return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); }
5
+ export function base64urlDecode(value) { const padded = value.replaceAll("-", "+").replaceAll("_", "/") + "=".repeat((4 - value.length % 4) % 4); return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0)); }
6
+ export 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; }
@@ -0,0 +1,5 @@
1
+ import { createD1 } from "../core/d1.js";
2
+
3
+ export async function listGroups(env, { who = "system:read" } = {}) { const result = await createD1(env, { who }).prepare("SELECT name,display_name,description,created_at,updated_at FROM auth_groups ORDER BY display_name COLLATE NOCASE").all(); return result.results || []; }
4
+ export async function listUserGroups(env, userId, { who = "system:read" } = {}) { const result = await createD1(env, { who }).prepare("SELECT group_name,granted_at FROM auth_user_groups WHERE user_id=? ORDER BY group_name").bind(userId).all(); return result.results || []; }
5
+ 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 }); }
@@ -0,0 +1,15 @@
1
+ import { base64url, base64urlDecode, constantTimeEqual, signValue } from "./encoding.js";
2
+
3
+ export async function createImpersonationToken(env, adminUserId, targetUserId, { ttlSeconds = 900 } = {}) {
4
+ if (!env?.AUTH_SESSION_SECRET) throw new Error("AUTH_SESSION_SECRET is required for impersonation.");
5
+ const payload = { adminUserId: String(adminUserId || "admin"), targetUserId: String(targetUserId), exp: Math.floor(Date.now() / 1000) + Math.min(Math.max(Number(ttlSeconds) || 900, 60), 3600) };
6
+ const encoded = base64url(new TextEncoder().encode(JSON.stringify(payload)));
7
+ return `${encoded}.${await signValue(encoded, env?.AUTH_SESSION_SECRET || "")}`;
8
+ }
9
+
10
+ export async function verifyImpersonationToken(token, env) {
11
+ if (!env?.AUTH_SESSION_SECRET) return null;
12
+ const [encoded, signature] = String(token || "").split(".");
13
+ if (!encoded || !signature || !constantTimeEqual(signature, await signValue(encoded, env?.AUTH_SESSION_SECRET || ""))) return null;
14
+ try { const payload = JSON.parse(new TextDecoder().decode(base64urlDecode(encoded))); return payload.exp > Date.now() / 1000 && payload.targetUserId ? payload : null; } catch { return null; }
15
+ }
@@ -0,0 +1,8 @@
1
+ export * from "./constants.js";
2
+ export * from "./scopes.js";
3
+ export * from "./users.js";
4
+ export * from "./tenants.js";
5
+ export * from "./groups.js";
6
+ export * from "./subscriptions.js";
7
+ export * from "./impersonation.js";
8
+ export * from "./oauth-single.js";
@@ -0,0 +1,193 @@
1
+ import { sameOrigin, secureJson, secureText } from "../core/security.js";
2
+ import { ensureUser } from "./users.js";
3
+
4
+ const encoder = new TextEncoder();
5
+ const decoder = new TextDecoder();
6
+ const configurationCache = new Map();
7
+ const configurationRequests = new Map();
8
+ const jwksCache = new Map();
9
+ const jwksRequests = new Map();
10
+ const OIDC_CACHE_MS = 15 * 60 * 1000;
11
+ const OIDC_TIMEOUT_MS = 10_000;
12
+ export const OAUTH_SINGLE = "OAUTH_SINGLE";
13
+ export const PACKAGE_NAME = "@agilesyndrome/cf-genai-base";
14
+ export const VERSION = "5.0.0";
15
+
16
+ export const authRepositoryDefinitions = [
17
+ { name: "users", resource: "auth_users", scope: "system", resourceDefinition: { name: "auth_users", table: "auth_users", scope: "system", columns: ["id", "provider", "subject", "email", "display_name", "is_admin", "created_at", "updated_at"], readableColumns: ["id", "provider", "subject", "email", "display_name", "is_admin", "created_at", "updated_at"], orderableColumns: ["id", "email", "created_at"], writableColumns: ["display_name", "is_admin"] } },
18
+ { name: "groups", resource: "auth_groups", scope: "system", resourceDefinition: { name: "auth_groups", table: "auth_groups", scope: "system", columns: ["name", "display_name", "description", "created_at", "updated_at"], idColumn: "name", readableColumns: ["name", "display_name", "description", "created_at", "updated_at"], orderableColumns: ["name", "created_at"], writableColumns: ["name", "display_name", "description"] } },
19
+ ];
20
+
21
+ /**
22
+ * Generic OIDC auth for Workers. It uses Authorization Code + PKCE and a
23
+ * signed, host-only cookie, so a site needs no auth service of its own.
24
+ * Site-specific roles can be derived from `claims` in `onLogin`.
25
+ */
26
+ export function createAuth(options = {}) {
27
+ const prefix = options.cookiePrefix || "__Host-cfgenai";
28
+ if (!/^(?:__Host-)?[A-Za-z0-9_-]+$/.test(prefix)) throw new Error("cookiePrefix contains invalid characters");
29
+ const names = { state: options.stateCookieName || `${prefix}_state`, session: options.sessionCookieName || `${prefix}_session` };
30
+ const publicPaths = options.publicPaths || ["/auth/", "/favicon.svg", "/robots.txt", "/health"];
31
+ const protectedPath = options.protectedPath || (() => true);
32
+ const envName = (key, fallback) => options.env?.[key] || fallback;
33
+
34
+ return {
35
+ name: "auth", displayName: options.displayName || "Authentication", packageName: PACKAGE_NAME, version: VERSION, strategy: OAUTH_SINGLE, repositories: options.repositories || authRepositoryDefinitions,
36
+ dataResources: options.dataResources || [], routes: options.routes || [],
37
+ healthchecks: options.healthchecks || [], circuitBreakers: options.circuitBreakers || [],
38
+ async handle(request, env) {
39
+ const url = new URL(request.url);
40
+ if (url.pathname === "/auth/login") return login(request, env);
41
+ if (url.pathname === "/auth/callback") return callback(request, env);
42
+ if (url.pathname === "/auth/logout") {
43
+ if (request.method !== "POST") return new Response("Method Not Allowed", { status: 405, headers: { Allow: "POST", "Cache-Control": "no-store" } });
44
+ const originResponse = checkOrigin(request, options.allowedOrigins);
45
+ if (originResponse) return originResponse;
46
+ return logout(request, env, names.session, options);
47
+ }
48
+ if (url.pathname === "/api/me") return secureJson({ user: publicUser(await resolveUser(request, env)) });
49
+ if (!protectedPath(url.pathname) || publicPaths.some((path) => path === "/" ? url.pathname === "/" : url.pathname.startsWith(path))) return null;
50
+ if (isMutation(request)) { const originResponse = checkOrigin(request, options.allowedOrigins); if (originResponse) return originResponse; }
51
+ const user = await resolveUser(request, env);
52
+ if (user && options.authorize && !(await options.authorize({ request, url, user, env }))) {
53
+ return url.pathname.startsWith("/api/") ? Response.json({ error: "Administrator access is required." }, { status: 403, headers: { "Cache-Control": "no-store" } }) : authError("Administrator access is required.", 403);
54
+ }
55
+ if (user) return null;
56
+ if (url.pathname.startsWith("/api/")) return Response.json({ error: "Authentication is required." }, { status: 401, headers: { "Cache-Control": "no-store" } });
57
+ return Response.redirect(`${url.origin}/auth/login?return_to=${encodeURIComponent(safeReturnTo(url.pathname + url.search))}`, 302);
58
+ },
59
+ getUser: (request, env) => resolveUser(request, env),
60
+ healthcheck: async (env) => ({ feature: "auth", component: "configuration", displayName: "Authentication configuration", state: Boolean(env?.DB) && [envName("issuer", "OIDC_ISSUER"), envName("clientId", "OIDC_CLIENT_ID"), envName("clientSecret", "OIDC_CLIENT_SECRET"), envName("sessionSecret", "AUTH_SESSION_SECRET")].every((key) => env?.[key] && !String(env[key]).startsWith("replace-with-")) ? "green" : "red" }),
61
+ middleware(request, env, ctx, next, state) {
62
+ return this.handle(request, env, ctx).then((response) => response || next(request, env, ctx, state));
63
+ },
64
+ };
65
+
66
+ async function login(request, env) {
67
+ if (options.getSession && await resolveUser(request, env)) {
68
+ const target = safeReturnTo(new URL(request.url).searchParams.get("return_to") || "/");
69
+ return redirect(new URL(request.url).origin + target, [clearCookie(names.state)]);
70
+ }
71
+ const config = await configuration(env, options);
72
+ const state = random();
73
+ const verifier = random();
74
+ const nonce = random();
75
+ const challenge = base64url(new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(verifier))));
76
+ const returnTo = safeReturnTo(new URL(request.url).searchParams.get("return_to") || "/");
77
+ const stateValue = `${state}.${verifier}.${nonce}.${base64url(encoder.encode(returnTo))}`;
78
+ const authorize = new URL(config.authorization_endpoint);
79
+ authorize.search = new URLSearchParams({ client_id: required(env, envName("clientId", "OIDC_CLIENT_ID")), response_type: "code", redirect_uri: callbackUrl(request, env, options), scope: options.scope || "openid profile email", state, code_challenge: challenge, code_challenge_method: "S256", nonce }).toString();
80
+ return redirect(authorize, [cookie(names.state, stateValue, 600)]);
81
+ }
82
+
83
+ async function callback(request, env) {
84
+ const url = new URL(request.url);
85
+ const value = cookies(request)[names.state] || "";
86
+ const [state, verifier, nonce, encodedReturn] = value.split(".");
87
+ if (!state || !constantTimeEqual(state, url.searchParams.get("state") || "") || !verifier) {
88
+ if (options.getSession && await resolveUser(request, env)) return redirect(new URL(request.url).origin, [clearCookie(names.state)]);
89
+ return authError("The sign-in state was invalid or expired.", 400);
90
+ }
91
+ const config = await configuration(env, options);
92
+ const clientId = required(env, envName("clientId", "OIDC_CLIENT_ID"));
93
+ const token = await fetchWithTimeout(config.token_endpoint, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: clientId, client_secret: required(env, envName("clientSecret", "OIDC_CLIENT_SECRET")), grant_type: "authorization_code", code: url.searchParams.get("code") || "", redirect_uri: callbackUrl(request, env, options), code_verifier: verifier }) }).then((response) => response.ok ? response.json() : Promise.reject(new Error("OIDC token exchange failed")));
94
+ const claims = await verify(token.id_token, config, clientId, nonce);
95
+ if (!claims.sub) return authError("The identity provider returned no subject.", 502);
96
+ const user = await resolveLoginUser({ ...(await (options.onLogin ? options.onLogin(claims, env) : normalizeUser(claims))), email_verified: claims.email_verified === true }, env, request);
97
+ const identity = sessionIdentity(user);
98
+ const signed = options.createSession ? await options.createSession(identity, env, request) : await sign(JSON.stringify({ ...identity, exp: Math.floor(Date.now() / 1000) + (options.sessionSeconds || 28800) }), env, envName("sessionSecret", "AUTH_SESSION_SECRET"));
99
+ await env.event?.("auth.login.succeeded", "auth", { userId: user.authUser.id });
100
+ return redirect(`${url.origin}${decodeReturn(encodedReturn)}`, [cookie(names.session, signed, options.sessionSeconds || 28800), clearCookie(names.state)]);
101
+ }
102
+ async function resolveUser(request, env) {
103
+ const user = await hydrateUser(await getUser(request, env, envName("sessionSecret", "AUTH_SESSION_SECRET"), names.session, options), env);
104
+ if (user && options.sessionAuthorize && !(await options.sessionAuthorize({ user, request, env }))) return null;
105
+ return user;
106
+ }
107
+
108
+ async function resolveLoginUser(user, env, request) {
109
+ const hydrated = await hydrateUser(user, env);
110
+ if (options.loginAuthorize && !(await options.loginAuthorize({ user: hydrated, request, env }))) throw authError("Authentication is not currently permitted.", 403);
111
+ return hydrated;
112
+ }
113
+ }
114
+
115
+ async function hydrateUser(user, env) {
116
+ if (!user) return user;
117
+ if (!env?.DB) throw new Error("OAUTH_SINGLE requires the base DB binding for canonical users");
118
+ const authUser = await ensureUser(env, user, { who: `user:${user.sub || "unknown"}` });
119
+ return { ...user, authUser };
120
+ }
121
+
122
+ async function getUser(request, env, secretName = "AUTH_SESSION_SECRET", sessionName = "__Host-cfgenai_session", options = {}) {
123
+ const token = cookies(request)[sessionName];
124
+ if (!token) return null;
125
+ if (options.getSession) return (await options.getSession(token, env, request)) || null;
126
+ const [payload, signature] = token.split(".");
127
+ if (!payload || !signature || !constantTimeEqual(signature, await sign(payload, env, secretName, false))) return null;
128
+ try { const user = JSON.parse(decoder.decode(decode(payload))); return typeof user.exp === "number" && user.exp > Date.now() / 1000 ? user : null; } catch { return null; }
129
+ }
130
+
131
+ async function configuration(env, options = {}) {
132
+ const discoveryName = envNameFor(options, "discoveryUrl", "OIDC_DISCOVERY_URL");
133
+ const configuredDiscovery = env[discoveryName];
134
+ const discoveryUrl = configuredDiscovery ? (String(configuredDiscovery).endsWith("/.well-known/openid-configuration") ? configuredDiscovery : String(configuredDiscovery).replace(/\/+$/, "") + "/.well-known/openid-configuration") : normalizeIssuer(required(env, envNameFor(options, "issuer", "OIDC_ISSUER"))).slice(0, -1) + "/.well-known/openid-configuration";
135
+ if (new URL(discoveryUrl).protocol !== "https:") throw new Error("OIDC discovery URL must use HTTPS");
136
+
137
+ const cached = configurationCache.get(discoveryUrl); if (cached && cached.exp > Date.now()) return cached.value;
138
+ const existing = configurationRequests.get(discoveryUrl); if (existing) return existing;
139
+ const request = fetchWithTimeout(discoveryUrl).then(async (response) => {
140
+ if (!response.ok) throw new Error("Unable to load OIDC configuration");
141
+ const value = await response.json();
142
+ if (!value.issuer || new URL(value.issuer).protocol !== "https:") throw new Error("OIDC configuration returned an invalid issuer");
143
+ if (!value.jwks_uri || new URL(value.jwks_uri).protocol !== "https:") throw new Error("OIDC configuration returned an invalid JWKS URL");
144
+ configurationCache.set(discoveryUrl, { value, exp: Date.now() + OIDC_CACHE_MS }); return value;
145
+ }).finally(() => configurationRequests.delete(discoveryUrl));
146
+ configurationRequests.set(discoveryUrl, request); return request;
147
+ }
148
+ async function verify(token, config, clientId, expectedNonce) {
149
+ const [head, body, signature] = String(token || "").split("."); if (!head || !body || !signature) throw new Error("Malformed ID token");
150
+ const header = JSON.parse(decoder.decode(decode(head))); if (header.alg !== "RS256" || !header.kid) throw new Error("Unsupported ID token signature"); const keys = await getJwks(config.jwks_uri); const jwk = keys.keys.find((key) => key.kid === header.kid); if (!jwk) throw new Error("ID token signing key was not found");
151
+ const key = await crypto.subtle.importKey("jwk", jwk, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["verify"]);
152
+ if (!await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, decode(signature), encoder.encode(`${head}.${body}`))) throw new Error("Invalid ID token");
153
+ const claims = JSON.parse(decoder.decode(decode(body))); const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud]; const validAzp = !Array.isArray(claims.aud) || claims.aud.length < 2 || claims.azp === clientId;
154
+ if (normalizeIssuer(claims.iss || "") !== normalizeIssuer(config.issuer || "") || !aud.includes(clientId) || !validAzp || claims.exp <= Date.now() / 1000 || claims.nonce !== expectedNonce || !claims.sub) throw new Error("Invalid ID token claims"); return claims;
155
+ }
156
+ async function getJwks(uri) {
157
+ const cached = jwksCache.get(uri); if (cached && cached.exp > Date.now()) return cached.value;
158
+ const existing = jwksRequests.get(uri); if (existing) return existing;
159
+ const request = fetchWithTimeout(uri).then(async (response) => { if (!response.ok) throw new Error("Unable to load OIDC signing keys"); const value = await response.json(); jwksCache.set(uri, { value, exp: Date.now() + OIDC_CACHE_MS }); return value; }).finally(() => jwksRequests.delete(uri));
160
+ jwksRequests.set(uri, request); return request;
161
+ }
162
+ async function fetchWithTimeout(input, init = {}) { return fetch(input, { ...init, signal: AbortSignal.timeout(OIDC_TIMEOUT_MS) }); }
163
+ function normalizeIssuer(value) { return String(value).replace(/\/+$/, "") + "/"; }
164
+ function isMutation(request) { return ["POST", "PUT", "PATCH", "DELETE"].includes(request.method); }
165
+ function checkOrigin(request, allowedOrigins = []) {
166
+ let requestOrigin;
167
+ try { requestOrigin = new URL(request.url).origin; } catch { return authError("This request did not pass the same-origin check.", 403); }
168
+ return sameOrigin(request, [requestOrigin, ...allowedOrigins]) ? null : authError("A same-origin request is required.", 403);
169
+ }
170
+ async function sign(value, env, name, encoded = true) { const secret = required(env, name); if (name === "AUTH_SESSION_SECRET" && secret.length < 32) throw new Error("AUTH_SESSION_SECRET must be at least 32 characters"); const data = encoded ? base64url(encoder.encode(value)) : value; const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); const sig = base64url(new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(data)))); return encoded ? `${data}.${sig}` : sig; }
171
+ function normalizeUser(claims) { return { sub: claims.sub, email: String(claims.email || "").toLowerCase(), name: claims.name || claims.email || claims.sub, email_verified: claims.email_verified === true, auth_strategy: OAUTH_SINGLE }; }
172
+ function envNameFor(options, key, fallback) { return options.env?.[key] || fallback; }
173
+ function required(env, key) { if (!env[key] || String(env[key]).startsWith("replace-with-")) throw new Error(`${key} is not configured`); return String(env[key]); }
174
+ function random() { const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); return base64url(bytes); }
175
+ function base64url(bytes) { let s = ""; for (const b of bytes) s += String.fromCharCode(b); return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); }
176
+ function decode(value) { return Uint8Array.from(atob(value.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - value.length % 4) % 4)), (c) => c.charCodeAt(0)); }
177
+ function cookies(request) { return Object.fromEntries((request.headers.get("Cookie") || "").split(";").flatMap((part) => { const i = part.indexOf("="); return i < 0 ? [] : [[part.slice(0, i).trim(), part.slice(i + 1).trim()]]; })); }
178
+ function cookie(name, value, age) { return `${name}=${value}; Max-Age=${age}; Path=/; HttpOnly; Secure; SameSite=Lax`; }
179
+ function clearCookie(name) { return `${name}=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Lax`; }
180
+ function redirect(location, setCookies) { const response = new Response(null, { status: 302, headers: { Location: String(location), "Cache-Control": "no-store" } }); for (const value of setCookies) response.headers.append("Set-Cookie", value); return response; }
181
+ async function logout(request, env, name, options = {}) {
182
+ const token = cookies(request)[name];
183
+ if (token && options.revokeSession) await options.revokeSession(token, env, request);
184
+ await env.event?.("auth.logout", "auth");
185
+ return redirect(new URL(request.url).origin + "/", [clearCookie(name)]);
186
+ }
187
+ function authError(message, status) { return secureText(message, status); }
188
+ function callbackUrl(request, env, options) { const configuredOrigin = env[envNameFor(options, "publicOrigin", "PUBLIC_ORIGIN")]; return (configuredOrigin ? new URL(String(configuredOrigin)).origin : new URL(request.url).origin) + "/auth/callback"; }
189
+ function safeReturnTo(value) { return value?.startsWith("/") && !value.startsWith("//") && !value.startsWith("/auth/") ? value : "/"; }
190
+ function decodeReturn(value) { try { return safeReturnTo(decoder.decode(decode(value))); } catch { return "/"; } }
191
+ function constantTimeEqual(a, b) { const aa = encoder.encode(a), bb = encoder.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; }
192
+ function sessionIdentity(user) { const { authUser: _authUser, exp: _exp, ...identity } = user || {}; return identity; }
193
+ function publicUser(user) { return user ? { id: user.authUser.id, email: user.email || user.authUser.email, name: user.name || user.authUser.display_name, isAdmin: Boolean(user.authUser.is_admin) } : null; }
@@ -0,0 +1,32 @@
1
+ import { createD1 } from "../core/d1.js";
2
+ import { AUTH_GRANT_TABLE, AUTH_SCOPE_TABLE } from "./constants.js";
3
+
4
+ export function normalizeScopes(scopes = []) {
5
+ return scopes.map((scope) => typeof scope === "string" ? { name: scope, label: scope, description: "", system: false } : scope)
6
+ .filter((scope) => scope && /^[a-z0-9]+(?::[a-z0-9-]+)+$/.test(String(scope.name || "")))
7
+ .map((scope) => ({ name: String(scope.name), label: String(scope.label || scope.name), description: String(scope.description || ""), system: Boolean(scope.system) }));
8
+ }
9
+
10
+ export async function ensureScopes(env, scopes = [], { who = "system:read" } = {}) {
11
+ if (!env?.DB) return;
12
+ const db = createD1(env, { who });
13
+ for (const scope of normalizeScopes(scopes)) 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();
14
+ }
15
+
16
+ export async function listAuthorizationScopes(env, { who = "system:read" } = {}) {
17
+ const { results } = await createD1(env, { who }).prepare(`SELECT name,label,description,system FROM ${AUTH_SCOPE_TABLE} ORDER BY name`).all();
18
+ return results;
19
+ }
20
+
21
+ export async function listUserGrants(env, userId, { who = "system:read" } = {}) {
22
+ const { results } = await createD1(env, { who }).prepare(`SELECT scope_name,granted_at FROM ${AUTH_GRANT_TABLE} WHERE user_id=? ORDER BY scope_name`).bind(userId).all();
23
+ return results;
24
+ }
25
+
26
+ export async function replaceUserGrants(env, userId, scopes, grantedBy, { who = "system:read" } = {}) {
27
+ const valid = new Set((await listAuthorizationScopes(env, { who })).map((scope) => scope.name));
28
+ const db = createD1(env, { who });
29
+ const requested = [...new Set(scopes)].filter((scope) => valid.has(scope));
30
+ await db.batch([db.prepare(`DELETE FROM ${AUTH_GRANT_TABLE} WHERE user_id=?`).bind(userId), ...requested.map((scope) => db.prepare(`INSERT INTO ${AUTH_GRANT_TABLE} (user_id,scope_name,granted_by) VALUES (?,?,?)`).bind(userId, scope, grantedBy || null))]);
31
+ return requested;
32
+ }
@@ -0,0 +1,25 @@
1
+ import { createD1 } from "../core/d1.js";
2
+ import { deepEqual, parseJsonValue } from "./encoding.js";
3
+ import { SubscriptionError } from "./constants.js";
4
+
5
+ export async function listTenantSubscriptions(env, tenantId, { who = "system:read" } = {}) {
6
+ const { results } = await createD1(env, { who }).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();
7
+ return Promise.all((results || []).map(async (subscription) => ({ ...subscription, entitlements: await listSubscriptionEntitlements(env, subscription.id, { who }) })));
8
+ }
9
+
10
+ export async function listSubscriptionEntitlements(env, subscriptionId, { who = "system:read" } = {}) {
11
+ const { results } = await createD1(env, { who }).prepare("SELECT entitlement,value_json FROM auth_subscription_entitlements WHERE subscription_id=? ORDER BY entitlement").bind(subscriptionId).all();
12
+ return (results || []).map((row) => ({ entitlement: row.entitlement, value: parseJsonValue(row.value_json) }));
13
+ }
14
+
15
+ export function normalizeSubscriptionManifest(manifest = []) { return manifest.map((subscription) => ({ id: String(subscription?.id || "").trim(), name: String(subscription?.name || subscription?.id || "").trim(), entitlements: Object.fromEntries(Object.entries(subscription?.entitlements || {}).map(([key, value]) => [String(key), value])) })).filter((subscription) => /^[a-z0-9][a-z0-9_-]*$/.test(subscription.id) && subscription.name); }
16
+
17
+ export async function ensureSubscriptionManifest(env, manifest = [], { who = "system:update" } = {}) {
18
+ if (!env?.DB) return; const db = createD1(env, { who });
19
+ for (const subscription of normalizeSubscriptionManifest(manifest)) { 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(); for (const [entitlement, value] of Object.entries(subscription.entitlements)) 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(); }
20
+ }
21
+
22
+ export async function hasSubscription(env, tenantId, subscriptionId, { who = "system:read" } = {}) { return Boolean(await createD1(env, { who }).prepare("SELECT 1 FROM auth_tenant_subscriptions WHERE tenant_id=? AND subscription_id=?").bind(tenantId, subscriptionId).first()); }
23
+ export async function requireSubscription(env, tenantId, subscriptionId, { who = "system:read" } = {}) { if (!await hasSubscription(env, tenantId, subscriptionId, { who })) throw new SubscriptionError("Required subscription is not active for this tenant."); return true; }
24
+ export async function hasEntitlement(env, tenantId, entitlement, expectedValue, { who = "system:read" } = {}) { const rows = await createD1(env, { who }).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(); return (rows.results || []).some((row) => expectedValue === undefined || deepEqual(parseJsonValue(row.value_json), expectedValue)); }
25
+ export async function requireEntitlement(env, tenantId, entitlement, expectedValue, { who = "system:read" } = {}) { if (!await hasEntitlement(env, tenantId, entitlement, expectedValue, { who })) throw new SubscriptionError(`Required entitlement is not active: ${entitlement}.`); return true; }
@@ -0,0 +1,34 @@
1
+ import { createD1 } from "../core/d1.js";
2
+
3
+ export async function listUserTenants(env, userId, { who = "system:read" } = {}) {
4
+ const { results } = await createD1(env, { who }).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();
5
+ return results || [];
6
+ }
7
+
8
+ export async function listAuthorizationTenants(env, { who = "system:read" } = {}) {
9
+ const { results } = await createD1(env, { who }).prepare("SELECT t.id,t.name,t.created_at,t.updated_at,COUNT(ut.user_id) AS user_count FROM auth_tenants t LEFT JOIN auth_user_tenants ut ON ut.tenant_id=t.id GROUP BY t.id ORDER BY t.name COLLATE NOCASE").all();
10
+ return results || [];
11
+ }
12
+
13
+ export async function getAuthorizationTenant(env, tenantId, { who = "system:read" } = {}) { return createD1(env, { who }).prepare("SELECT id,name,created_at,updated_at FROM auth_tenants WHERE id=?").bind(String(tenantId)).first(); }
14
+
15
+ export async function createAuthorizationTenant(env, tenantId, name, { who = "system:update" } = {}) {
16
+ const id = String(tenantId || "").trim(); const tenantName = String(name || "").trim();
17
+ if (!/^[a-z0-9][a-z0-9_-]*$/.test(id)) throw new Error("Tenant id must contain lowercase letters, numbers, hyphens, or underscores.");
18
+ if (!tenantName) throw new Error("Tenant name is required.");
19
+ await createD1(env, { who }).prepare("INSERT INTO auth_tenants (id,name) VALUES (?,?)").bind(id, tenantName).run();
20
+ return getAuthorizationTenant(env, id, { who });
21
+ }
22
+
23
+ export async function updateAuthorizationTenant(env, tenantId, name, { who = "system:update" } = {}) {
24
+ const tenantName = String(name || "").trim(); if (!tenantName) throw new Error("Tenant name is required.");
25
+ await createD1(env, { who }).prepare("UPDATE auth_tenants SET name=?,updated_at=CURRENT_TIMESTAMP WHERE id=?").bind(tenantName, String(tenantId)).run();
26
+ return getAuthorizationTenant(env, tenantId, { who });
27
+ }
28
+
29
+ export async function replaceUserTenants(env, userId, tenantIds, { who = "system:update" } = {}) {
30
+ const db = createD1(env, { who }); const requested = [...new Set((tenantIds || []).map((id) => String(id).trim()).filter(Boolean))];
31
+ if (requested.length) { const placeholders = requested.map(() => "?").join(","); const { results } = await db.prepare(`SELECT id FROM auth_tenants WHERE id IN (${placeholders})`).bind(...requested).all(); const found = new Set((results || []).map((row) => row.id)); if (found.size !== requested.length) throw new Error("One or more tenants do not exist."); }
32
+ await db.batch([db.prepare("DELETE FROM auth_user_tenants WHERE user_id=?").bind(userId), ...requested.map((tenantId) => db.prepare("INSERT INTO auth_user_tenants (user_id,tenant_id) VALUES (?,?)").bind(userId, tenantId))]);
33
+ return listUserTenants(env, userId, { who });
34
+ }
@@ -0,0 +1,31 @@
1
+ import { createD1 } from "../core/d1.js";
2
+ import { AUTH_GRANT_TABLE, AUTH_USER_TABLE, DEFAULT_TENANT_ID, DEFAULT_TENANT_NAME } from "./constants.js";
3
+ import { listUserGrants } from "./scopes.js";
4
+ import { listUserTenants } from "./tenants.js";
5
+
6
+ export async function ensureUser(env, user, { who = "system:read" } = {}) {
7
+ if (!env?.DB || !user?.sub) return null;
8
+ const db = createD1(env, { who }); const provider = String(user.auth_strategy || "oauth"); const subject = String(user.sub); const email = String(user.email || "").trim().toLowerCase();
9
+ const existing = await db.prepare(`SELECT * FROM ${AUTH_USER_TABLE} WHERE provider=? AND subject=?`).bind(provider, subject).first();
10
+ if (existing) { await db.prepare(`UPDATE ${AUTH_USER_TABLE} SET email=?,display_name=?,updated_at=CURRENT_TIMESTAMP WHERE id=?`).bind(email, String(user.name || email || subject), existing.id).run(); await ensureDefaultTenantMembership(db, existing.id); return { ...existing, email, display_name: String(user.name || email || subject), is_admin: Boolean(existing.is_admin) }; }
11
+ const id = await stableId(`${provider}:${subject}`);
12
+ await db.prepare(`INSERT INTO ${AUTH_USER_TABLE} (id,provider,subject,email,display_name,is_admin) VALUES (?,?,?,?,?,0) ON CONFLICT(provider,subject) DO NOTHING`).bind(id, provider, subject, email, String(user.name || email || subject)).run();
13
+ await ensureDefaultTenantMembership(db, id);
14
+ return db.prepare(`SELECT * FROM ${AUTH_USER_TABLE} WHERE id=?`).bind(id).first();
15
+ }
16
+
17
+ async function ensureDefaultTenantMembership(db, userId) { await db.batch([db.prepare("INSERT OR IGNORE INTO auth_tenants (id,name) VALUES (?,?)").bind(DEFAULT_TENANT_ID, DEFAULT_TENANT_NAME), db.prepare("INSERT OR IGNORE INTO auth_user_tenants (user_id,tenant_id) VALUES (?,?)").bind(userId, DEFAULT_TENANT_ID)]); }
18
+
19
+ export async function hasScope(env, user, scope, { who = "system:read" } = {}) {
20
+ const db = createD1(env, { who }); if (user?.auth_strategy === "http_basic") return true; const authUser = user?.authUser || await ensureUser(env, user, { who }); if (!authUser) return false; if (Boolean(authUser.is_admin)) return true;
21
+ return Boolean(await db.prepare(`SELECT 1 FROM ${AUTH_GRANT_TABLE} WHERE user_id=? AND scope_name=?`).bind(authUser.id, scope).first());
22
+ }
23
+
24
+ export async function listAuthorizationUsers(env, { who = "system:read" } = {}) {
25
+ const { results } = await createD1(env, { who }).prepare(`SELECT id,email,display_name,provider,subject,is_admin,created_at,updated_at FROM ${AUTH_USER_TABLE} ORDER BY email COLLATE NOCASE`).all();
26
+ return Promise.all(results.map(async (user) => ({ ...user, scopes: (await listUserGrants(env, user.id, { who })).map((grant) => grant.scope_name), tenants: await listUserTenants(env, user.id, { who }) })));
27
+ }
28
+
29
+ export async function getAuthorizationUser(env, userId, { who = "system:read" } = {}) { return createD1(env, { who }).prepare(`SELECT id,email,display_name,provider,subject,is_admin,created_at,updated_at FROM ${AUTH_USER_TABLE} WHERE id=?`).bind(userId).first(); }
30
+
31
+ async function stableId(value) { const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)); return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("").slice(0, 32); }
package/src/cli/cli.js ADDED
@@ -0,0 +1,133 @@
1
+ import { backupD1, checkConfig, checkD1, migrateD1, refreshLocalD1, refreshStagingD1, restoreD1, statusD1 } from "./d1.js";
2
+ import { runUserCommand } from "./users.js";
3
+ import { runTenantCommand } from "./tenants.js";
4
+ import { runOperationalCommand } from "./operations.js";
5
+ import { runProjectCommand } from "./project.js";
6
+ import { runVersionCommand } from "./version.js";
7
+ import { siteStatus } from "./status.js";
8
+
9
+ const usage = `Usage:
10
+ cf-genai check|test|build|ci|ci:lint
11
+ cf-genai lint data-access
12
+ cf-genai dev [options]
13
+ cf-genai upgrade <base|auth|llm|messaging> <latest|VERSION>
14
+ cf-genai release [--confirm] [--first] [--add-trust] [--dry-run] [--bypass-lint] [--type patch|minor|major] [--version MAJOR.MINOR]
15
+ cf-genai release-status [--wait MINUTES] [--json]
16
+ cf-genai status [--env local|staging|production] [--json]
17
+ cf-genai version
18
+ cf-genai d1 refresh|backup|restore|migrate|status|check local|staging|production [options]
19
+ cf-genai admin status|features|users|tenants|scopes|groups|healthchecks|circuit-breakers [options]
20
+ cf-genai config check [options]
21
+ cf-genai user list|get|update [username] [options]
22
+ cf-genai tenant list|get|create|update [tenant-id] [options]
23
+
24
+ Options:
25
+ --database NAME D1 binding or database name (default: DB)
26
+ --production-database NAME Production D1 binding or database name
27
+ --production-env NAME Wrangler production environment (default: none)
28
+ --staging-env NAME Wrangler staging environment (default: staging)
29
+ --config PATH Wrangler config path (default: wrangler.jsonc)
30
+ --env VALUE Environment alias: local, staging, or prod
31
+ --output PATH Backup output file
32
+ --file PATH Restore input file
33
+ --wrangler COMMAND Wrangler command (default: npx wrangler)
34
+ --yes Confirm a remote staging refresh
35
+ --confirm-production Explicitly permit production migration or breaker changes
36
+ --confirm Confirm a destructive or release operation
37
+ --dry-run Show release checks without changing Git or npm
38
+ --version MAJOR.MINOR Explicit release target; patch is assigned as .0
39
+ --bypass-lint Skip the release preflight lint (use sparingly)
40
+ --json Return machine-readable output
41
+ --help Show this help
42
+ `;
43
+
44
+ function splitCommand(value = "npx wrangler") {
45
+ return value.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)?.map((part) => part.replace(/^(["'])(.*)\1$/, "$2")) ?? ["npx", "wrangler"];
46
+ }
47
+
48
+ function parseOptions(args, env = process.env) {
49
+ const options = {
50
+ config: env.CF_GENAI_WRANGLER_CONFIG || "wrangler.jsonc",
51
+ database: env.CF_GENAI_DATABASE || "DB",
52
+ productionEnv: env.CF_GENAI_PRODUCTION_ENV || "",
53
+ stagingEnv: env.CF_GENAI_STAGING_ENV || "staging",
54
+ wranglerCommand: splitCommand(env.CF_GENAI_WRANGLER || "npx wrangler"),
55
+ yes: false,
56
+ confirmProduction: false,
57
+ target: env.CF_GENAI_TARGET || "local",
58
+ roles: undefined,
59
+ scopes: undefined,
60
+ groups: undefined,
61
+ tenants: undefined,
62
+ name: undefined,
63
+ json: false,
64
+ };
65
+ const positional = [];
66
+ for (let index = 0; index < args.length; index += 1) {
67
+ const arg = args[index];
68
+ if (arg === "--help" || arg === "-h") return { ...options, help: true, positional };
69
+ if (arg === "--yes") options.yes = true;
70
+ else if (arg === "--json") options.json = true
71
+ else if (arg === "--confirm-production") options.confirmProduction = true;
72
+ else if (arg.startsWith("--")) {
73
+ const [key, inline] = arg.slice(2).split("=", 2);
74
+ const names = {
75
+ database: "database", "production-database": "productionDatabase",
76
+ "production-env": "productionEnv", "staging-env": "stagingEnv",
77
+ config: "config", wrangler: "wrangler", target: "target", env: "target", roles: "roles", scopes: "scopes", groups: "groups", tenants: "tenants", name: "name", output: "output", file: "file",
78
+ };
79
+ const optionName = names[key];
80
+ if (!optionName) throw new Error(`Unknown option: ${arg}`);
81
+ const value = inline ?? args[++index];
82
+ if (value === undefined) throw new Error(`Missing value for ${arg}.`);
83
+ if (optionName === "wrangler") options.wranglerCommand = splitCommand(value);
84
+ else options[optionName] = value;
85
+ } else positional.push(arg);
86
+ }
87
+ return { ...options, positional, productionDatabase: options.productionDatabase || options.database };
88
+ }
89
+
90
+ export async function main(args = process.argv.slice(2), env = process.env) {
91
+ if (args[0]?.includes(":")) { const [domain, action] = args[0].split(":", 2); if (["user", "tenant", "healthcheck", "healthchecks", "circuit-breaker", "circuit-breakers", "circuit"].includes(domain)) args = [domain, action, ...args.slice(1)]; }
92
+ if (args[0] === "version") return runVersionCommand();
93
+ const projectCommand = args[0];
94
+ if (["check", "test", "build", "ci", "ci:lint", "lint", "dev", "upgrade", "release", "release-status"].includes(projectCommand)) {
95
+ const result = runProjectCommand(projectCommand, args.slice(1));
96
+ if (result === null) throw new Error(`Unknown command.\n\n${usage}`);
97
+ return result;
98
+ }
99
+ const options = parseOptions(args, env);
100
+ if (options.positional[0] === "status") return siteStatus(options);
101
+ if (options.help || options.positional.length === 0) {
102
+ console.log(usage);
103
+ return;
104
+ }
105
+ let [domain, action, target] = options.positional;
106
+ if (domain === "admin") { const adminAction = { user: "users", tenant: "tenants", healthcheck: "healthchecks", "circuit-breaker": "circuit-breakers", circuit: "circuit-breakers" }[action] || action; if (["local", "staging", "prod", "production"].includes(target)) options.target = target === "prod" ? "production" : target; if (adminAction === "tenants") return runTenantCommand({ action: options.positional[2] || "list", identifier: options.positional[3], name: options.name, options }); if (["healthchecks", "circuit-breakers"].includes(adminAction)) return runOperationalCommand({ domain: adminAction, action: options.positional[2] || "list", identifier: options.positional[3], value: options.positional[4], options }); return runOperationalCommand({ domain: "admin", action: adminAction, identifier: options.positional[3], value: options.positional[4], options }); }
107
+ if (domain === "backup" || domain === "restore") { target = action || options.target; action = domain; return runD1FileCommand(action, target, options); }
108
+ if (domain === "user") return runUserCommand({ action, username: target, options });
109
+ if (domain === "tenant") return runTenantCommand({ action, identifier: target, name: options.name, options });
110
+ if (["healthcheck", "healthchecks", "circuit-breaker", "circuit-breakers", "circuit"].includes(domain)) return runOperationalCommand({ domain, action, identifier: target, value: options.positional[3], options });
111
+ if (domain === "config" && action === "check") return checkConfig(options);
112
+ if (domain !== "d1" || !["refresh", "backup", "restore", "migrate", "status", "check"].includes(action)) throw new Error(`Unknown command.\n\n${usage}`);
113
+ target = target === "prod" ? "production" : target;
114
+ if (!["local", "staging", "production"].includes(target)) throw new Error("Target must be local, staging, or production.");
115
+ if (action === "backup" || action === "restore") return runD1FileCommand(action, target, options);
116
+ if (action === "refresh") {
117
+ if (target === "staging" && !options.yes) throw new Error("Staging refresh replaces remote data; rerun with --yes.");
118
+ if (target === "local") return refreshLocalD1({ database: options.database, productionDatabase: options.productionDatabase, options });
119
+ if (target === "staging") return refreshStagingD1({ database: options.database, productionDatabase: options.productionDatabase, options });
120
+ throw new Error(`Unknown command.\n\n${usage}`);
121
+ }
122
+ if (action === "migrate" && target === "production" && !options.confirmProduction) throw new Error("Production migration requires --confirm-production.");
123
+ if (action === "migrate") return migrateD1({ target, database: options.database, options });
124
+ if (action === "status") return statusD1({ target, database: options.database, options });
125
+ return checkD1({ target, database: options.database, options });
126
+ }
127
+
128
+ function runD1FileCommand(action, target, options) {
129
+ if (action === "backup") return backupD1({ target, database: options.database, options });
130
+ return restoreD1({ target, database: options.database, options });
131
+ }
132
+
133
+ export { parseOptions, usage };