@agilesyndrome/cf-genai-base 5.0.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agilesyndrome/cf-genai-base",
3
- "version": "5.0.1",
3
+ "version": "5.0.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "cf-genai": "bin/cf-genai.js"
@@ -6,7 +6,7 @@ export async function adminBoundary(request, env, ctx, next, state, { provider,
6
6
  if (!isAdminPath(url.pathname)) return next(request);
7
7
  const strategy = String(env?.AUTH_STRATEGY || "http_basic").trim().toLowerCase();
8
8
  if (strategy === "http_basic") { const user = basicUser(request, env); if (!user) return adminUnauthorized(request); state.user = user; }
9
- else if (strategy === "oauth") { const user = provider?.getUser ? await provider.getUser(request, env) : null; if (!user) return oauthUnauthorized(request, url); state.user = user; }
9
+ else if (strategy === "oauth") { const user = Object.hasOwn(state, "user") ? state.user : provider?.getUser ? await provider.getUser(request, env) : null; if (!user) return oauthUnauthorized(request, url); state.user = user; }
10
10
  else return new Response("Unsupported AUTH_STRATEGY", { status: 500, headers: { "Cache-Control": "no-store" } });
11
11
  if (["POST", "PUT", "PATCH", "DELETE"].includes(request.method) && url.pathname.startsWith("/api/")) { const origin = request.headers.get("Origin"); if (!origin || (() => { try { return new URL(origin).origin !== url.origin; } catch { return true; } })()) return Response.json({ error: "A same-origin request is required." }, { status: 403, headers: { "Cache-Control": "no-store" } }); }
12
12
  const who = state.user?.auth_strategy === "http_basic" ? "user:admin" : `user:${state.user?.sub || "unknown"}`;
@@ -35,7 +35,7 @@ export function createAuth(options = {}) {
35
35
  name: "auth", displayName: options.displayName || "Authentication", packageName: PACKAGE_NAME, version: VERSION, strategy: OAUTH_SINGLE, repositories: options.repositories || authRepositoryDefinitions,
36
36
  dataResources: options.dataResources || [], routes: options.routes || [],
37
37
  healthchecks: options.healthchecks || [], circuitBreakers: options.circuitBreakers || [],
38
- async handle(request, env) {
38
+ async handle(request, env, _ctx, state) {
39
39
  const url = new URL(request.url);
40
40
  if (url.pathname === "/auth/login") return login(request, env);
41
41
  if (url.pathname === "/auth/callback") return callback(request, env);
@@ -45,10 +45,10 @@ export function createAuth(options = {}) {
45
45
  if (originResponse) return originResponse;
46
46
  return logout(request, env, names.session, options);
47
47
  }
48
- if (url.pathname === "/api/me") return secureJson({ user: publicUser(await resolveUser(request, env)) });
48
+ if (url.pathname === "/api/me") return secureJson({ user: publicUser(await resolveRequestUser(request, env, state)) });
49
49
  if (!protectedPath(url.pathname) || publicPaths.some((path) => path === "/" ? url.pathname === "/" : url.pathname.startsWith(path))) return null;
50
50
  if (isMutation(request)) { const originResponse = checkOrigin(request, options.allowedOrigins); if (originResponse) return originResponse; }
51
- const user = await resolveUser(request, env);
51
+ const user = await resolveRequestUser(request, env, state);
52
52
  if (user && options.authorize && !(await options.authorize({ request, url, user, env }))) {
53
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
54
  }
@@ -59,7 +59,7 @@ export function createAuth(options = {}) {
59
59
  getUser: (request, env) => resolveUser(request, env),
60
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
61
  middleware(request, env, ctx, next, state) {
62
- return this.handle(request, env, ctx).then((response) => response || next(request, env, ctx, state));
62
+ return this.handle(request, env, ctx, state).then((response) => response || next(request, env, ctx, state));
63
63
  },
64
64
  };
65
65
 
@@ -105,6 +105,10 @@ export function createAuth(options = {}) {
105
105
  return user;
106
106
  }
107
107
 
108
+ function resolveRequestUser(request, env, state) {
109
+ return state && Object.hasOwn(state, "user") ? state.user : resolveUser(request, env);
110
+ }
111
+
108
112
  async function resolveLoginUser(user, env, request) {
109
113
  const hydrated = await hydrateUser(user, env);
110
114
  if (options.loginAuthorize && !(await options.loginAuthorize({ user: hydrated, request, env }))) throw authError("Authentication is not currently permitted.", 403);
@@ -131,19 +135,24 @@ async function getUser(request, env, secretName = "AUTH_SESSION_SECRET", session
131
135
  async function configuration(env, options = {}) {
132
136
  const discoveryName = envNameFor(options, "discoveryUrl", "OIDC_DISCOVERY_URL");
133
137
  const configuredDiscovery = env[discoveryName];
138
+ const configuredIssuer = env[envNameFor(options, "issuer", "OIDC_ISSUER")];
134
139
  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
140
  if (new URL(discoveryUrl).protocol !== "https:") throw new Error("OIDC discovery URL must use HTTPS");
141
+ const expectedIssuer = configuredIssuer ? normalizeIssuer(configuredIssuer) : issuerFromDiscoveryUrl(discoveryUrl);
142
+ const cacheKey = `${discoveryUrl}\0${expectedIssuer}`;
136
143
 
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;
144
+ const cached = configurationCache.get(cacheKey); if (cached && cached.exp > Date.now()) return cached.value;
145
+ const existing = configurationRequests.get(cacheKey); if (existing) return existing;
139
146
  const request = fetchWithTimeout(discoveryUrl).then(async (response) => {
140
147
  if (!response.ok) throw new Error("Unable to load OIDC configuration");
141
148
  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;
149
+ if (!value.issuer || new URL(value.issuer).protocol !== "https:" || normalizeIssuer(value.issuer) !== expectedIssuer) throw new Error("OIDC configuration returned an unexpected issuer");
150
+ requireHttpsEndpoint(value.authorization_endpoint, "authorization");
151
+ requireHttpsEndpoint(value.token_endpoint, "token");
152
+ requireHttpsEndpoint(value.jwks_uri, "JWKS");
153
+ configurationCache.set(cacheKey, { value, exp: Date.now() + OIDC_CACHE_MS }); return value;
154
+ }).finally(() => configurationRequests.delete(cacheKey));
155
+ configurationRequests.set(cacheKey, request); return request;
147
156
  }
148
157
  async function verify(token, config, clientId, expectedNonce) {
149
158
  const [head, body, signature] = String(token || "").split("."); if (!head || !body || !signature) throw new Error("Malformed ID token");
@@ -161,6 +170,8 @@ async function getJwks(uri) {
161
170
  }
162
171
  async function fetchWithTimeout(input, init = {}) { return fetch(input, { ...init, signal: AbortSignal.timeout(OIDC_TIMEOUT_MS) }); }
163
172
  function normalizeIssuer(value) { return String(value).replace(/\/+$/, "") + "/"; }
173
+ function issuerFromDiscoveryUrl(value) { const url = new URL(value); url.pathname = url.pathname.replace(/\/\.well-known\/openid-configuration$/, "").replace(/\/+$/, ""); url.search = ""; url.hash = ""; return normalizeIssuer(url.href); }
174
+ function requireHttpsEndpoint(value, label) { if (!value || new URL(value).protocol !== "https:") throw new Error(`OIDC configuration returned an invalid ${label} endpoint`); }
164
175
  function isMutation(request) { return ["POST", "PUT", "PATCH", "DELETE"].includes(request.method); }
165
176
  function checkOrigin(request, allowedOrigins = []) {
166
177
  let requestOrigin;
@@ -2,6 +2,8 @@ import { createD1 } from "../core/d1.js";
2
2
  import { deepEqual, parseJsonValue } from "./encoding.js";
3
3
  import { SubscriptionError } from "./constants.js";
4
4
 
5
+ const manifestPromises = new WeakMap();
6
+
5
7
  export async function listTenantSubscriptions(env, tenantId, { who = "system:read" } = {}) {
6
8
  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
9
  return Promise.all((results || []).map(async (subscription) => ({ ...subscription, entitlements: await listSubscriptionEntitlements(env, subscription.id, { who }) })));
@@ -15,8 +17,23 @@ export async function listSubscriptionEntitlements(env, subscriptionId, { who =
15
17
  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
18
 
17
19
  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
+ if (!env?.DB) return;
21
+ const normalized = normalizeSubscriptionManifest(manifest);
22
+ const key = JSON.stringify(normalized);
23
+ let registrations = manifestPromises.get(env.DB);
24
+ if (!registrations) { registrations = new Map(); manifestPromises.set(env.DB, registrations); }
25
+ let promise = registrations.get(key);
26
+ if (!promise) {
27
+ promise = writeSubscriptionManifest(env, normalized, who);
28
+ registrations.set(key, promise);
29
+ promise.catch(() => registrations.delete(key));
30
+ }
31
+ return promise;
32
+ }
33
+
34
+ async function writeSubscriptionManifest(env, manifest, who) {
35
+ const db = createD1(env, { who });
36
+ for (const subscription of 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
37
  }
21
38
 
22
39
  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()); }
package/src/auth/users.js CHANGED
@@ -1,15 +1,16 @@
1
1
  import { createD1 } from "../core/d1.js";
2
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
3
 
6
4
  export async function ensureUser(env, user, { who = "system:read" } = {}) {
7
5
  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();
6
+ const db = createD1(env, { who }); const provider = String(user.auth_strategy || "oauth"); const subject = String(user.sub); const email = String(user.email || "").trim().toLowerCase(); const displayName = String(user.name || email || subject);
9
7
  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) }; }
8
+ if (existing) {
9
+ if (existing.email !== email || existing.display_name !== displayName) await db.prepare(`UPDATE ${AUTH_USER_TABLE} SET email=?,display_name=?,updated_at=CURRENT_TIMESTAMP WHERE id=?`).bind(email, displayName, existing.id).run();
10
+ return { ...existing, email, display_name: displayName, is_admin: Boolean(existing.is_admin) };
11
+ }
11
12
  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 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, displayName).run();
13
14
  await ensureDefaultTenantMembership(db, id);
14
15
  return db.prepare(`SELECT * FROM ${AUTH_USER_TABLE} WHERE id=?`).bind(id).first();
15
16
  }
@@ -22,10 +23,18 @@ export async function hasScope(env, user, scope, { who = "system:read" } = {}) {
22
23
  }
23
24
 
24
25
  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 }) })));
