@agilesyndrome/cf-genai-base 5.0.0 → 5.0.2

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.
@@ -0,0 +1,34 @@
1
+ import { executeJson, parseJsonRows } from "./d1.js";
2
+
3
+ function sql(value) { return "'" + String(value).replaceAll("'", "''") + "'"; }
4
+ function query(database, target, command, options) { return parseJsonRows(executeJson(options.wranglerCommand, database, target, command, options)); }
5
+ function csv(value) { return String(value).split(",").map((item) => item.trim()).filter(Boolean); }
6
+
7
+ export function runUserCommand({ action, username, options }) {
8
+ const target = options.target || "local";
9
+ if (!["local", "staging", "production"].includes(target)) throw new Error("User target must be local, staging, or production.");
10
+ if (action === "list") {
11
+ const users = query(options.database, target, "SELECT id,email,display_name,provider,subject,is_admin,created_at,updated_at FROM auth_users ORDER BY email COLLATE NOCASE;", options);
12
+ return console.log(JSON.stringify(users.map((user) => ({ ...user, tenants: query(options.database, target, "SELECT t.id,t.name FROM auth_tenants t JOIN auth_user_tenants ut ON ut.tenant_id=t.id WHERE ut.user_id=" + sql(user.id) + " ORDER BY t.name COLLATE NOCASE;", options) })), null, 2));
13
+ }
14
+ if (!username) throw new Error("A username, email, subject, or user id is required.");
15
+ const where = `(id=${sql(username)} OR email=${sql(username)} OR subject=${sql(username)})`;
16
+ const rows = query(options.database, target, `SELECT id,email,display_name,provider,subject,is_admin,created_at,updated_at FROM auth_users WHERE ${where} LIMIT 1;`, options);
17
+ if (!rows[0]) throw new Error(`User not found: ${username}`);
18
+ if (action === "get") {
19
+ const user = rows[0];
20
+ const scopes = query(options.database, target, `SELECT scope_name,granted_at FROM auth_user_scopes WHERE user_id=${sql(user.id)} ORDER BY scope_name;`, options);
21
+ const groups = query(options.database, target, `SELECT group_name,granted_at FROM auth_user_groups WHERE user_id=${sql(user.id)} ORDER BY group_name;`, options);
22
+ const tenants = query(options.database, target, `SELECT t.id,t.name,ut.joined_at FROM auth_tenants t JOIN auth_user_tenants ut ON ut.tenant_id=t.id WHERE ut.user_id=${sql(user.id)} ORDER BY t.name COLLATE NOCASE;`, options);
23
+ return console.log(JSON.stringify({ ...user, scopes, groups, tenants }, null, 2));
24
+ }
25
+ if (action !== "update") throw new Error("Unknown user action.");
26
+ const assignments = [];
27
+ if (options.roles !== undefined) assignments.push(`is_admin=${csv(options.roles).includes("admin") ? 1 : 0}`);
28
+ if (!assignments.length && options.scopes === undefined && options.groups === undefined && options.tenants === undefined) throw new Error("user:update requires --roles, --scopes, --groups, or --tenants.");
29
+ if (assignments.length) query(options.database, target, `UPDATE auth_users SET ${assignments.join(",")},updated_at=CURRENT_TIMESTAMP WHERE id=${sql(rows[0].id)};`, options);
30
+ if (options.scopes !== undefined) { query(options.database, target, "DELETE FROM auth_user_scopes WHERE user_id=" + sql(rows[0].id) + ";", options); for (const scope of csv(options.scopes)) query(options.database, target, "INSERT OR IGNORE INTO auth_user_scopes (user_id,scope_name,granted_by) VALUES (" + sql(rows[0].id) + "," + sql(scope) + ",'human:cli');", options); }
31
+ if (options.groups !== undefined) { query(options.database, target, "DELETE FROM auth_user_groups WHERE user_id=" + sql(rows[0].id) + ";", options); for (const group of csv(options.groups)) query(options.database, target, "INSERT OR IGNORE INTO auth_user_groups (user_id,group_name,granted_by) VALUES (" + sql(rows[0].id) + "," + sql(group) + ",'human:cli');", options); }
32
+ if (options.tenants !== undefined) { const tenants = csv(options.tenants); if (tenants.length) { const available = query(options.database, target, "SELECT id FROM auth_tenants WHERE id IN (" + tenants.map(sql).join(",") + ");", options); const found = new Set(available.map((tenant) => tenant.id)); if (found.size !== tenants.length) throw new Error("One or more tenants do not exist."); } query(options.database, target, "DELETE FROM auth_user_tenants WHERE user_id=" + sql(rows[0].id) + ";", options); for (const tenant of tenants) query(options.database, target, "INSERT OR IGNORE INTO auth_user_tenants (user_id,tenant_id) VALUES (" + sql(rows[0].id) + "," + sql(tenant) + ");", options); }
33
+ return runUserCommand({ action: "get", username: rows[0].id, options });
34
+ }
@@ -0,0 +1,54 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+
4
+ export const PACKAGE_NAME = "@agilesyndrome/cf-genai-base";
5
+
6
+ export function localVersion() {
7
+ const packageUrl = new URL("../../package.json", import.meta.url);
8
+ return JSON.parse(readFileSync(packageUrl, "utf8")).version;
9
+ }
10
+
11
+ export function latestVersion(packageName = PACKAGE_NAME) {
12
+ const result = spawnSync("npm", ["view", packageName, "version"], {
13
+ stdio: ["ignore", "pipe", "pipe"],
14
+ encoding: "utf8",
15
+ });
16
+ if (result.error || result.status !== 0) return null;
17
+ const value = result.stdout.trim();
18
+ return value || null;
19
+ }
20
+
21
+ function parsedVersion(value) {
22
+ const match = String(value).trim().replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);
23
+ if (!match) return null;
24
+ return {
25
+ numbers: match.slice(1, 4).map(Number),
26
+ prerelease: match[4]?.split(".") ?? [],
27
+ };
28
+ }
29
+
30
+ export function compareVersions(left, right) {
31
+ const a = parsedVersion(left);
32
+ const b = parsedVersion(right);
33
+ if (!a || !b) return String(left).localeCompare(String(right));
34
+ for (let index = 0; index < 3; index += 1) {
35
+ if (a.numbers[index] !== b.numbers[index]) return a.numbers[index] - b.numbers[index];
36
+ }
37
+ if (a.prerelease.length === 0 && b.prerelease.length > 0) return 1;
38
+ if (a.prerelease.length > 0 && b.prerelease.length === 0) return -1;
39
+ return String(a.prerelease).localeCompare(String(b.prerelease), undefined, { numeric: true });
40
+ }
41
+
42
+ export function formatVersionOutput({ local, latest, packageName = PACKAGE_NAME }) {
43
+ const lines = [`${packageName} local: ${local}`, `${packageName} latest: ${latest ?? "unavailable"}`];
44
+ if (latest && compareVersions(latest, local) > 0) {
45
+ lines.push("Upgrade available:", ` npm i -g ${packageName}`);
46
+ }
47
+ return lines.join("\n");
48
+ }
49
+
50
+ export function runVersionCommand() {
51
+ const local = localVersion();
52
+ const latest = latestVersion();
53
+ console.log(formatVersionOutput({ local, latest }));
54
+ }
@@ -13,11 +13,11 @@ export async function readJsonClone(request, maxBytes = 64 * 1024) {
13
13
  const declared = Number(request.headers.get("Content-Length"));
14
14
  if (Number.isFinite(declared) && declared > maxBytes) return { error: secureJson({ error: "Request body is too large" }, 413) };
15
15
  try {
16
- const value = await request.clone().json();
16
+ const value = await readBoundedJson(request, maxBytes);
17
17
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Object required");
18
- if (new TextEncoder().encode(JSON.stringify(value)).byteLength > maxBytes) return { error: secureJson({ error: "Request body is too large" }, 413) };
19
18
  return { value };
20
- } catch {
19
+ } catch (error) {
20
+ if (error?.status === 413) return { error: secureJson({ error: "Request body is too large" }, 413) };
21
21
  return { error: secureJson({ error: "Invalid JSON object request" }, 400) };
22
22
  }
23
23
  }
