@agilesyndrome/cf-genai-base 2.0.0 → 2.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.
package/CONTRACT.md CHANGED
@@ -62,6 +62,8 @@ 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
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.
65
67
  Request handlers receive `state.data`, whose scope-specific readers apply the
66
68
  validated user or tenant predicate. Domain handlers must not use unrestricted
67
69
  `env.DB` for registered resources. Base cannot provide row-level security to
package/README.md CHANGED
@@ -82,6 +82,9 @@ the reader applies ownership predicates, supports bounded cursor pagination via
82
82
  `.page()`, and never accepts raw SQL. Resources can explicitly restrict their
83
83
  operations to `read`, `create`, `update`, and `delete`.
84
84
 
85
+ Anonymous tenant reads require both `publicTenantId` on `createWorker` and
86
+ `publicRead: true` on the resource; they never grant anonymous system access.
87
+
85
88
  For example, a tenant-owned resource registers its `tenant_id` column with
86
89
  base, while feature code calls `state.data.tenant.list("recipes")` without
87
90
  passing a tenant ID. The active tenant must be a validated membership. A
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agilesyndrome/cf-genai-base",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.js",
package/src/data.js CHANGED
@@ -36,7 +36,7 @@ export function normalizeDataResources(resources = []) {
36
36
  if (writableColumns.some((column) => !columns.includes(column))) throw new TypeError(`Data resource ${resource.name} references an unwritable column`);
37
37
  const operations = [...new Set((resource.operations || ["read", ...(writableColumns.length ? ["create", "update", "delete"] : [])]).map((operation) => String(operation).toLowerCase()))];
38
38
  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 };
39
+ return { ...resource, name, table: String(resource.table), scope, columns, idColumn, ownerColumn, tenantColumn, filterableColumns, orderableColumns, writableColumns, operations, publicRead: Boolean(resource.publicRead) };
40
40
  });
41
41
  }
42
42
 
@@ -122,18 +122,18 @@ export function createDataReader(env, { resources = [], context } = {}) {
122
122
  return { user: scope("user"), tenant: scope("tenant"), system: scope("system"), resources: [...registry.values()] };
123
123
  }
124
124
 
125
- export async function requestDataContext(env, { state = {}, request } = {}) {
125
+ export async function requestDataContext(env, { state = {}, request, publicTenantId = null } = {}) {
126
126
  const authUser = state.authUser || (state.user ? await ensureUser(env, state.user, { who: `user:${state.user.sub || "unknown"}` }) : null);
127
127
  const system = Boolean(state.user?.auth_strategy === "http_basic" || (authUser && authUser.is_admin));
128
- if (!authUser) return { userId: null, tenantId: null, system: false };
128
+ if (!authUser) return { userId: null, tenantId: publicTenantId, public: Boolean(publicTenantId), system: false };
129
129
  const tenants = await listUserTenants(env, authUser.id, { who: `user:${authUser.id}` });
130
130
  const requestedTenant = state.tenantId || request?.headers?.get("X-Tenant-ID") || null;
131
131
  const tenant = requestedTenant ? tenants.find((item) => item.id === requestedTenant) : tenants.length === 1 ? tenants[0] : null;
132
- return { userId: authUser.id, tenantId: tenant?.id || null, system, tenants };
132
+ return { userId: authUser.id, tenantId: tenant?.id || null, public: false, system, tenants };
133
133
  }
134
134
 
135
135
  function isAllowed(resource, requestedScope, actor, operation) {
136
- const allowed = requestedScope === "system" ? Boolean(actor.system) : requestedScope === resource.scope && (requestedScope === "user" ? Boolean(actor.userId) : Boolean(actor.userId && actor.tenantId));
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))));
137
137
  if (!allowed || !resource.operations.includes(operation)) {
138
138
  auditLog({ who: actorLabel(actor), operation: "deny", resource: `data:${resource.name}:${requestedScope}:${operation}` });
139
139
  return false;
package/src/index.js CHANGED
@@ -7,7 +7,7 @@ import { getCircuitBreaker, evaluateCircuitBreaker, listCircuitBreakers, listHea
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 = [], health, boot, metrics, security = true, adminPage, siteAdminPage }) {
10
+ export function createWorker({ fetch, scheduled, auth, authorize, scopes = [], scopeRoutes = [], middleware = [], features = [], dataResources = [], publicTenantId = null, health, boot, metrics, security = true, adminPage, siteAdminPage }) {
11
11
  if (typeof fetch !== "function") throw new TypeError("createWorker requires a fetch handler");
12
12
  const provider = auth || features.find((feature) => typeof feature?.getUser === "function");
13
13
  const registeredDataResources = normalizeDataResources([...dataResources, ...features.flatMap((feature) => Array.isArray(feature?.dataResources) ? feature.dataResources : [])]);
@@ -23,7 +23,7 @@ export function createWorker({ fetch, scheduled, auth, authorize, scopes = [], s
23
23
  if (boot) await boot(env, { request, ctx });
24
24
  const url = new URL(request.url);
25
25
  const state = Object.create(null);
26
- state.data = createDataReader(env, { resources: registeredDataResources, context: () => requestDataContext(env, { state, request }) });
26
+ state.data = createDataReader(env, { resources: registeredDataResources, context: () => requestDataContext(env, { state, request, publicTenantId }) });
27
27
  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
28
  const dispatch = async (index, currentRequest = request) => {
29
29
  const layer = chain[index];