26
+ const db = createD1(env, { who });
27
+ const [{ results: users = [] }, { results: grants = [] }, { results: memberships = [] }] = await Promise.all([
28
+ db.prepare(`SELECT id,email,display_name,provider,subject,is_admin,created_at,updated_at FROM ${AUTH_USER_TABLE} ORDER BY email COLLATE NOCASE`).all(),
29
+ db.prepare(`SELECT user_id,scope_name FROM ${AUTH_GRANT_TABLE} ORDER BY user_id,scope_name`).all(),
30
+ db.prepare("SELECT ut.user_id,t.id,t.name,t.created_at,t.updated_at FROM auth_user_tenants ut JOIN auth_tenants t ON t.id=ut.tenant_id ORDER BY ut.user_id,t.name COLLATE NOCASE").all(),
31
+ ]);
32
+ const scopesByUser = groupRows(grants, "user_id", (grant) => grant.scope_name);
33
+ const tenantsByUser = groupRows(memberships, "user_id", ({ user_id: _userId, ...tenant }) => tenant);
34
+ return users.map((user) => ({ ...user, scopes: scopesByUser.get(user.id) || [], tenants: tenantsByUser.get(user.id) || [] }));
27
35
  }
28
36
 
29
37
  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
38
 
31
39
  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); }
40
+ function groupRows(rows, key, map) { const grouped = new Map(); for (const row of rows) { const values = grouped.get(row[key]) || []; values.push(map(row)); grouped.set(row[key], values); } return grouped; }
@@ -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
+ }