@agilesyndrome/cf-genai-base 2.0.1 → 2.1.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.
package/CONTRACT.md CHANGED
@@ -18,7 +18,7 @@ is optional and must use `ctx.waitUntil` for background work.
18
18
  - `scopes` registers an application scope manifest. `scopeRoutes` associates route prefixes or match functions with required scopes.
19
19
  - `adminPage` optionally renders the authorized platform admin browser pages so a site can keep the shared system menu and its own visual shell consistent.
20
20
  - `siteAdminPage` optionally renders site-owned pages below `/admin/site/*`, keeping them separate from the reserved platform page paths.
21
- - Base provides `/api/admin/users`, `/api/admin/scopes`, `/api/admin/groups`, `/api/admin/status`, `/api/admin/features`, `/api/admin/healthchecks`, `/api/admin/circuit-breakers`, and `/api/admin/users/:id/scopes|groups` for platform administrators when the authorization and core migrations are installed. `GET /api/admin/features` returns the installed runtime feature manifests, package names and versions, per-feature health rollups, healthchecks, and circuit breakers. The browser route `GET /admin/features` renders that catalog. Feature manifests may provide `name`, `displayName`, `packageName`, and `version`. The exported UI includes users, scopes, groups, healthchecks, and circuit-breaker catalogs.
21
+ - Base provides `/api/admin/users`, `/api/admin/scopes`, `/api/admin/groups`, `/api/admin/status`, `/api/admin/features`, `/api/admin/healthchecks`, `/api/admin/circuit-breakers`, and `/api/admin/users/:id/scopes|groups` for platform administrators when the authorization and core migrations are installed. It also provides short-lived `/api/admin/users/:id/impersonate` and `/api/admin/impersonate/clear` controls. `GET /api/tenant` returns the authenticated active tenant and validated memberships; invalid `X-Tenant-ID` values return 400. `GET /api/admin/features` returns the installed runtime feature manifests, package names and versions, per-feature health rollups, healthchecks, and circuit breakers. The browser route `/admin/features` renders that catalog. Feature manifests may provide `name`, `displayName`, `packageName`, and `version`. The exported UI includes users, scopes, groups, healthchecks, and circuit-breaker catalogs.
22
22
  - Public APIs must be explicitly listed in provider-specific auth configuration.
23
23
  - Mutating `/api/*` requests require a same-origin `Origin` header.
24
24
 
@@ -61,9 +61,11 @@ must remain in the application router rather than in the shared auth package.
61
61
  manifest through `feature.dataResources`. Each resource must declare a safe
62
62
  name, table, explicit columns, and one scope: `user`, `tenant`, or `system`.
63
63
  Resources may also declare allowed operations (`read`, `create`, `update`, and
64
- `delete`); reads support bounded cursor pagination through `reader.page()`.
65
- Anonymous tenant reads require an explicit worker `publicTenantId` and a
66
- resource-level `publicRead: true` declaration.
64
+ `delete`); reads support bounded native pagination through
65
+ `reader.page({ limit, offset })` and `reader.count()`, plus safe filtered
66
+ updates and deletes. Anonymous tenant reads require an explicit worker
67
+ `publicTenantId` and a resource-level `publicRead` declaration; object form
68
+ adds a row visibility predicate.
67
69
  Request handlers receive `state.data`, whose scope-specific readers apply the
68
70
  validated user or tenant predicate. Domain handlers must not use unrestricted
69
71
  `env.DB` for registered resources. Base cannot provide row-level security to
package/README.md CHANGED
@@ -78,12 +78,27 @@ Features may register D1 resources with `dataResources` and receive the
78
78
  scoped reader on the request state as `state.data`. Resources declare `user`,
79
79
  `tenant`, or `system` scope, their physical table, and an explicit column
80
80
  allowlist. Use `state.data.tenant`, `state.data.user`, or `state.data.system`;
81
- the reader applies ownership predicates, supports bounded cursor pagination via
82
- `.page()`, and never accepts raw SQL. Resources can explicitly restrict their
83
- operations to `read`, `create`, `update`, and `delete`.
84
-
85
- Anonymous tenant reads require both `publicTenantId` on `createWorker` and
86
- `publicRead: true` on the resource; they never grant anonymous system access.
81
+ the reader applies ownership predicates, supports bounded native pagination via
82
+ page with limit/offset, count, and safe bulk updateWhere/deleteWhere
83
+ operations, and never accepts raw SQL. Resources can explicitly restrict
84
+ their operations to read, create, update, and delete.
85
+
86
+ Anonymous tenant reads require both publicTenantId on createWorker and a
87
+ resource-level publicRead declaration. Use publicRead true only when the
88
+ whole resource is public; for opt-in rows use a publicRead column/value
89
+ declaration such as visibility=public. Anonymous reads never grant anonymous
90
+ system access.
91
+
92
+ Applications may pass subscriptionManifest to createWorker to register their
93
+ own subscription IDs and entitlement values. Base exposes
94
+ requireSubscription and requireEntitlement but does not know any
95
+ product-specific subscription such as VIP. Authenticated users can inspect
96
+ their validated active tenant at GET /api/tenant.
97
+
98
+ Administrators can start a short-lived, HttpOnly impersonation session with
99
+ POST /api/admin/users/:id/impersonate and clear it with
100
+ POST /api/admin/impersonate/clear. Impersonation affects scoped data context
101
+ only and does not grant the target user administrator permissions.
87
102
 
