@browserlessapi/inferlo 0.5.0 → 0.7.0

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 (138) hide show
  1. package/README.md +14 -5
  2. package/dist/ai/ai-sdk.d.ts +48 -0
  3. package/dist/ai/ai-sdk.js +235 -0
  4. package/dist/ai/ai-sdk.js.map +1 -0
  5. package/dist/ai/index.d.ts +44 -3
  6. package/dist/ai/index.js +164 -9
  7. package/dist/ai/index.js.map +1 -1
  8. package/dist/ai/public.d.ts +6 -1
  9. package/dist/ai/public.js +2 -0
  10. package/dist/ai/public.js.map +1 -1
  11. package/dist/ai/ui.d.ts +47 -0
  12. package/dist/ai/ui.js +33 -0
  13. package/dist/ai/ui.js.map +1 -0
  14. package/dist/api-version.d.ts +32 -0
  15. package/dist/api-version.js +45 -0
  16. package/dist/api-version.js.map +1 -0
  17. package/dist/app.d.ts +60 -6
  18. package/dist/app.js +310 -66
  19. package/dist/app.js.map +1 -1
  20. package/dist/astro.d.ts +60 -0
  21. package/dist/astro.js +68 -0
  22. package/dist/astro.js.map +1 -0
  23. package/dist/audit.d.ts +57 -0
  24. package/dist/audit.js +99 -0
  25. package/dist/audit.js.map +1 -0
  26. package/dist/auth.d.ts +1 -0
  27. package/dist/auth.js +1 -0
  28. package/dist/auth.js.map +1 -1
  29. package/dist/azure.d.ts +66 -0
  30. package/dist/azure.js +119 -0
  31. package/dist/azure.js.map +1 -0
  32. package/dist/cache.d.ts +96 -0
  33. package/dist/cache.js +247 -0
  34. package/dist/cache.js.map +1 -0
  35. package/dist/cli/main.js +28 -7
  36. package/dist/cli/main.js.map +1 -1
  37. package/dist/client-ip.d.ts +36 -1
  38. package/dist/client-ip.js +80 -1
  39. package/dist/client-ip.js.map +1 -1
  40. package/dist/db/memory.js +2 -1
  41. package/dist/db/memory.js.map +1 -1
  42. package/dist/db/postgres.js +4 -1
  43. package/dist/db/postgres.js.map +1 -1
  44. package/dist/db/table.d.ts +37 -5
  45. package/dist/db/table.js +96 -15
  46. package/dist/db/table.js.map +1 -1
  47. package/dist/db/types.d.ts +2 -0
  48. package/dist/describe.d.ts +54 -2
  49. package/dist/describe.js +34 -3
  50. package/dist/describe.js.map +1 -1
  51. package/dist/events.d.ts +134 -0
  52. package/dist/events.js +196 -0
  53. package/dist/events.js.map +1 -0
  54. package/dist/fastify.d.ts +10 -2
  55. package/dist/fastify.js +22 -4
  56. package/dist/fastify.js.map +1 -1
  57. package/dist/h3.d.ts +76 -0
  58. package/dist/h3.js +61 -0
  59. package/dist/h3.js.map +1 -0
  60. package/dist/hapi.d.ts +45 -0
  61. package/dist/hapi.js +57 -0
  62. package/dist/hapi.js.map +1 -0
  63. package/dist/index.d.ts +25 -5
  64. package/dist/index.js +9 -1
  65. package/dist/index.js.map +1 -1
  66. package/dist/jobs.d.ts +70 -10
  67. package/dist/jobs.js +78 -13
  68. package/dist/jobs.js.map +1 -1
  69. package/dist/koa.d.ts +51 -0
  70. package/dist/koa.js +45 -0
  71. package/dist/koa.js.map +1 -0
  72. package/dist/lambda.d.ts +110 -0
  73. package/dist/lambda.js +169 -0
  74. package/dist/lambda.js.map +1 -0
  75. package/dist/langchain.d.ts +58 -0
  76. package/dist/langchain.js +220 -0
  77. package/dist/langchain.js.map +1 -0
  78. package/dist/mcp.d.ts +1 -1
  79. package/dist/mcp.js +7 -3
  80. package/dist/mcp.js.map +1 -1
  81. package/dist/model.d.ts +23 -6
  82. package/dist/model.js +35 -6
  83. package/dist/model.js.map +1 -1
  84. package/dist/module.d.ts +52 -0
  85. package/dist/module.js +152 -0
  86. package/dist/module.js.map +1 -0
  87. package/dist/nextjs.d.ts +35 -0
  88. package/dist/nextjs.js +33 -0
  89. package/dist/nextjs.js.map +1 -0
  90. package/dist/openapi.d.ts +3 -0
  91. package/dist/openapi.js +12 -3
  92. package/dist/openapi.js.map +1 -1
  93. package/dist/permissions.d.ts +26 -0
  94. package/dist/permissions.js +78 -0
  95. package/dist/permissions.js.map +1 -0
  96. package/dist/rate-limit.d.ts +5 -3
  97. package/dist/rate-limit.js +3 -3
  98. package/dist/rate-limit.js.map +1 -1
  99. package/dist/react-router.d.ts +41 -0
  100. package/dist/react-router.js +42 -0
  101. package/dist/react-router.js.map +1 -0
  102. package/dist/redis-events.d.ts +27 -0
  103. package/dist/redis-events.js +163 -0
  104. package/dist/redis-events.js.map +1 -0
  105. package/dist/redis-jobs.d.ts +24 -0
  106. package/dist/redis-jobs.js +203 -0
  107. package/dist/redis-jobs.js.map +1 -0
  108. package/dist/redis.d.ts +30 -0
  109. package/dist/redis.js +106 -0
  110. package/dist/redis.js.map +1 -0
  111. package/dist/request-matcher.d.ts +4 -0
  112. package/dist/request-matcher.js +14 -0
  113. package/dist/request-matcher.js.map +1 -0
  114. package/dist/response-body.d.ts +6 -0
  115. package/dist/response-body.js +7 -0
  116. package/dist/response-body.js.map +1 -0
  117. package/dist/route.d.ts +50 -2
  118. package/dist/route.js +24 -0
  119. package/dist/route.js.map +1 -1
  120. package/dist/schema.d.ts +2 -2
  121. package/dist/serve.d.ts +26 -2
  122. package/dist/serve.js +111 -10
  123. package/dist/serve.js.map +1 -1
  124. package/dist/service-client.d.ts +91 -0
  125. package/dist/service-client.js +177 -0
  126. package/dist/service-client.js.map +1 -0
  127. package/dist/sessions.d.ts +74 -0
  128. package/dist/sessions.js +172 -0
  129. package/dist/sessions.js.map +1 -0
  130. package/dist/sveltekit.d.ts +72 -0
  131. package/dist/sveltekit.js +68 -0
  132. package/dist/sveltekit.js.map +1 -0
  133. package/dist/tracing.d.ts +122 -0
  134. package/dist/tracing.js +210 -0
  135. package/dist/tracing.js.map +1 -0
  136. package/llms.txt +8 -3
  137. package/package.json +78 -4
  138. package/templates/app/AGENTS.md +10 -6