@@ -28,9 +28,8 @@ export async function readJson(request, maxBytes = 64 * 1024) {
28
28
  if (Number.isFinite(declared) && declared > maxBytes) throw Object.assign(new Error("Request body is too large"), { status: 413 });
29
29
  if (!request.body) throw Object.assign(new Error("Request body required"), { status: 400 });
30
30
  try {
31
- const value = await request.clone().json();
31
+ const value = await readBoundedJson(request, maxBytes);
32
32
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Object required");
33
- if (new TextEncoder().encode(JSON.stringify(value)).byteLength > maxBytes) throw Object.assign(new Error("Request body is too large"), { status: 413 });
34
33
  return value;
35
34
  } catch (error) {
36
35
  if (error?.status) throw error;
@@ -38,6 +37,24 @@ export async function readJson(request, maxBytes = 64 * 1024) {
38
37
  }
39
38
  }
40
39
 
40
+ async function readBoundedJson(request, maxBytes) {
41
+ const reader = request.clone().body?.getReader();
42
+ if (!reader) throw new Error("Request body required");
43
+ const chunks = [];
44
+ let total = 0;
45
+ while (true) {
46
+ const { done, value } = await reader.read();
47
+ if (done) break;
48
+ total += value.byteLength;
49
+ if (total > maxBytes) { void reader.cancel(); throw Object.assign(new Error("Request body is too large"), { status: 413 }); }
50
+ chunks.push(value);
51
+ }
52
+ const body = new Uint8Array(total);
53
+ let offset = 0;
54
+ for (const chunk of chunks) { body.set(chunk, offset); offset += chunk.byteLength; }
55
+ return JSON.parse(new TextDecoder().decode(body));
56
+ }
57
+
41
58
  export function secureJson(payload, status = 200, headers = {}) {
42
59
  return secureResponse(Response.json(payload, { status, headers: { "Cache-Control": "no-store", ...headers } }));
43
60
  }
@@ -1,4 +1,4 @@
1
- import { ensureSubscriptionManifest, SubscriptionError } from "../auth/index.js";
1
+ import { ensureScopes, ensureSubscriptionManifest, SubscriptionError } from "../auth/index.js";
2
2
  import { auditedD1, createEventHandler, listFeatureHealth, requestActor, requestContext, secureResponse } from "../core/index.js";
3
3
  import { jobResponse, liveEventsResponse } from "../api/jobs.js";
4
4
  import { createRepositories } from "../repository.js";
@@ -11,21 +11,25 @@ import { adminBoundary } from "../admin/routes.js";
11
11
  import { ensureFeatureManifests } from "./features.js";
12
12
  import { healthResponse } from "./health.js";
13
13
 
14
+ const scopeManifestPromises = new WeakMap();
15
+
14
16
  export function createWorker({ fetch, scheduled, app = { name: "worker" }, auth, authorize, scopes = [], subscriptionManifest = [], scopeRoutes = [], apiRoutes = [], middleware = [], features = [], dataResources = [], repositories = [], publicTenantId = null, health, boot, security = true, eventHubBinding = "EVENT_HUB" }) {
15
17
  if (typeof fetch !== "function") throw new TypeError("createWorker requires a fetch handler");
16
18
  const application = defineApp(app); const provider = auth || features.find((feature) => typeof feature?.getUser === "function"); const eventHandler = createEventHandler(features, { eventHubBinding });
17
19
  const repositoryDefinitions = [...repositories, ...features.flatMap((feature) => Array.isArray(feature?.repositories) ? feature.repositories : [])];
18
20
  const registeredDataResources = normalizeDataResources([...dataResources, ...features.flatMap((feature) => Array.isArray(feature?.dataResources) ? feature.dataResources : []), ...repositoryDefinitions.filter((definition) => definition.resourceDefinition).map((definition) => definition.resourceDefinition)]);
19
21
  const featureRoutes = features.flatMap((feature) => Array.isArray(feature?.routes) ? [async (request, env, ctx, next, state) => { for (const route of feature.routes) { const matches = typeof route?.match === "function" ? await route.match(request, env, state) : route?.path === new URL(request.url).pathname; if (matches && typeof route.handle === "function") return route.handle({ request, env, ctx, state, next }); } return next(); }] : []);
20
- const chain = [(request, env, ctx, next, state) => adminBoundary(request, env, ctx, next, state, { provider, authorize, scopes, scopeRoutes, features }), ...(apiRoutes.length ? [(request, env, ctx, next, state) => dispatchRoutes(request, env, ctx, state, apiRoutes).then((response) => response || next(request))] : []), ...features.flatMap((feature) => feature?.middleware ? [feature.middleware.bind(feature)] : []), ...featureRoutes, ...middleware, ...(auth ? [(request, env, ctx, next) => auth(request, env, ctx, next)] : [])].filter(Boolean);
22
+ const chain = [(request, env, ctx, next, state) => adminBoundary(request, env, ctx, next, state, { provider, authorize, scopes: [], scopeRoutes, features }), ...(apiRoutes.length ? [(request, env, ctx, next, state) => dispatchRoutes(request, env, ctx, state, apiRoutes).then((response) => response || next(request))] : []), ...features.flatMap((feature) => feature?.middleware ? [feature.middleware.bind(feature)] : []), ...featureRoutes, ...middleware, ...(auth ? [(request, env, ctx, next) => auth(request, env, ctx, next)] : [])].filter(Boolean);
21
23
  return {
22
24
  async fetch(request, env, ctx) {
23
25
  try {
24
26
  if (boot) await boot(env, { request, ctx });
25
27
  if (subscriptionManifest.length) await ensureSubscriptionManifest(env, subscriptionManifest, { who: "system:update" });
26
28
  const url = new URL(request.url); const state = Object.create(null); state.requestId = crypto.randomUUID();
29
+ if (env?.DB && scopes.length && isAdminPath(url.pathname)) await ensureScopeManifest(env, scopes);
27
30
  if (provider?.getUser) state.user = await provider.getUser(request, env).catch(() => null); if (state.user?.authUser) state.authUser = state.user.authUser;
28
- state.data = createDataReader(env, { resources: registeredDataResources, context: () => requestDataContext(env, { state, request, publicTenantId }) });
31
+ let dataContext;
32
+ state.data = createDataReader(env, { resources: registeredDataResources, context: () => dataContext ||= requestDataContext(env, { state, request, publicTenantId }) });
29
33
  const requestEnv = Object.create(env || null);
30
34
  Object.assign(requestEnv, { app: application, DB: env?.DB ? auditedD1(env.DB, requestActor(state)) : env?.DB, data: state.data, repositories: createRepositories(requestEnv, repositoryDefinitions), user: state.user || null, authUser: state.authUser || null, userId: state.authUser?.id || null, eventHandler: (event, eventEnv, eventCtx) => eventHandler(event, eventEnv || requestEnv, eventCtx || ctx) });
31
35
  requestEnv.requestId = state.requestId; state.context = requestContext({ request, env: requestEnv, ctx, state, data: state.data }); requestEnv.context = state.context; requestEnv.event = state.context.event;
@@ -43,3 +47,12 @@ export function createWorker({ fetch, scheduled, app = { name: "worker" }, auth,
43
47
  }
44
48
 
45
49
  function withRequestId(response, requestId) { const headers = new Headers(response.headers); headers.set("X-Request-ID", String(requestId)); return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); }
50
+ function isAdminPath(pathname) { return pathname === "/admin" || pathname.startsWith("/admin/") || pathname === "/api/admin" || pathname.startsWith("/api/admin/"); }
51
+ function ensureScopeManifest(env, scopes) {
52
+ let registrations = scopeManifestPromises.get(env.DB);
53
+ if (!registrations) { registrations = new Map(); scopeManifestPromises.set(env.DB, registrations); }
54
+ const key = JSON.stringify(scopes);
55
+ let promise = registrations.get(key);
56
+ if (!promise) { promise = ensureScopes(env, scopes, { who: "system:update" }); registrations.set(key, promise); promise.catch(() => registrations.delete(key)); }
57
+ return promise;
58
+ }