88
103
  For example, a tenant-owned resource registers its `tenant_id` column with
89
104
  base, while feature code calls `state.data.tenant.list("recipes")` without
@@ -35,11 +35,5 @@ CREATE INDEX IF NOT EXISTS auth_user_tenants_tenant_idx
35
35
  INSERT OR IGNORE INTO auth_tenants (id, name)
36
36
  VALUES ('easley-family', 'Easley Family');
37
37
 
38
- INSERT OR IGNORE INTO auth_subscriptions (id, name)
39
- VALUES ('vip', 'VIP');
40
-
41
- INSERT OR IGNORE INTO auth_tenant_subscriptions (tenant_id, subscription_id)
42
- VALUES ('easley-family', 'vip');
43
-
44
38
  INSERT OR IGNORE INTO auth_user_tenants (user_id, tenant_id)
45
39
  SELECT id, 'easley-family' FROM auth_users;
@@ -0,0 +1,13 @@
1
+ -- Application-defined subscription capabilities. Base stores the contract;
2
+ -- applications decide which subscriptions and entitlement keys they expose.
3
+ CREATE TABLE IF NOT EXISTS auth_subscription_entitlements (
4
+ subscription_id TEXT NOT NULL REFERENCES auth_subscriptions(id) ON DELETE CASCADE,
5
+ entitlement TEXT NOT NULL,
6
+ value_json TEXT NOT NULL DEFAULT 'true',
7
+ created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
8
+ updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
9
+ PRIMARY KEY (subscription_id, entitlement)
10
+ );
11
+
12
+ CREATE INDEX IF NOT EXISTS auth_subscription_entitlements_key_idx
13
+ ON auth_subscription_entitlements(entitlement);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agilesyndrome/cf-genai-base",
3
- "version": "2.0.1",
3
+ "version": "2.1.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.js",
@@ -5,8 +5,10 @@ export const AUTH_SCOPE_TABLE = "auth_scopes";
5
5
  export const AUTH_GRANT_TABLE = "auth_user_scopes";
6
6
  export const DEFAULT_TENANT_ID = "easley-family";
7
7
  export const DEFAULT_TENANT_NAME = "Easley Family";
8
- export const DEFAULT_SUBSCRIPTION_ID = "vip";
9
- export const DEFAULT_SUBSCRIPTION_NAME = "VIP";
8
+
9
+ export class SubscriptionError extends Error {
10
+ constructor(message) { super(message); this.name = "SubscriptionError"; }
11
+ }
10
12
 