package/dist/app.js CHANGED
@@ -4,21 +4,33 @@
4
4
  */
5
5
  import { bindTools, createAI } from "./ai/index.js";
6
6
  import { memory } from "./db/memory.js";
7
- import { bindDb, createDb, guardAdapter } from "./db/table.js";
7
+ import { createAudit } from "./audit.js";
8
+ import { checkRouteCache, createCache, databaseCacheStore, etagFor, etagMatches, memoryCacheStore, resolveCacheConfig, routeCacheKey } from "./cache.js";
9
+ import { bindDb, checkTenantId, createDb, guardAdapter } from "./db/table.js";
8
10
  import { describeApp } from "./describe.js";
9
11
  import { InferloError, didYouMean, suggest } from "./errors.js";
10
12
  import { callTool, handleMcp, toolDefinition } from "./mcp.js";
11
13
  import { buildOpenAPI } from "./openapi.js";
12
- import { isRoute } from "./route.js";
14
+ import { collectRoutes } from "./route.js";
15
+ import { checkRedisClient, pingRedis, redisCacheStore, redisMissing, redisPrefix, redisRateLimitStore } from "./redis.js";
13
16
  import { checkRateLimit, databaseRateLimitStore, memoryRateLimitStore, rateLimitResult, windowMs } from "./rate-limit.js";
14
- import { clientIp, clientIps } from "./client-ip.js";
15
- import { collectCrons, collectJobs, createJobs, jobStoreFor, stopWorkers } from "./jobs.js";
17
+ import { clientIp, clientIps, clientSignals, keepEventStreamOpen, platformWaitUntil } from "./client-ip.js";
18
+ import { redisJobStore } from "./redis-jobs.js";
19
+ import { knownBodies } from "./response-body.js";
20
+ import { collectCrons, collectJobs, collectSubscriptions, createJobs, jobStoreFor, stopWorkers, withTenant } from "./jobs.js";
21
+ import { checkEventsConfig, invalidTransport } from "./events.js";
22
+ import { redisEventTransport } from "./redis-events.js";
16
23
  import { ValidationError } from "./schema.js";
17
24
  import { createRedactor, redactingLogger, redactValues, secretsOf } from "./redact.js";
18
25
  import { defaultLogger, loggingOptions, REQUEST_ID } from "./logging.js";
26
+ import { clientTracing, jobTracing, requestTracing, traceAdapter, traceFields } from "./tracing.js";
19
27
  import { resolveDeprecation } from "./deprecation.js";
20
28
  import { createMetrics, hasMetricsToken, resolveMetrics } from "./metrics.js";
21
29
  import { INFERLO_VERSION } from "./describe.js";
30
+ import { registerRequestMatcher } from "./request-matcher.js";
31
+ import { versionDocPath, versionsOf } from "./api-version.js";
32
+ import { resolveModules } from "./module.js";
33
+ import { isPermissions } from "./permissions.js";
22
34
  import { applySecureHeaders, resolveSecureHeaders } from "./secure-headers.js";
23
35
  const testUsers = new WeakMap();