11
13
  export function normalizeScopes(scopes = []) {
12
14
  return scopes.map((scope) => typeof scope === "string" ? { name: scope, label: scope, description: "", system: false } : scope)
@@ -44,8 +46,6 @@ export async function ensureUser(env, user, { who = "system:read" } = {}) {
44
46
  async function ensureDefaultTenantMembership(db, userId) {
45
47
  await db.batch([
46
48
  db.prepare("INSERT OR IGNORE INTO auth_tenants (id,name) VALUES (?,?)").bind(DEFAULT_TENANT_ID, DEFAULT_TENANT_NAME),
47
- db.prepare("INSERT OR IGNORE INTO auth_subscriptions (id,name) VALUES (?,?)").bind(DEFAULT_SUBSCRIPTION_ID, DEFAULT_SUBSCRIPTION_NAME),
48
- db.prepare("INSERT OR IGNORE INTO auth_tenant_subscriptions (tenant_id,subscription_id) VALUES (?,?)").bind(DEFAULT_TENANT_ID, DEFAULT_SUBSCRIPTION_ID),
49
49
  db.prepare("INSERT OR IGNORE INTO auth_user_tenants (user_id,tenant_id) VALUES (?,?)").bind(userId, DEFAULT_TENANT_ID)
50
50
  ]);
51
51
  }
@@ -59,7 +59,70 @@ export async function listUserTenants(env, userId, { who = "system:read" } = {})
59
59
  export async function listTenantSubscriptions(env, tenantId, { who = "system:read" } = {}) {
60
60
  const db = createD1(env, { who });
61
61
  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();
62
- return results || [];
62
+ return Promise.all((results || []).map(async (subscription) => ({ ...subscription, entitlements: await listSubscriptionEntitlements(env, subscription.id, { who }) })));
63
+ }
64
+
65
+ export async function listSubscriptionEntitlements(env, subscriptionId, { who = "system:read" } = {}) {
66
+ const db = createD1(env, { who });
67
+ const { results } = await db.prepare("SELECT entitlement,value_json FROM auth_subscription_entitlements WHERE subscription_id=? ORDER BY entitlement").bind(subscriptionId).all();
68
+ return (results || []).map((row) => ({ entitlement: row.entitlement, value: parseJsonValue(row.value_json) }));
69
+ }
70
+
71
+ export function normalizeSubscriptionManifest(manifest = []) {
72
+ return manifest.map((subscription) => ({
73
+ id: String(subscription?.id || "").trim(),
74
+ name: String(subscription?.name || subscription?.id || "").trim(),
75
+ entitlements: Object.fromEntries(Object.entries(subscription?.entitlements || {}).map(([key, value]) => [String(key), value])),
76
+ })).filter((subscription) => /^[a-z0-9][a-z0-9_-]*$/.test(subscription.id) && subscription.name);
77
+ }
78
+
79
+ export async function ensureSubscriptionManifest(env, manifest = [], { who = "system:update" } = {}) {
80
+ if (!env?.DB) return;
81
+ const db = createD1(env, { who });
82
+ for (const subscription of normalizeSubscriptionManifest(manifest)) {
83
+ 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();
84
+ for (const [entitlement, value] of Object.entries(subscription.entitlements)) {
85
+ 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();
86
+ }
87
+ }
88
+ }
89
+
90
+ export async function hasSubscription(env, tenantId, subscriptionId, { who = "system:read" } = {}) {
91
+ const db = createD1(env, { who });
92
+ return Boolean(await db.prepare("SELECT 1 FROM auth_tenant_subscriptions WHERE tenant_id=? AND subscription_id=?").bind(tenantId, subscriptionId).first());
93
+ }
94
+
95
+ export async function requireSubscription(env, tenantId, subscriptionId, { who = "system:read" } = {}) {
96
+ if (!await hasSubscription(env, tenantId, subscriptionId, { who })) throw new SubscriptionError("Required subscription is not active for this tenant.");
97
+ return true;
98
+ }
99
+
100
+ export async function hasEntitlement(env, tenantId, entitlement, expectedValue, { who = "system:read" } = {}) {
101
+ const db = createD1(env, { who });
102
+ 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();
103
+ return (rows.results || []).some((row) => expectedValue === undefined || deepEqual(parseJsonValue(row.value_json), expectedValue));
104
+ }
105
+
106
+ export async function requireEntitlement(env, tenantId, entitlement, expectedValue, { who = "system:read" } = {}) {
107
+ if (!await hasEntitlement(env, tenantId, entitlement, expectedValue, { who })) throw new SubscriptionError(`Required entitlement is not active: ${entitlement}.`);
108
+ return true;
109
+ }
110
+
111
+ export async function createImpersonationToken(env, adminUserId, targetUserId, { ttlSeconds = 900 } = {}) {
112
+ if (!env?.AUTH_SESSION_SECRET) throw new Error("AUTH_SESSION_SECRET is required for impersonation.");
113
+ const payload = { adminUserId: String(adminUserId || "admin"), targetUserId: String(targetUserId), exp: Math.floor(Date.now() / 1000) + Math.min(Math.max(Number(ttlSeconds) || 900, 60), 3600) };
114
+ const encoded = base64url(new TextEncoder().encode(JSON.stringify(payload)));
115
+ return `${encoded}.${await signValue(encoded, env?.AUTH_SESSION_SECRET || "")}`;
116
+ }
117
+
118
+ export async function verifyImpersonationToken(token, env) {
119
+ if (!env?.AUTH_SESSION_SECRET) return null;
120
+ const [encoded, signature] = String(token || "").split(".");
121
+ if (!encoded || !signature || !constantTimeEqual(signature, await signValue(encoded, env?.AUTH_SESSION_SECRET || ""))) return null;
122
+ try {
123
+ const payload = JSON.parse(new TextDecoder().decode(base64urlDecode(encoded)));
124
+ return payload.exp > Date.now() / 1000 && payload.targetUserId ? payload : null;
125
+ } catch { return null; }
63
126
  }
64
127
 
65
128
  export async function hasScope(env, user, scope, { who = "system:read" } = {}) {
@@ -77,6 +140,11 @@ export async function listAuthorizationUsers(env, { who = "system:read" } = {})
77
140
  return Promise.all(results.map(async (user) => ({ ...user, scopes: (await listUserGrants(env, user.id, { who })).map((grant) => grant.scope_name) })));
78
141
  }
79
142
 
143
+ export async function getAuthorizationUser(env, userId, { who = "system:read" } = {}) {
144
+ const db = createD1(env, { who });
145
+ 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();
146
+ }
147
+
80
148
  export async function listAuthorizationScopes(env, { who = "system:read" } = {}) {
81
149
  const db = createD1(env, { who });
82
150
  const { results } = await db.prepare(`SELECT name,label,description,system FROM ${AUTH_SCOPE_TABLE} ORDER BY name`).all();
@@ -110,3 +178,10 @@ async function stableId(value) {
110
178
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
111
179
  return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("").slice(0, 32);
112
180
  }
181
+
182
+ function parseJsonValue(value) { try { return JSON.parse(value); } catch { return value; } }
183
+ function deepEqual(left, right) { return JSON.stringify(left) === JSON.stringify(right); }
184
+ 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)); }
185
+ function base64url(bytes) { return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); }
186
+ function base64urlDecode(value) { const padded = value.replaceAll("-", "+").replaceAll("_", "/") + "=".repeat((4 - value.length % 4) % 4); return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0)); }
187
+ 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; }
package/src/data.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { auditLog, createD1 } from "./core.js";
2
- import { ensureUser, listUserTenants } from "./authorization.js";
2
+ import { ensureUser, getAuthorizationUser, listUserGrants, listUserTenants, verifyImpersonationToken } from "./authorization.js";
3
3
 
4
4
  export const DATA_SCOPES = ["user", "tenant", "system"];
5
5
  export const DATA_OPERATIONS = ["read", "create", "update", "delete"];
@@ -34,9 +34,13 @@ export function normalizeDataResources(resources = []) {
34
34
  for (const column of [...filterableColumns, ...orderableColumns]) if (!columns.includes(column)) throw new TypeError(`Data resource ${resource.name} references an unselected column`);
35
35
  const writableColumns = [...new Set((resource.writableColumns || []).map(String))].filter((column) => column !== ownerColumn && column !== tenantColumn);
36
36
  if (writableColumns.some((column) => !columns.includes(column))) throw new TypeError(`Data resource ${resource.name} references an unwritable column`);
37
+ const columnScopes = Object.fromEntries(Object.entries(resource.columnScopes || {}).map(([column, scopes]) => [String(column), Array.isArray(scopes) ? scopes.map(String) : [String(scopes)]]));
38
+ if (Object.keys(columnScopes).some((column) => !columns.includes(column) || columnScopes[column].some((scope) => !/^[a-z0-9]+(?::[a-z0-9-]+)+$/.test(scope)))) throw new TypeError(`Data resource ${resource.name} has invalid column scopes`);
37
39
  const operations = [...new Set((resource.operations || ["read", ...(writableColumns.length ? ["create", "update", "delete"] : [])]).map((operation) => String(operation).toLowerCase()))];
38
40
  if (!operations.length || operations.some((operation) => !DATA_OPERATIONS.includes(operation)) || !operations.includes("read")) throw new TypeError(`Data resource ${name} has invalid operations`);
39
- return { ...resource, name, table: String(resource.table), scope, columns, idColumn, ownerColumn, tenantColumn, filterableColumns, orderableColumns, writableColumns, operations, publicRead: Boolean(resource.publicRead) };
41
+ const publicRead = resource.publicRead && typeof resource.publicRead === "object" ? { column: String(resource.publicRead.column || ""), value: resource.publicRead.value } : Boolean(resource.publicRead);
42
+ if (publicRead && typeof publicRead === "object" && (!/^[a-z][a-z0-9_]*$/.test(publicRead.column) || !columns.includes(publicRead.column))) throw new TypeError(`Public resource ${resource.name} requires a selected visibility column`);
43
+ return { ...resource, name, table: String(resource.table), scope, columns, idColumn, ownerColumn, tenantColumn, filterableColumns, orderableColumns, writableColumns, columnScopes, operations, publicRead };
40
44
  });
41
45
  }
42
46
 