24
36
  function compile(def) {
@@ -45,29 +57,6 @@ function compile(def) {
45
57
  .join("");
46
58
  return { def, regex: new RegExp(`^${pattern || ""}/?$`), keys, score: score * 10 + parts.length };
47
59
  }
48
- function collectRoutes(sources) {
49
- const out = [];
50
- const seen = new Set();
51
- const visit = (source, name) => {
52
- if (!source || typeof source !== "object" || seen.has(source))
53
- return;
54
- seen.add(source);
55
- if (isRoute(source)) {
56
- out.push(name && !source.name ? { ...source, name } : source);
57
- return;
58
- }
59
- if (Array.isArray(source)) {
60
- for (const item of source)
61
- visit(item);
62
- return;
63
- }
64
- for (const [key, value] of Object.entries(source))
65
- if (isRoute(value) || Array.isArray(value))
66
- visit(value, key === "default" ? undefined : key);
67
- };
68
- visit(sources);
69
- return out;
70
- }
71
60
  function corsHeaders(config, origin) {
72
61
  const allowed = config.origin === "*" ? "*" : origin && (Array.isArray(config.origin) ? config.origin : [config.origin]).includes(origin) ? origin : null;
73
62
  if (!allowed)
@@ -131,14 +120,22 @@ function withHeader(response, name, value) {
131
120
  }
132
121
  function json(data, status, headers) {
133
122
  headers.set("content-type", "application/json; charset=utf-8");
134
- return new Response(JSON.stringify(data), { status, headers });
123
+ const text = JSON.stringify(data);
124
+ const response = new Response(text, { status, headers });
125
+ knownBodies.set(response, text);
126
+ return response;
135
127
  }
136
128
  export function createApp(config = {}) {
137
129
  // Everything the app logs goes through the redactor: credentials and secrets never reach the logs.
138
130
  const redactor = createRedactor([...redactValues(config.redact), ...Object.values(config.ai?.providers ?? {}).flatMap((p) => secretsOf(p))]);
139
131
  const logging = loggingOptions(config.logging, config.logger !== undefined);
140
132
  const logger = redactingLogger(config.logger ?? defaultLogger(logging.format, config.name ?? "inferlo-app"), redactor);
141
- const models = (config.models ?? []);
133
+ const tracing = requestTracing(config.tracing, config.name ?? "inferlo-app", config.version ?? "0.0.0");
134
+ const tracingApi = tracing ? config.tracing.api : undefined;
135
+ // The server span of each request, for errors recorded while it runs.
136
+ const requestSpans = new WeakMap();
137
+ const { modules, modelOwner } = resolveModules(config.modules, config.models ?? []);
138
+ const models = [...(config.models ?? []), ...modules.flatMap((m) => m.models)];
142
139
  const modelNames = new Set();
143
140
  for (const m of models) {
144
141
  if (modelNames.has(m.name))
@@ -155,7 +152,15 @@ export function createApp(config = {}) {
155
152
  }
156
153
  }
157
154
  }
155
+ // Routes of the app itself, then each module's; routeModule remembers which module a route came from.
156
+ const routeModule = new Map();
158
157
  const routes = collectRoutes(config.routes);
158
+ for (const m of modules) {
159
+ for (const r of collectRoutes(m.routes)) {
160
+ routeModule.set(r, m.name);
161
+ routes.push(r);
162
+ }
163
+ }
159
164
  const compiled = routes.map(compile).sort((a, b) => b.score - a.score);
160
165
  const signatures = new Map();
161
166
  for (const r of routes) {
@@ -180,9 +185,29 @@ export function createApp(config = {}) {
180
185
  fix: 'Routes are private by default. Add auth: "public" to routes anyone may call, or pass createApp({ auth: { resolve(req) { ... } } }).',
181
186
  });
182
187
  }
188
+ if (config.permissions !== undefined && !isPermissions(config.permissions)) {
189
+ throw new InferloError("IL6026", "createApp({ permissions }) takes the result of permissions({ ... }).", { fix: 'Define them with permissions({ "tickets.update": ({ user, ticket }) => ticket.ownerId === user.id }) and pass that value.' });
190
+ }
191
+ for (const r of routes) {
192
+ const name = r.options.permission;
193
+ if (name === undefined)
194
+ continue;
195
+ const where = `${r.method} ${r.path}`;
196
+ if (typeof name !== "string" || !config.permissions?.has(name)) {
197
+ throw new InferloError("IL6026", config.permissions ? `The route ${where} uses the unknown permission "${String(name)}".` : `The route ${where} uses the permission "${String(name)}", but createApp has no permissions.`, {
198
+ fix: config.permissions ? `Use one of: ${config.permissions.list.map((p) => p.name).join(", ")}, or add it to permissions({ ... }).` : "Pass createApp({ permissions }) with the value from permissions({ ... }).",
199
+ });
200
+ }
201
+ if (r.options.auth === "public") {
202
+ throw new InferloError("IL6026", `The route ${where} has a permission but auth: "public".`, { fix: 'A permission needs a signed-in user: remove auth: "public" from the route.' });
203
+ }
204
+ }
183
205
  for (const r of routes)
184
206
  if (r.options.rateLimit !== undefined)
185
207
  checkRateLimit(r.options.rateLimit, `${r.method} ${r.path}`);
208
+ for (const r of routes)
209
+ if (r.options.cache !== undefined)
210
+ checkRouteCache(r.options.cache, r.method, `${r.method} ${r.path}`);
186
211
  const requestTimeoutMs = config.requestTimeoutMs === undefined ? 60_000 : checkTimeout(config.requestTimeoutMs, "createApp({ requestTimeoutMs })");
187
212
  for (const r of routes)
188
213
  if (r.options.timeoutMs !== undefined)
@@ -194,39 +219,90 @@ export function createApp(config = {}) {
194
219
  deprecations.set(r, resolved);
195
220
  }
196
221
  const storeName = config.rateLimit?.store ?? "memory";
197
- if (storeName !== "memory" && storeName !== "database") {
198
- throw new InferloError("IL6007", `Invalid rateLimit store "${String(storeName)}".`, { fix: 'Use createApp({ rateLimit: { store: "memory" } }) or { store: "database" }.' });
222
+ if (storeName !== "memory" && storeName !== "database" && storeName !== "redis") {
223
+ throw new InferloError("IL6007", `Invalid rateLimit store "${String(storeName)}".`, { fix: 'Use createApp({ rateLimit: { store: "memory" } }), { store: "database" } or { store: "redis" }.' });
199
224
  }
225
+ const redis = checkRedisClient(config.redis);
226
+ if (storeName === "redis" && !redis)
227
+ throw redisMissing("rateLimit");
200
228
  const openapiPath = config.openapi === undefined ? "/openapi.json" : config.openapi;
201
229
  const hasTools = routes.some((r) => r.options.tool);
202
230
  const mcpPath = config.mcp === undefined ? (hasTools ? "/mcp" : false) : config.mcp;
203
- const health = resolveHealth(config.health, routes, [openapiPath, mcpPath]);
231
+ const health = resolveHealth(config.health, routes, [openapiPath, mcpPath], redis ? ["redis"] : []);
232
+ // apiVersion("v1", ...) routes get their own OpenAPI document at /v1/openapi.json.
233
+ const versions = versionsOf(routes);
234
+ const versionDocs = new Map(openapiPath ? versions.map((v) => [versionDocPath(v, openapiPath), v]) : []);
235
+ for (const docPath of versionDocs.keys()) {
236
+ const clash = routes.find((r) => r.method === "GET" && r.path.replace(/\/+$/, "") === docPath);
237
+ if (clash)
238
+ throw new InferloError("IL6025", `The route GET ${clash.path} has the path of the OpenAPI document of API version ${versionDocs.get(docPath)}.`, { fix: "Rename the route; Inferlo serves each version's OpenAPI document there." });
239
+ }
204
240
  const metricsConfig = resolveMetrics(config.metrics, [
241
+ ...[...versionDocs.keys()].map((path) => ({ path, what: "an OpenAPI document path" })),
205
242
  { path: openapiPath, what: "the OpenAPI path" },
206
243
  { path: mcpPath, what: "the MCP path" },
207
244
  { path: health?.path ?? false, what: "the health path" },
208
245
  { path: health?.readyPath ?? false, what: "the readiness path" },
209
246
  ...routes.filter((r) => r.method === "GET").map((r) => ({ path: r.path, what: `the route GET ${r.path}` })),
210
247
  ]);
211
- const adapter = config.db ?? memory();
248
+ // With tracing, database queries made while a request or job is traced get client spans.
249
+ const appTracing = tracing ? { ...config.tracing, tracerName: config.tracing.tracerName ?? config.name ?? "inferlo-app" } : undefined;
250
+ const adapter = appTracing ? traceAdapter(config.db ?? memory(), clientTracing(appTracing, "inferlo-app", config.version ?? "0.0.0")) : (config.db ?? memory());
212
251
  const db = createDb(models, adapter);
213
- const rateLimits = storeName === "database" ? databaseRateLimitStore(adapter) : memoryRateLimitStore();
252
+ const rateLimits = storeName === "redis" ? redisRateLimitStore(redis, redisPrefix(config.name)) : storeName === "database" ? databaseRateLimitStore(adapter) : memoryRateLimitStore();
253
+ const cacheConfig = resolveCacheConfig(config.cache);
254
+ if (cacheConfig.store === "redis" && !redis)
255
+ throw redisMissing("cache");
256
+ const cacheStore = cacheConfig.store === "redis" ? redisCacheStore(redis, redisPrefix(config.name)) : cacheConfig.store === "database" ? databaseCacheStore(adapter) : memoryCacheStore(cacheConfig.maxEntries);
257
+ const cache = createCache(cacheStore, cacheConfig.store);
214
258
  const metrics = metricsConfig
215
259
  ? createMetrics({ app: config.name ?? "inferlo-app", version: config.version ?? "0.0.0", inferlo: INFERLO_VERSION }, metricsConfig.buckets)
216
260
  : undefined;
217
261
  // With metrics on, every AI call that reports usage is counted before the app's own onUsage runs.
218
- const aiConfig = metrics && config.ai
219
- ? { ...config.ai, onUsage: (usage) => (metrics.ai(usage), config.ai.onUsage?.(usage)) }
262
+ // With tracing, AI calls get client spans too (unless the AI config has its own tracing).
263
+ const aiConfig = config.ai && (metrics || appTracing)
264
+ ? {
265
+ ...config.ai,
266
+ ...(metrics ? { onUsage: (usage) => (metrics.ai(usage), config.ai.onUsage?.(usage)) } : {}),
267
+ ...(appTracing ? { tracing: config.ai.tracing ?? appTracing } : {}),
268
+ }
220
269
  : config.ai;
221
270
  const ai = createAI(aiConfig);
222
- const jobs = createJobs(collectJobs(config.jobs), {
271
+ const jobModule = new Map();
272
+ for (const m of modules)
273
+ for (const j of collectJobs(m.jobs))
274
+ jobModule.set(j.name, m.name);
275
+ const moduleInfo = { modules, routeModule, modelOwner, jobModule };
276
+ const jobSources = [...(config.jobs ?? []), ...modules.flatMap((m) => m.jobs)];
277
+ const transportOption = checkEventsConfig(config.events);
278
+ let eventTransport;
279
+ if (transportOption === "redis") {
280
+ if (!redis)
281
+ throw new InferloError("IL6031", 'events transport "redis" needs a Redis client, but createApp has no redis option.', { fix: 'Create and connect a client with the redis package: const redis = await createClient({ url: process.env.REDIS_URL }).connect(); then createApp({ name: "billing", redis, events: { transport: "redis" } }).' });
282
+ // The app name is the consumer group: two services with one name would split each other's events.
283
+ if (!config.name)
284
+ throw invalidTransport('events transport "redis" needs createApp({ name }): each service reads events as its own consumer group, named after the app.');
285
+ eventTransport = redisEventTransport(redis, { group: config.name });
286
+ }
287
+ else
288
+ eventTransport = transportOption;
289
+ const jobs = createJobs(collectJobs(jobSources), {
223
290
  db: db,
224
291
  ai,
225
- store: jobStoreFor(config.jobQueue, adapter),
292
+ cache,
293
+ audit: (run) => createAudit(models, redactor, () => db, { userId: null, tenantId: run.tenantId, source: `job ${run.name}` }),
294
+ store: jobStoreFor(config.jobQueue, adapter, () => {
295
+ if (!redis)
296
+ throw redisMissing("jobQueue");
297
+ return redisJobStore(redis, redisPrefix(config.name));
298
+ }),
226
299
  queue: config.jobQueue?.store ?? "memory",
227
300
  logger,
228
301
  onRun: metrics ? (name, outcome, ms) => metrics.job(name, outcome, ms / 1000) : undefined,
229
- }, collectCrons(config.jobs));
302
+ tracing: appTracing ? jobTracing(appTracing, "inferlo-app", config.version ?? "0.0.0") : undefined,
303
+ eventTransport,
304
+ }, collectCrons(jobSources), collectSubscriptions(jobSources));
305
+ const events = jobs.events;
230
306
  const maxBody = config.maxBodySize ?? 1024 * 1024;
231
307
  const secureHeaders = resolveSecureHeaders(config.secureHeaders);
232
308
  const cors = config.cors === true ? { origin: "*" } : config.cors || undefined;
@@ -250,6 +326,8 @@ export function createApp(config = {}) {
250
326
  }
251
327
  };
252
328
  }