@@ -46,17 +50,20 @@ export function createDataReader(env, { resources = [], context } = {}) {
46
50
  const scope = (requestedScope) => ({
47
51
  list: (name, options) => readList(name, requestedScope, options),
48
52
  page: (name, options) => readPage(name, requestedScope, options),
53
+ count: (name, options) => readCount(name, requestedScope, options),
49
54
  get: async (name, id) => (await readList(name, requestedScope, { where: { [registry.get(name)?.idColumn || "id"]: id }, limit: 1 }))[0] || null,
50
55
  insert: (name, values) => writeInsert(name, requestedScope, values),
51
56
  update: (name, id, changes) => writeUpdate(name, requestedScope, id, changes),
57
+ updateWhere: (name, where, changes) => writeUpdateWhere(name, requestedScope, where, changes),
52
58
  delete: (name, id) => writeDelete(name, requestedScope, id),
59
+ deleteWhere: (name, where) => writeDeleteWhere(name, requestedScope, where),
53
60
  });
54
61
 
55
- async function readList(name, requestedScope, { where = {}, limit = 100, orderBy } = {}) {
56
- return (await readPage(name, requestedScope, { where, limit, orderBy })).rows;
62
+ async function readList(name, requestedScope, { where = {}, limit = 100, orderBy, offset = 0 } = {}) {
63
+ return (await readPage(name, requestedScope, { where, limit, orderBy, offset })).rows;
57
64
  }
58
65
 
59
- async function readPage(name, requestedScope, { where = {}, limit = 100, orderBy, cursor } = {}) {
66
+ async function readPage(name, requestedScope, { where = {}, limit = 100, orderBy, cursor, offset = 0 } = {}) {
60
67
  const resource = getResource(name);
61
68
  const actor = await getContext();
62
69
  if (!isAllowed(resource, requestedScope, actor, "read")) return { rows: [], nextCursor: null };
@@ -66,7 +73,8 @@ export function createDataReader(env, { resources = [], context } = {}) {
66
73
  addFilters(resource, where, predicates, bindings);
67
74
  if (cursor !== undefined && cursor !== null) { predicates.push(`${quote(resource.idColumn)}>?`); bindings.push(cursor); }
68
75
  const safeLimit = Math.min(Math.max(Number(limit) || 100, 1), 1000);
69
- let sql = `SELECT ${resource.columns.map(quote).join(",")} FROM ${quote(resource.table)}${predicates.length ? ` WHERE ${predicates.join(" AND ")}` : ""} LIMIT ${safeLimit}`;
76
+ const safeOffset = Math.max(Number(offset) || 0, 0);
77
+ let sql = `SELECT ${resource.columns.map(quote).join(",")} FROM ${quote(resource.table)}${predicates.length ? ` WHERE ${predicates.join(" AND ")}` : ""} LIMIT ${safeLimit} OFFSET ${safeOffset}`;
70
78
  if (orderBy) {
71
79
  const [column, direction = "ASC"] = String(orderBy).split(/\s+/, 2);
72
80
  if (!resource.orderableColumns.includes(column)) throw new TypeError(`Column ${column} cannot order ${resource.name}`);
@@ -77,16 +85,30 @@ export function createDataReader(env, { resources = [], context } = {}) {
77
85
  return { rows, nextCursor: rows.length === safeLimit ? rows[rows.length - 1][resource.idColumn] : null };
78
86
  }
79
87
 
88
+ async function readCount(name, requestedScope, { where = {} } = {}) {
89
+ const resource = getResource(name);
90
+ const actor = await getContext();
91
+ if (!isAllowed(resource, requestedScope, actor, "read")) return 0;
92
+ const predicates = [];
93
+ const bindings = [];
94
+ addScopePredicate(resource, requestedScope, actor, predicates, bindings);
95
+ addFilters(resource, where, predicates, bindings);
96
+ const result = await createD1(env, { who: actorLabel(actor) }).prepare(`SELECT COUNT(*) AS count FROM ${quote(resource.table)}${predicates.length ? ` WHERE ${predicates.join(" AND ")}` : ""}`).bind(...bindings).first();
97
+ return Number(result?.count || 0);
98
+ }
99
+
80
100
  async function writeInsert(name, requestedScope, values = {}) {
81
101
  const resource = getResource(name);
82
102
  const actor = await getContext();
83
103
  assertWritable(resource, requestedScope, actor, "create");
84
104
  const data = cleanWritableValues(resource, values, { includeId: true, includeOwnership: requestedScope === "system" });
105
+ assertColumnScopes(resource, actor, Object.keys(data));
85
106
  addOwnedValue(resource, requestedScope, actor, data);
86
107
  const columns = Object.keys(data);
87
108
  if (!columns.length) throw new TypeError(`No writable values supplied for ${resource.name}`);
88
- await createD1(env, { who: actorLabel(actor) }).prepare(`INSERT INTO ${quote(resource.table)} (${columns.map(quote).join(",")}) VALUES (${columns.map(() => "?").join(",")})`).bind(...columns.map((column) => data[column])).run();
89
- return data[resource.idColumn] === undefined ? data : (await readList(name, requestedScope, { where: { [resource.idColumn]: data[resource.idColumn] }, limit: 1 }))[0] || data;
109
+ const result = await createD1(env, { who: actorLabel(actor) }).prepare(`INSERT INTO ${quote(resource.table)} (${columns.map(quote).join(",")}) VALUES (${columns.map(() => "?").join(",")})`).bind(...columns.map((column) => data[column])).run();
110
+ const insertedId = data[resource.idColumn] === undefined ? result?.meta?.last_row_id : data[resource.idColumn];
111
+ return insertedId === undefined ? data : (await readList(name, requestedScope, { where: { [resource.idColumn]: insertedId }, limit: 1 }))[0] || { ...data, [resource.idColumn]: insertedId };
90
112
  }
91
113
 
92
114
  async function writeUpdate(name, requestedScope, id, changes = {}) {
@@ -94,6 +116,7 @@ export function createDataReader(env, { resources = [], context } = {}) {
94
116
  const actor = await getContext();
95
117
  assertWritable(resource, requestedScope, actor, "update");
96
118
  const data = cleanWritableValues(resource, changes, { includeOwnership: requestedScope === "system" });
119
+ assertColumnScopes(resource, actor, Object.keys(data));
97
120
  const columns = Object.keys(data);
98
121
  if (!columns.length) throw new TypeError(`No writable values supplied for ${resource.name}`);
99
122
  const predicates = [`${quote(resource.idColumn)}=?`];
@@ -113,27 +136,60 @@ export function createDataReader(env, { resources = [], context } = {}) {
113
136
  return createD1(env, { who: actorLabel(actor) }).prepare(`DELETE FROM ${quote(resource.table)} WHERE ${predicates.join(" AND ")}`).bind(...bindings).run();
114
137
  }
115
138
 
139
+ async function writeUpdateWhere(name, requestedScope, where, changes) {
140
+ const resource = getResource(name);
141
+ const actor = await getContext();
142
+ assertWritable(resource, requestedScope, actor, "update");
143
+ const data = cleanWritableValues(resource, changes, { includeOwnership: requestedScope === "system" });
144
+ assertColumnScopes(resource, actor, Object.keys(data));
145
+ const columns = Object.keys(data);
146
+ if (!columns.length) throw new TypeError(`No writable values supplied for ${resource.name}`);
147
+ const predicates = [];
148
+ const bindings = [];
149
+ addScopePredicate(resource, requestedScope, actor, predicates, bindings);
150
+ addFilters(resource, where, predicates, bindings);
151
+ await createD1(env, { who: actorLabel(actor) }).prepare(`UPDATE ${quote(resource.table)} SET ${columns.map((column) => `${quote(column)}=?`).join(",")} WHERE ${predicates.join(" AND ")}`).bind(...columns.map((column) => data[column]), ...bindings).run();
152
+ return readCount(name, requestedScope, { where });
153
+ }
154
+
155
+ async function writeDeleteWhere(name, requestedScope, where) {
156
+ const resource = getResource(name);
157
+ const actor = await getContext();
158
+ assertWritable(resource, requestedScope, actor, "delete");
159
+ const predicates = [];
160
+ const bindings = [];
161
+ addScopePredicate(resource, requestedScope, actor, predicates, bindings);
162
+ addFilters(resource, where, predicates, bindings);
163
+ return createD1(env, { who: actorLabel(actor) }).prepare(`DELETE FROM ${quote(resource.table)} WHERE ${predicates.join(" AND ")}`).bind(...bindings).run();
164
+ }
165
+
116
166
  function getResource(name) {
117
167
  const resource = registry.get(String(name));
118
168
  if (!resource) throw new TypeError(`Unknown data resource: ${name}`);
119
169
  return resource;
120
170
  }
121
171
 
122
- return { user: scope("user"), tenant: scope("tenant"), system: scope("system"), resources: [...registry.values()] };
172
+ return { user: scope("user"), tenant: scope("tenant"), system: scope("system"), context: getContext, resources: [...registry.values()] };
123
173
  }
124
174
 
125
175
  export async function requestDataContext(env, { state = {}, request, publicTenantId = null } = {}) {
126
- const authUser = state.authUser || (state.user ? await ensureUser(env, state.user, { who: `user:${state.user.sub || "unknown"}` }) : null);
176
+ let authUser = state.authUser || (state.user ? await ensureUser(env, state.user, { who: `user:${state.user.sub || "unknown"}` }) : null);
127
177
  const system = Boolean(state.user?.auth_strategy === "http_basic" || (authUser && authUser.is_admin));
128
178
  if (!authUser) return { userId: null, tenantId: publicTenantId, public: Boolean(publicTenantId), system: false };
129
- const tenants = await listUserTenants(env, authUser.id, { who: `user:${authUser.id}` });
179
+ const impersonation = system ? await verifyImpersonationToken(request?.headers?.get("X-CF-GenAI-Impersonation") || readCookie(request, "__Host-cfgenai_impersonation"), env) : null;
180
+ if (impersonation) {
181
+ const targetUser = await getAuthorizationUser(env, impersonation.targetUserId, { who: `user:${impersonation.adminUserId}` });
182
+ if (targetUser) authUser = targetUser;
183
+ }
184
+ const who = `user:${authUser.id}`;
185
+ const [tenants, grants] = await Promise.all([listUserTenants(env, authUser.id, { who }), listUserGrants(env, authUser.id, { who })]);
130
186
  const requestedTenant = state.tenantId || request?.headers?.get("X-Tenant-ID") || null;
131
187
  const tenant = requestedTenant ? tenants.find((item) => item.id === requestedTenant) : tenants.length === 1 ? tenants[0] : null;
132
- return { userId: authUser.id, tenantId: tenant?.id || null, public: false, system, tenants };
188
+ return { userId: authUser.id, tenantId: tenant?.id || null, public: false, system, scopes: grants.map((grant) => grant.scope_name), tenants, invalidTenant: Boolean(requestedTenant && !tenant), impersonated: Boolean(impersonation), impersonatedBy: impersonation?.adminUserId || null };
133
189
  }
134
190
 
135
191
  function isAllowed(resource, requestedScope, actor, operation) {
136
- const allowed = requestedScope === "system" ? Boolean(actor.system) : requestedScope === resource.scope && (requestedScope === "user" ? Boolean(actor.userId) : Boolean(actor.tenantId && (actor.userId || (actor.public && resource.publicRead))));
192
+ const allowed = requestedScope === "system" ? Boolean(actor.system) : requestedScope === resource.scope && (requestedScope === "user" ? Boolean(actor.userId) : Boolean(actor.tenantId && (actor.userId || (actor.public && resource.publicRead))));
137
193
  if (!allowed || !resource.operations.includes(operation)) {
138
194
  auditLog({ who: actorLabel(actor), operation: "deny", resource: `data:${resource.name}:${requestedScope}:${operation}` });
139
195
  return false;
@@ -145,9 +201,17 @@ function assertWritable(resource, requestedScope, actor, operation) {
145
201
  if (!isAllowed(resource, requestedScope, actor, operation)) throw new DataScopeError(`Data scope ${requestedScope} cannot ${operation} resource ${resource.name}`);
146
202
  }
147
203
 
204
+ function assertColumnScopes(resource, actor, columns) {
205
+ if (actor.system) return;
206
+ for (const column of columns) {
207
+ const required = resource.columnScopes[column] || [];
208
+ if (required.some((scope) => !actor.scopes?.includes(scope))) throw new DataScopeError(`Missing scope to write ${resource.name}.${column}`);
209
+ }
210
+ }
211
+
148
212
  function addScopePredicate(resource, requestedScope, actor, predicates, bindings) {
149
213
  if (requestedScope === "user") { predicates.push(`${quote(resource.ownerColumn)}=?`); bindings.push(actor.userId); }
150
- if (requestedScope === "tenant") { predicates.push(`${quote(resource.tenantColumn)}=?`); bindings.push(actor.tenantId); }
214
+ if (requestedScope === "tenant") { predicates.push(`${quote(resource.tenantColumn)}=?`); bindings.push(actor.tenantId); if (actor.public && resource.publicRead && typeof resource.publicRead === "object") { predicates.push(`${quote(resource.publicRead.column)}=?`); bindings.push(resource.publicRead.value); } }
151
215
  }
152
216
 
153
217
  function addFilters(resource, where, predicates, bindings) {
@@ -169,3 +233,4 @@ function addOwnedValue(resource, requestedScope, actor, data) {
169
233
 
170
234
  function actorLabel(actor) { return actor.system ? "system:data" : `user:${actor.userId || "unknown"}`; }
171
235
  function quote(identifier) { return `"${identifier}"`; }
236
+ function readCookie(request, name) { const value = request?.headers?.get("Cookie") || ""; return value.split(";").map((part) => part.trim()).find((part) => part.startsWith(`${name}=`))?.slice(name.length + 1) || ""; }
package/src/index.js CHANGED
@@ -2,12 +2,13 @@
2
2
  * Lean, opinionated Worker composition for Cloudflare sites.
3
3
  * Site code owns domain routes and data; this owns lifecycle and edge concerns.
4
4
  */
5
- import { ensureScopes, ensureUser, hasScope, listAuthorizationScopes, listAuthorizationUsers, listGroups, listUserGroups, listUserGrants, replaceUserGroups, replaceUserGrants } from "./authorization.js";
5
+ import { createImpersonationToken, ensureScopes, ensureSubscriptionManifest, ensureUser, getAuthorizationUser, hasScope, listAuthorizationScopes, listAuthorizationUsers, listGroups, listTenantSubscriptions, listUserGroups, listUserGrants, replaceUserGroups, replaceUserGrants, SubscriptionError } from "./authorization.js";
6
6
  import { getCircuitBreaker, evaluateCircuitBreaker, listCircuitBreakers, listHealthchecks, listFeatureCatalog, listFeatureHealth, registerFeatureManifests, requestActor, setCircuitBreaker, updateHealthcheck } from "./core.js";
7
7
  import { createDataReader, DataScopeError, normalizeDataResources, requestDataContext } from "./data.js";
8
8
  export * from "./core.js";
9
9
  export * from "./data.js";
10
- export function createWorker({ fetch, scheduled, auth, authorize, scopes = [], scopeRoutes = [], middleware = [], features = [], dataResources = [], publicTenantId = null, health, boot, metrics, security = true, adminPage, siteAdminPage }) {
10
+ export * from "./authorization.js";
11
+ export function createWorker({ fetch, scheduled, auth, authorize, scopes = [], subscriptionManifest = [], scopeRoutes = [], middleware = [], features = [], dataResources = [], publicTenantId = null, health, boot, metrics, security = true, adminPage, siteAdminPage }) {
11
12
  if (typeof fetch !== "function") throw new TypeError("createWorker requires a fetch handler");
12
13
  const provider = auth || features.find((feature) => typeof feature?.getUser === "function");
13
14
  const registeredDataResources = normalizeDataResources([...dataResources, ...features.flatMap((feature) => Array.isArray(feature?.dataResources) ? feature.dataResources : [])]);
@@ -21,8 +22,10 @@ export function createWorker({ fetch, scheduled, auth, authorize, scopes = [], s
21
22
  async fetch(request, env, ctx) {
22
23
  try {
23
24
  if (boot) await boot(env, { request, ctx });
25
+ if (subscriptionManifest.length) await ensureSubscriptionManifest(env, subscriptionManifest, { who: "system:update" });
24
26
  const url = new URL(request.url);
25
27
  const state = Object.create(null);
28
+ if (provider?.getUser) state.user = await provider.getUser(request, env).catch(() => null);
26
29
  state.data = createDataReader(env, { resources: registeredDataResources, context: () => requestDataContext(env, { state, request, publicTenantId }) });
27
30
  if (env?.DB && features.some((feature) => typeof feature?.healthcheck === "function" || feature?.healthchecks?.length || feature?.healthChecks?.length || feature?.circuitBreakers?.length || feature?.circuit_breakers?.length)) ctx?.waitUntil?.(registerFeatureManifests(env, features, { who: "system:update" }).then(() => listCircuitBreakers(env, { who: "system:update" }).then((breakers) => Promise.all(breakers.filter(Boolean).map((breaker) => evaluateCircuitBreaker(env, breaker.id, { who: "system:update" }))))).catch((error) => console.error("[EventLog] feature manifest registration failed", error)));
28
31
  const dispatch = async (index, currentRequest = request) => {
@@ -33,6 +36,12 @@ export function createWorker({ fetch, scheduled, auth, authorize, scopes = [], s
33
36
  const featureHealth = env?.DB ? await listFeatureHealth(env, { who: "system:read" }).catch(() => []) : [];
34
37
  return healthResponse(env, featureHealth.length ? { ...details, features: featureHealth } : details);
35
38
  }
39
+ if (url.pathname === "/api/tenant" && currentRequest.method === "GET") {
40
+ const context = await state.data.context();
41
+ if (!context.userId) return Response.json({ error: "Authentication is required." }, { status: 401 });
42
+ if (context.invalidTenant) return Response.json({ error: "The requested tenant is not available." }, { status: 400 });
43
+ return Response.json({ tenant: context.tenantId ? { id: context.tenantId, name: context.tenants?.find((tenant) => tenant.id === context.tenantId)?.name || null } : null, tenants: context.tenants || [] });
44
+ }
36
45
  return fetch(currentRequest, env, ctx, state);
37
46
  }
38
47
  if (typeof layer !== "function") throw new TypeError("Worker middleware must be a function");
@@ -43,7 +52,7 @@ export function createWorker({ fetch, scheduled, auth, authorize, scopes = [], s
43
52
  return security ? secureResponse(response) : response;
44
53
  } catch (error) {
45
54
  console.error("[worker] request failed", error);
46
- if (error instanceof DataScopeError) return secureResponse(Response.json({ error: "Data access is not permitted." }, { status: 403, headers: { "Cache-Control": "no-store" } }));
55
+ if (error instanceof DataScopeError || error instanceof SubscriptionError) return secureResponse(Response.json({ error: error.message }, { status: 403, headers: { "Cache-Control": "no-store" } }));
47
56
  return secureResponse(Response.json({ error: "Internal server error" }, { status: 500, headers: { "Cache-Control": "no-store" } }));
48
57
  }
49
58
  },
@@ -108,10 +117,19 @@ function requiredScopeFor(pathname, routes) {
108
117
 
109
118
  async function authorizationApi(request, env, url, state, features = []) {
110
119
  const grantsMatch = url.pathname.match(/\/api\/admin\/users\/([^/]+)\/scopes$/);
111
- const platformPath = url.pathname === "/api/admin/users" || url.pathname === "/api/admin/scopes" || url.pathname === "/api/admin/groups" || url.pathname.startsWith("/api/admin/users/") || url.pathname === "/api/admin/status" || url.pathname === "/api/admin/features" || url.pathname === "/api/admin/healthchecks" || url.pathname === "/api/admin/circuit-breakers" || url.pathname.startsWith("/api/admin/healthchecks/") || url.pathname.startsWith("/api/admin/circuit-breakers/") || Boolean(grantsMatch);
120
+ const platformPath = url.pathname === "/api/admin/users" || url.pathname === "/api/admin/scopes" || url.pathname === "/api/admin/groups" || url.pathname.startsWith("/api/admin/users/") || url.pathname.startsWith("/api/admin/impersonate") || url.pathname === "/api/admin/status" || url.pathname === "/api/admin/features" || url.pathname === "/api/admin/healthchecks" || url.pathname === "/api/admin/circuit-breakers" || url.pathname.startsWith("/api/admin/healthchecks/") || url.pathname.startsWith("/api/admin/circuit-breakers/") || Boolean(grantsMatch);
112
121
  if (!platformPath) return null;
113
122
  if (!(state.user.auth_strategy === "http_basic" || (state.authUser && state.authUser.is_admin))) return Response.json({ error: "Administrator access is required." }, { status: 403, headers: { "Cache-Control": "no-store" } });
123
+ if (url.pathname === "/api/admin/impersonate/clear" && request.method === "POST") return new Response(JSON.stringify({ ok: true }), { headers: { "content-type": "application/json; charset=utf-8", "Set-Cookie": "__Host-cfgenai_impersonation=; Max-Age=0; Path=/; Secure; HttpOnly; SameSite=Lax" } });
114
124
  if (url.pathname === "/api/admin/users" && request.method === "GET") return Response.json({ users: await listAuthorizationUsers(env, { who: requestActor(state) }) });
125
+ const impersonateMatch = url.pathname.match(/^\/api\/admin\/users\/([^/]+)\/impersonate$/);
126
+ if (impersonateMatch && request.method === "POST") {
127
+ const target = decodeURIComponent(impersonateMatch[1]);
128
+ const targetUser = await getAuthorizationUser(env, target, { who: requestActor(state) });
129
+ if (!targetUser) return Response.json({ error: "User not found." }, { status: 404 });
130
+ const token = await createImpersonationToken(env, state.authUser?.id || state.user?.sub || "admin", targetUser.id);
131
+ return new Response(JSON.stringify({ ok: true, user: { id: targetUser.id, email: targetUser.email, display_name: targetUser.display_name }, expires_in: 900 }), { headers: { "content-type": "application/json; charset=utf-8", "Set-Cookie": `__Host-cfgenai_impersonation=${token}; Max-Age=900; Path=/; Secure; HttpOnly; SameSite=Lax` } });
132
+ }
115
133
  if (url.pathname === "/api/admin/scopes" && request.method === "GET") return Response.json({ scopes: await listAuthorizationScopes(env, { who: requestActor(state) }) });
116
134
  if (url.pathname === "/api/admin/status" && request.method === "GET") return Response.json({ features: await listFeatureHealth(env, { who: requestActor(state) }) });
117
135
  if (url.pathname === "/api/admin/features" && request.method === "GET") return Response.json({ features: await listFeatureCatalog(env, features, { who: requestActor(state) }) });