329
+ if (redis)
330
+ readinessChecks.redis = () => pingRedis(redis);
253
331
  Object.assign(readinessChecks, health?.checks);
254
332
  const runCheck = async (name, check) => {
255
333
  const started = Date.now();
@@ -305,9 +383,19 @@ export function createApp(config = {}) {
305
383
  }
306
384
  return { name: tool.name, status: response.status, output, text: text || String(response.status) };
307
385
  }, describeTool, signal);
386
+ const tenantScope = (user) => {
387
+ if (!user)
388
+ return "none";
389
+ const id = config.auth?.tenant ? config.auth.tenant(user) : user.tenantId;
390
+ return id === null || id === undefined ? "none" : { tenant: checkTenantId(id) };
391
+ };
392
+ const tenantOf = (scope) => (typeof scope === "object" ? scope.tenant : null);
308
393
  const roles = (user) => config.auth?.roles?.(user) ?? (user.roles ?? []);
309
394
  const errorResponse = (error, headers, req, requestId) => {
310
- const fields = { requestId };
395
+ const span = requestSpans.get(req);
396
+ const fields = { requestId, ...traceFields(span) };
397
+ if (span && !(error instanceof InferloError && error.status < 500))
398
+ span.recordException(error);
311
399
  if (error instanceof InferloError) {
312
400
  if (error.status >= 500)
313
401
  logger.error(`${req.method} ${new URL(req.url).pathname} failed: ${error.format()}`, error.cause ?? "", fields);
@@ -329,7 +417,7 @@ export function createApp(config = {}) {
329
417
  const method = req.method.toUpperCase();
330
418
  if (method === "OPTIONS" && cors) {
331
419
  headers.set("access-control-allow-methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
332
- headers.set("access-control-allow-headers", (cors.headers ?? ["content-type", "authorization"]).join(", "));
420
+ headers.set("access-control-allow-headers", (cors.headers ?? ["content-type", "authorization", "x-csrf-token"]).join(", "));
333
421
  headers.set("access-control-max-age", String(cors.maxAge ?? 600));
334
422
  return new Response(null, { status: 204, headers });
335
423
  }
@@ -352,13 +440,33 @@ export function createApp(config = {}) {
352
440
  headers.set("connection", "close");
353
441
  throw new InferloError("IL2006", "The server is shutting down.", { status: 503, fix: "Retry the request; another instance will answer it." });
354
442
  }
443
+ if (method === "GET" && versionDocs.has(url.pathname)) {
444
+ seen.route = url.pathname;
445
+ return json(app.openapi({ version: versionDocs.get(url.pathname) }), 200, headers);
446
+ }
355
447
  if (openapiPath && url.pathname === openapiPath && method === "GET") {
356
448
  seen.route = openapiPath;
357
449
  return json(app.openapi(), 200, headers);
358
450
  }
359
451
  if (mcpPath && url.pathname === mcpPath) {
360
452
  seen.route = mcpPath;
361
- return await handleMcp(app, req, headers, testUsers);
453
+ // tools/list leaves out tools that need a role or permission the caller does not have.
454
+ let caller;
455
+ const visible = async (def) => {
456
+ const level = def.options.auth ?? "user";
457
+ if (!def.options.permission && (level === "public" || level === "user"))
458
+ return true;
459
+ caller ??= testUsers.has(req) ? Promise.resolve(testUsers.get(req) ?? null) : config.auth ? Promise.resolve(config.auth.resolve(req, { db: db, url })).then((u) => u ?? null) : Promise.resolve(null);
460
+ const user = await caller;
461
+ if (!user)
462
+ return false;
463
+ if (level !== "public" && level !== "user" && !roles(user).includes(level))
464
+ return false;
465
+ if (!def.options.permission)
466
+ return true;
467
+ return config.permissions.can(def.options.permission, { user }).catch(() => false);
468
+ };
469
+ return await handleMcp(app, req, headers, testUsers, visible);
362
470
  }
363
471
  const lookup = method === "HEAD" ? "GET" : method;
364
472
  let match;
@@ -390,8 +498,18 @@ export function createApp(config = {}) {
390
498
  const limit = opts.timeoutMs === undefined ? requestTimeoutMs : opts.timeoutMs;
391
499
  // timeout aborts when the route takes too long; signal (ctx.signal) also when the client disconnects.
392
500
  const timeout = new AbortController();
393
- const signal = AbortSignal.any([req.signal, timeout.signal]);
501
+ // Combined on first use: AbortSignal.any is costly, and most handlers never read ctx.signal.
502
+ let combined;
503
+ const signal = () => (combined ??= AbortSignal.any([clientSignals.get(req) ?? req.signal, timeout.signal]));
394
504
  let requestDb;
505
+ let queues;
506
+ let requestUser = null;
507
+ const tenantQueues = () => {
508
+ if (queues)
509
+ return queues;
510
+ const scope = tenantScope(requestUser);
511
+ return (queues = typeof scope === "object" ? withTenant({ jobs, events }, scope.tenant) : { jobs, events });
512
+ };
395
513
  const run = async () => {
396
514
  // Auth
397
515
  let user = null;
@@ -399,6 +517,7 @@ export function createApp(config = {}) {
399
517
  user = testUsers.get(req) ?? null;
400
518
  else if (config.auth)
401
519
  user = (await config.auth.resolve(req, { db: db, url })) ?? null;
520
+ requestUser = user;
402
521
  const level = opts.auth ?? "user";
403
522
  if (level !== "public") {
404
523
  if (!user)
@@ -406,9 +525,14 @@ export function createApp(config = {}) {
406
525
  if (level !== "user" && !roles(user).includes(level))
407
526
  throw new InferloError("IL3002", `This requires the "${level}" role.`, { status: 403 });
408
527
  }
409
- // Rate limit: counted per user (or IP without one), after auth so failed sign-ins on private routes do not use it up.
528
+ if (opts.permission)
529
+ await config.permissions.authorize(opts.permission, { user });
530
+ // Rate limit: counted per user, tenant or IP, after auth so failed sign-ins on private routes do not use it up.
531
+ // by "tenant" counts users without a tenant one by one; by "user" and "tenant" count requests without a user by IP.
410
532
  if (opts.rateLimit) {
411
- const who = (opts.rateLimit.by ?? "user") === "user" && user ? `user:${user.id}` : `ip:${ip}`;
533
+ const by = opts.rateLimit.by ?? "user";
534
+ const scope = by === "tenant" && user ? tenantScope(user) : "none";
535
+ const who = typeof scope === "object" ? `tenant:${scope.tenant}` : by !== "ip" && user ? `user:${user.id}` : `ip:${ip}`;
412
536
  const hit = await rateLimits.hit(`${def.method} ${def.path} ${who}`, windowMs(opts.rateLimit));
413
537
  const limited = rateLimitResult(opts.rateLimit, hit);
414
538
  for (const [k, v] of Object.entries(limited.headers))
@@ -432,8 +556,12 @@ export function createApp(config = {}) {
432
556
  const type = req.headers.get("content-type") ?? "";
433
557
  let raw = undefined;
434
558
  if (text) {
435
- if (type.includes("application/x-www-form-urlencoded"))
436
- raw = Object.fromEntries(new URLSearchParams(text));
559
+ if (type.includes("application/x-www-form-urlencoded")) {
560
+ // _csrf is the sessions() CSRF field of HTML forms, checked before the handler and not part of the body.
561
+ const form = new URLSearchParams(text);
562
+ form.delete("_csrf");
563
+ raw = Object.fromEntries(form);
564
+ }
437
565
  else {
438
566
  try {
439
567
  raw = JSON.parse(text);
@@ -445,6 +573,34 @@ export function createApp(config = {}) {
445
573
  }
446
574
  body = parseOrThrow(opts.body, raw, "request body", type.includes("application/x-www-form-urlencoded"));
447
575
  }
576
+ // Response cache: after auth, permissions, rate limits and input checks, so a cached response is only
577
+ // served to callers who may call the route with valid input.
578
+ const routeCache = opts.cache;
579
+ const cacheKey = routeCache ? await routeCacheKey(def.method, def.path, routeCache, user?.id, url, routeCache.by === "tenant" && user ? tenantOf(tenantScope(user)) : undefined) : undefined;
580
+ const cacheHeaders = (outcome, etag) => {
581
+ headers.set("x-inferlo-cache", outcome);
582
+ headers.set("etag", etag);
583
+ if (!headers.has("cache-control"))
584
+ headers.set("cache-control", `${(routeCache.by ?? "user") === "public" && (opts.auth ?? "user") === "public" ? "public" : "private"}, no-cache`);
585
+ if ((routeCache.by ?? "user") !== "public")
586
+ headers.append("vary", "Authorization, Cookie");
587
+ };
588
+ const cachedAnswer = (body, etag) => {
589
+ if (etagMatches(req.headers.get("if-none-match"), etag))
590
+ return new Response(null, { status: 304, headers });
591
+ headers.set("content-type", "application/json; charset=utf-8");
592
+ return new Response(method === "HEAD" ? null : body, { status: 200, headers });
593
+ };
594
+ if (cacheKey) {
595
+ const hit = await cache.get(cacheKey);
596
+ if (hit) {
597
+ for (const [name, value] of hit.headers)
598
+ headers.set(name, value);
599
+ cacheHeaders("HIT", hit.etag);
600
+ return cachedAnswer(JSON.stringify(hit.body), hit.etag);
601
+ }
602
+ }
603
+ const handlerHeaders = [];
448
604
  let status = 0;
449
605
  const ctx = {
450
606
  body,
@@ -453,30 +609,67 @@ export function createApp(config = {}) {
453
609
  user,
454
610
  get db() {
455
611
  // Built on first use: after a timeout, new queries fail with IL2007 instead of running.
456
- return (requestDb ??= bindDb(models, guardAdapter(adapter, timeout.signal)));
612
+ // Tenant-scoped tables are filtered by the signed-in user's tenant (none without one).
613
+ return (requestDb ??= bindDb(models, guardAdapter(adapter, timeout.signal), tenantScope(user)));
614
+ },
615
+ // ctx.ai and ctx.audit are built on first use too, so routes that do not use them pay nothing.
616
+ get ai() {
617
+ const value = toolsFor(req, signal());
618
+ Object.defineProperty(ctx, "ai", { value, enumerable: true });
619
+ return value;
620
+ },
621
+ get signal() {
622
+ return signal();
457
623
  },
458
- ai: toolsFor(req, signal),
459
- signal,
460
624
  requestId,
461
- jobs,
625
+ // With a tenant, jobs and events queued by the request carry it.
626
+ get jobs() {
627
+ return tenantQueues().jobs;
628
+ },
629
+ get events() {
630
+ return tenantQueues().events;
631
+ },
632
+ cache,
462
633
  req,
463
634
  url,
464
635
  headers: req.headers,
465
636
  state,
466
637
  status: (code) => void (status = code),
467
- header: (name, value) => headers.set(name, value),
638
+ header: (name, value) => {
639
+ // Set-Cookie is added, not replaced, so a handler can set several cookies.
640
+ if (name.toLowerCase() === "set-cookie")
641
+ headers.append(name, value);
642
+ else
643
+ headers.set(name, value);
644
+ handlerHeaders.push([name.toLowerCase(), value]);
645
+ },
468
646
  waitUntil: (p) => void pending.push(p.catch((e) => logger.error("waitUntil task failed", e, { requestId }))),
647
+ get audit() {
648
+ const value = createAudit(models, redactor, () => ctx.db, { userId: user?.id ?? null, tenantId: tenantOf(tenantScope(user)), source: `${def.method} ${def.path}`, requestId });
649
+ Object.defineProperty(ctx, "audit", { value, enumerable: true });
650
+ return value;
651
+ },
469
652
  };
470
653
  const result = await def.handler(ctx);
471
654
  if (result instanceof Response) {
472
655
  headers.forEach((v, k) => {
473
- if (!result.headers.has(k))
656
+ if (k !== "set-cookie" && !result.headers.has(k))
474
657
  result.headers.set(k, v);
475
658
  });
659
+ for (const cookie of headers.getSetCookie())
660
+ result.headers.append("set-cookie", cookie);
476
661
  return result;
477
662
  }
478
663
  if (result === undefined)
479
664
  return new Response(null, { status: status || 204, headers });
665
+ // Only plain 200 JSON answers are cached, never one that sets a cookie.
666
+ if (cacheKey && (status || 200) === 200 && !handlerHeaders.some(([name]) => name === "set-cookie")) {
667
+ const body = JSON.stringify(result);
668
+ const etag = await etagFor(body);
669
+ await cache.set(cacheKey, { body: JSON.parse(body), headers: handlerHeaders, etag }, { ttlMs: routeCache.ttlMs, tags: routeCache.tags ?? [] });
670
+ cacheHeaders("MISS", etag);
671
+ return cachedAnswer(body, etag);
672
+ }
480
673
  const response = json(result, status || 200, headers);
481
674
  return method === "HEAD" ? new Response(null, { status: response.status, headers: response.headers }) : response;
482
675
  };
@@ -540,8 +733,29 @@ export function createApp(config = {}) {
540
733
  }
541
734
  };
542
735
  async function handle(req, rest) {
543
- const started = performance.now();
544
736
  const url = new URL(req.url);
737
+ // Health and metrics endpoints are called every few seconds by load balancers and scrapers: not logged, counted or traced.
738
+ const probe = url.pathname === health?.path || url.pathname === health?.readyPath || url.pathname === metricsConfig?.path;
739
+ if (!tracing || probe)
740
+ return handleTraced(req, rest, url, probe, undefined);
741
+ return tracing.run(req, url, async (span) => {
742
+ requestSpans.set(req, span);
743
+ let status = 500;
744
+ try {
745
+ const response = await handleTraced(req, rest, url, probe, span);
746
+ status = response.status;
747
+ return response;
748
+ }
749
+ finally {
750
+ span.setAttribute("http.response.status_code", status);
751
+ if (status >= 500)
752
+ span.setStatus({ code: tracingApi.SpanStatusCode.ERROR });
753
+ span.end();
754
+ }
755
+ });
756
+ }
757
+ async function handleTraced(req, rest, url, probe, span) {
758
+ const started = performance.now();
545
759
  // Behind a trusted proxy, keep the id it sent so log entries line up across services.
546
760
  const incoming = config.trustProxy ? req.headers.get("x-request-id") : null;
547
761
  const requestId = incoming && REQUEST_ID.test(incoming) ? incoming : crypto.randomUUID();
@@ -567,15 +781,17 @@ export function createApp(config = {}) {
567
781
  response = errorResponse(error, new Headers(), req, requestId);
568
782
  }
569
783
  response = withHeader(response, "x-request-id", requestId);
570
- // Health and metrics endpoints are called every few seconds by load balancers and scrapers: not logged or counted.
571
- const probe = url.pathname === health?.path || url.pathname === health?.readyPath || url.pathname === metricsConfig?.path;
784
+ if (span && seen.route !== "unmatched") {
785
+ span.updateName(`${req.method.toUpperCase()} ${seen.route}`);
786
+ span.setAttribute("http.route", seen.route);
787
+ }
572
788
  if (!probe) {
573
789
  const elapsed = performance.now() - started;
574
790
  const method = req.method.toUpperCase();
575
791
  metrics?.request(method, seen.route, response.status, elapsed / 1000);
576
792
  if (logging.requests) {
577
793
  const durationMs = Math.round(elapsed * 10) / 10;
578
- logger.info(`${method} ${url.pathname} ${response.status} ${durationMs}ms`, undefined, { requestId, method, path: url.pathname, status: response.status, durationMs });
794
+ logger.info(`${method} ${url.pathname} ${response.status} ${durationMs}ms`, undefined, { requestId, ...traceFields(span), method, path: url.pathname, status: response.status, durationMs });
579
795
  }
580
796
  }
581
797
  if (secureHeaders.length) {
@@ -584,9 +800,9 @@ export function createApp(config = {}) {
584
800
  }
585
801
  if (pending.length) {
586
802
  const all = Promise.all(pending);
587
- const execution = rest[1];
588
- if (execution?.waitUntil)
589
- execution.waitUntil(all);
803
+ const execution = platformWaitUntil(rest);
804
+ if (execution)
805
+ execution(all);
590
806
  else {
591
807
  // Kept until it settles, so app.close() waits for it.
592
808
  tasks.add(all);
@@ -603,9 +819,12 @@ export function createApp(config = {}) {
603
819
  version: config.version ?? "0.0.0",
604
820
  routes,
605
821
  models,
822
+ modules,
606
823
  db,
607
824
  ai,
608
825
  jobs,
826
+ events,
827
+ cache,
609
828
  get closing() {
610
829
  return closing;
611
830
  },
@@ -613,7 +832,10 @@ export function createApp(config = {}) {
613
832
  inFlight++;
614
833
  metrics?.inFlight(1);
615
834
  try {
616
- return await handle(req, rest);
835
+ const response = await handle(req, rest);
836
+ if (rest.length)
837
+ keepEventStreamOpen(rest[0], req, response);
838
+ return response;
617
839
  }
618
840
  finally {
619
841
  inFlight--;
@@ -706,15 +928,37 @@ export function createApp(config = {}) {
706
928
  requestTimeoutMs,
707
929
  logging: { format: config.logger ? "custom" : logging.format, requests: logging.requests },
708
930
  description: config.description,
931
+ modules: moduleInfo,
932
+ versions: versions.map((v) => ({ name: v, openapi: openapiPath ? versionDocPath(v, openapiPath) : false })),
933
+ permissions: config.permissions?.list.map((p) => (p.roles ? { name: p.name, roles: [...p.roles] } : { name: p.name })) ?? [],
709
934
  });
710
935
  },
711
- openapi() {
712
- return buildOpenAPI(app, { description: config.description, auth: Boolean(config.auth) });
936
+ openapi(options = {}) {
937
+ if (options.version !== undefined && !versions.includes(options.version)) {
938
+ throw new InferloError("IL6025", `The app has no API version ${JSON.stringify(options.version)}.${versions.length ? ` Its versions: ${versions.join(", ")}.` : " It has no apiVersion() routes."}`, {
939
+ fix: 'Pass a version used in apiVersion("v1", routes), or call app.openapi() for every route.',
940
+ });
941
+ }
942
+ return buildOpenAPI(app, { description: config.description, auth: Boolean(config.auth), modules: moduleInfo, version: options.version });
713
943
  },
714
944
  };
945
+ // Built-in endpoints with the methods dispatch answers them for (MCP takes every method).
946
+ const readEndpoints = [health?.path, health?.readyPath, metricsConfig?.path].filter((p) => typeof p === "string");
947
+ registerRequestMatcher(app, (rawMethod, pathname) => {
948
+ const method = rawMethod.toUpperCase();
949
+ if (mcpPath && pathname === mcpPath)
950
+ return true;
951
+ if (method === "GET" && ((openapiPath && pathname === openapiPath) || versionDocs.has(pathname)))
952
+ return true;
953
+ if ((method === "GET" || method === "HEAD") && readEndpoints.includes(pathname))
954
+ return true;
955
+ const lookup = method === "HEAD" ? "GET" : method;
956
+ // A CORS preflight belongs to the app when any of its routes has this path.
957
+ return compiled.some((c) => (method === "OPTIONS" || c.def.method === lookup) && c.regex.test(pathname));
958
+ });
715
959
  return app;
716
960
  }
717
- function resolveHealth(option, routes, taken) {
961
+ function resolveHealth(option, routes, taken, builtIn) {
718
962
  if (option === undefined || option === false)
719
963
  return undefined;
720
964
  const bad = (message, fix) => new InferloError("IL6014", message, { fix });
@@ -731,9 +975,9 @@ function resolveHealth(option, routes, taken) {
731
975
  if (!Number.isFinite(resolved.timeoutMs) || resolved.timeoutMs <= 0)
732
976
  throw bad(`health.timeoutMs must be a positive number of milliseconds, got ${String(resolved.timeoutMs)}.`, "Use health: { timeoutMs: 5000 }.");
733
977
  if (typeof resolved.checks !== "object" || resolved.checks === null)
734
- throw bad("health.checks must be an object of functions.", "Use health: { checks: { redis: () => redis.ping() } }.");
978
+ throw bad("health.checks must be an object of functions.", "Use health: { checks: { search: () => search.ping() } }.");
735
979
  for (const [name, check] of Object.entries(resolved.checks)) {
736
- if (name === "database" || name === "ai")
980
+ if (name === "database" || name === "ai" || builtIn.includes(name))
737
981
  throw bad(`health.checks.${name} is built in.`, `Give your check another name, for example "${name}Replica".`);
738
982
  if (typeof check !== "function")
739
983
  throw bad(`health.checks.${name} must be a function.`, `Use health: { checks: { ${name}: async () => { ... } } }; throw when it is not ready.`);