@anteros/core 0.0.44 → 0.0.47

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 (65) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -1
  3. package/database/field.crypto.ts +38 -13
  4. package/database/mongodbadapter.ts +85 -25
  5. package/database/vars.ts +22 -2
  6. package/index.ts +22 -0
  7. package/lib/agent.ts +707 -79
  8. package/lib/agentMemory.ts +199 -0
  9. package/lib/agents.ts +133 -52
  10. package/lib/attachment.ts +24 -3
  11. package/lib/define.ts +5 -1
  12. package/lib/eval.ts +123 -0
  13. package/lib/files.ts +21 -15
  14. package/lib/jsonSchema.ts +8 -0
  15. package/lib/mcp.ts +52 -30
  16. package/lib/messages.ts +202 -19
  17. package/lib/partialJson.ts +79 -0
  18. package/lib/providers.ts +26 -2
  19. package/lib/redact.ts +11 -0
  20. package/lib/tracing.ts +20 -0
  21. package/lib/zip.ts +23 -3
  22. package/package.json +4 -5
  23. package/server/agents.ts +66 -1
  24. package/server/api.ts +302 -20
  25. package/server/boot.ts +13 -4
  26. package/server/hono.ts +146 -6
  27. package/tests/access-log-body.test.ts +253 -0
  28. package/tests/agent-checkpoint.test.ts +195 -0
  29. package/tests/agent-hardening.test.ts +238 -0
  30. package/tests/agent-memory.test.ts +175 -1
  31. package/tests/agent-stream-advanced.test.ts +246 -0
  32. package/tests/agent-tracing-eval.test.ts +219 -0
  33. package/tests/agent-working-memory.test.ts +7 -4
  34. package/tests/agent.test.ts +54 -3
  35. package/tests/agents-api.test.ts +21 -0
  36. package/tests/api-external-guards.test.ts +304 -0
  37. package/tests/attachment.test.ts +63 -0
  38. package/tests/audit-redact.test.ts +26 -1
  39. package/tests/field-encryption.test.ts +47 -3
  40. package/tests/filters-hardening.test.ts +81 -0
  41. package/tests/fixtures/ai-tenant/ai/agents/support/billing.agent.ts +9 -0
  42. package/tests/fixtures/ai-tenant/ai/agents/support/billing.tool.ts +9 -0
  43. package/tests/fixtures/ai-tenant/ai/agents/support.agent.ts +9 -0
  44. package/tests/fixtures/ai-tenant/ai/agents/support.tool.ts +9 -0
  45. package/tests/fixtures/ai-tenant/ai/mcp/resources/readme.resource.ts +9 -0
  46. package/tests/fixtures/ai-tenant/ai/mcp/tools/echo.tool.ts +9 -0
  47. package/tests/fixtures/fake-provider.ts +33 -0
  48. package/tests/fixtures/tool-tenant/ai/agents/report.agent.ts +12 -0
  49. package/tests/fixtures/tool-tenant/ai/agents/report.tool.ts +12 -0
  50. package/tests/fixtures/vars-tenant/vars/open.var.ts +18 -0
  51. package/tests/hono.test.ts +81 -1
  52. package/tests/jwt-tenant.test.ts +165 -0
  53. package/tests/logger.test.ts +62 -10
  54. package/tests/messages.test.ts +88 -32
  55. package/tests/tool-runtime.test.ts +291 -0
  56. package/tests/tool-types.ts +33 -0
  57. package/tests/upload.test.ts +175 -3
  58. package/tests/vars-api.test.ts +73 -1
  59. package/tests/vars.test.ts +1 -1
  60. package/types/agent.d.ts +386 -4
  61. package/types/config.d.ts +56 -2
  62. package/types/file.d.ts +1 -1
  63. package/types/mcp.d.ts +2 -2
  64. package/utils/func.ts +20 -0
  65. package/utils/logger.ts +125 -33
package/lib/zip.ts CHANGED
@@ -23,9 +23,17 @@ const SIGNATURE = {
23
23
  /** Zip entries are never larger than this in a document we read — sanity guard. */
24
24
  const MAX_EOCD_SCAN = 65_535 + 22; // the folder comment cap + the record itself
25
25
 
26
+ /**
27
+ * Decompressed-entry ceiling (#41) — mirrors `MAX_FILE_BYTES` (attachment.ts) and
28
+ * stops a deflate bomb: a 20 KB archive can otherwise expand to gigabytes.
29
+ */
30
+ const MAX_ENTRY_BYTES = 10 * 1024 * 1024;
31
+
26
32
  type Entry = {
27
33
  method: number;
28
34
  compressedSize: number;
35
+ /** Declared size after inflation — checked before we allocate anything. */
36
+ uncompressedSize: number;
29
37
  localOffset: number;
30
38
  };
31
39
 
@@ -67,17 +75,18 @@ function openZip(buffer: Uint8Array): ZipArchive {
67
75
 
68
76
  const method = view.getUint16(offset + 10, true);
69
77
  const compressedSize = view.getUint32(offset + 20, true);
78
+ const uncompressedSize = view.getUint32(offset + 24, true);
70
79
  const nameLength = view.getUint16(offset + 28, true);
71
80
  const extraLength = view.getUint16(offset + 30, true);
72
81
  const commentLength = view.getUint16(offset + 32, true);
73
82
  const localOffset = view.getUint32(offset + 42, true);
74
83
 
75
- if (compressedSize === 0xffffffff || localOffset === 0xffffffff) {
84
+ if (compressedSize === 0xffffffff || uncompressedSize === 0xffffffff || localOffset === 0xffffffff) {
76
85
  throw bad("ZIP64 archives are not supported");
77
86
  }
78
87
 
79
88
  const name = new TextDecoder().decode(buffer.subarray(offset + 46, offset + 46 + nameLength));
80
- entries.set(name, { method, compressedSize, localOffset });
89
+ entries.set(name, { method, compressedSize, uncompressedSize, localOffset });
81
90
  offset += 46 + nameLength + extraLength + commentLength;
82
91
  }
83
92
 
@@ -98,9 +107,20 @@ function openZip(buffer: Uint8Array): ZipArchive {
98
107
  const data = buffer.subarray(start, start + entry.compressedSize);
99
108
  if (start + entry.compressedSize > buffer.length) throw bad(`truncated entry '${name}'`);
100
109
 
110
+ // The declared size is a first guard — before inflating anything.
111
+ if (entry.uncompressedSize > MAX_ENTRY_BYTES) {
112
+ throw bad(`entry '${name}' expands beyond the ${MAX_ENTRY_BYTES / 1024 / 1024}MB limit`);
113
+ }
114
+
101
115
  // 0 = stored, 8 = deflate — the only two methods Office writes
102
116
  if (entry.method === 0) return data;
103
- if (entry.method === 8) return new Uint8Array(inflateRawSync(data));
117
+ if (entry.method === 8) {
118
+ try {
119
+ return new Uint8Array(inflateRawSync(data, { maxOutputLength: MAX_ENTRY_BYTES }));
120
+ } catch {
121
+ throw bad(`entry '${name}' expands beyond the ${MAX_ENTRY_BYTES / 1024 / 1024}MB limit`);
122
+ }
123
+ }
104
124
  throw bad(`unsupported compression method ${entry.method} for '${name}'`);
105
125
  },
106
126
  };
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "@anteros/core",
3
3
  "module": "index.ts",
4
- "version": "0.0.44",
4
+ "version": "0.0.47",
5
5
  "type": "module",
6
6
  "description": "Anteros Core",
7
+ "license": "MIT",
7
8
  "private": false,
8
9
  "packageManager": "bun",
9
10
  "engines": {
@@ -22,16 +23,14 @@
22
23
  "@modelcontextprotocol/sdk": "^1.30.0",
23
24
  "@socket.io/bun-engine": "0.1.1",
24
25
  "bentocache": "1.6.1",
25
- "boxen": "^8.0.1",
26
- "dayjs": "1.11.21",
26
+ "boxen": "^9.0.0",
27
27
  "hono": "^4.13.1",
28
28
  "ioredis": "6.0.0",
29
- "joi": "18.2.3",
29
+ "joi": "^18.3.0",
30
30
  "jose": "6.2.8",
31
31
  "mongodb": "7.5.0",
32
32
  "socket.io": "4.8.3",
33
33
  "tcp-port-used": "^1.0.3",
34
- "ufo": "1.6.4",
35
34
  "zod": "^4.4.3"
36
35
  }
37
36
  }
package/server/agents.ts CHANGED
@@ -167,6 +167,9 @@ function presentResult(result: AgentGenerateResult) {
167
167
  ...(result.reasoning ? { reasoning: result.reasoning } : {}),
168
168
  toolCalls: result.toolCalls,
169
169
  toolResults: result.toolResults,
170
+ toolStats: result.toolStats,
171
+ ...(result.model ? { model: result.model } : {}),
172
+ ...(result.budget ? { budget: result.budget } : {}),
170
173
  steps: result.steps,
171
174
  usage: result.usage,
172
175
  finishReason: result.finishReason,
@@ -256,6 +259,9 @@ function streamResponse(
256
259
  object: await stream.object,
257
260
  toolCalls: await stream.toolCalls,
258
261
  toolResults: await stream.toolResults,
262
+ toolStats: await stream.toolStats,
263
+ model: await stream.model,
264
+ budget: await stream.budget,
259
265
  steps: await stream.steps,
260
266
  usage: await stream.usage,
261
267
  finishReason: await stream.finishReason,
@@ -299,6 +305,62 @@ function streamResponse(
299
305
  });
300
306
  }
301
307
 
308
+ /** A file a caller may attach over HTTP — content only, never a server path. */
309
+ type HttpAttachment = {
310
+ name?: string;
311
+ mimeType?: string;
312
+ data: string;
313
+ encoding?: "base64" | "utf8";
314
+ };
315
+
316
+ /**
317
+ * Validate the `files` field of the HTTP body (issue #29).
318
+ *
319
+ * Over HTTP a file is **content** — `{ name?, mimeType?, data, encoding? }`, base64
320
+ * or UTF-8 — never a server path. Path attachments are a server-side/in-process
321
+ * feature: accepting one here let any caller read a file the process could read
322
+ * (`files: ["../../../.env"]`), whose content then went to the model provider.
323
+ */
324
+ function httpAttachments(input: unknown): HttpAttachment[] | undefined {
325
+ if (input === undefined) return undefined;
326
+ if (!Array.isArray(input)) {
327
+ throw new AppError("`files` must be an array of { name, data, encoding } objects", {
328
+ status: 400, code: "AGENT_FILES_INVALID",
329
+ });
330
+ }
331
+
332
+ return input.map((entry) => {
333
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
334
+ throw new AppError("Each `files` entry must be an object — a server path is not accepted over HTTP", {
335
+ status: 400, code: "AGENT_FILES_INVALID",
336
+ });
337
+ }
338
+
339
+ const { name, mimeType, data, encoding } = entry as Record<string, unknown>;
340
+ if (typeof data !== "string") {
341
+ throw new AppError("Each `files` entry needs a `data` string (base64, or UTF-8 with `encoding: 'utf8'`)", {
342
+ status: 400, code: "AGENT_FILES_INVALID",
343
+ });
344
+ }
345
+ if (name !== undefined && typeof name !== "string") {
346
+ throw new AppError("A `files` entry `name` must be a string", { status: 400, code: "AGENT_FILES_INVALID" });
347
+ }
348
+ if (mimeType !== undefined && typeof mimeType !== "string") {
349
+ throw new AppError("A `files` entry `mimeType` must be a string", { status: 400, code: "AGENT_FILES_INVALID" });
350
+ }
351
+ if (encoding !== undefined && encoding !== "base64" && encoding !== "utf8") {
352
+ throw new AppError("A `files` entry `encoding` must be 'base64' or 'utf8'", { status: 400, code: "AGENT_FILES_INVALID" });
353
+ }
354
+
355
+ return {
356
+ ...(name !== undefined ? { name } : {}),
357
+ ...(mimeType !== undefined ? { mimeType } : {}),
358
+ data,
359
+ ...(encoding !== undefined ? { encoding } : {}),
360
+ } as HttpAttachment;
361
+ });
362
+ }
363
+
302
364
  function initializeAgents(app: Hono<{ Variables: HonoVariables }>) {
303
365
  app.post(AGENT_PREFIX, async (c) => {
304
366
  const started = Date.now();
@@ -347,6 +409,8 @@ function initializeAgents(app: Hono<{ Variables: HonoVariables }>) {
347
409
  // A resume carries its own conversation: no input to ask for
348
410
  const needsInput = (action === 'generate' || action === 'stream' || action === 'object') && !body?.resume;
349
411
  const input = needsInput ? normalizeInput(body) : undefined;
412
+ // Files are content over HTTP — a server path is refused before the run
413
+ const files = httpAttachments(body?.files);
350
414
  // `memory: { thread, resource }` or the flat `thread` / `resource`
351
415
  const thread = resolveThread(body);
352
416
  const resource = resolveResource(body);
@@ -360,6 +424,7 @@ function initializeAgents(app: Hono<{ Variables: HonoVariables }>) {
360
424
  thread,
361
425
  // The deprecated alias, so a rule written against it keeps working
362
426
  threadId: thread,
427
+ tenant: tenant_id,
363
428
  });
364
429
 
365
430
  const callOptions: AgentCallOptions = {
@@ -372,7 +437,7 @@ function initializeAgents(app: Hono<{ Variables: HonoVariables }>) {
372
437
  ...(body?.messages ? { messages: body.messages as AgentInput } : {}),
373
438
  // Stop the run when the client disconnects (no tokens past the wire)
374
439
  signal: c.req.raw.signal,
375
- ...(body?.files?.length ? { files: body.files } : {}),
440
+ ...(files?.length ? { files } : {}),
376
441
  ...(body?.resume ? { resume: body.resume } : {}),
377
442
  ...(readOnly ? { memory: { readOnly: true } } : {}),
378
443
  };
package/server/api.ts CHANGED
@@ -130,6 +130,235 @@ function omitAtPath(node: any, path: string[], keys: (string | RegExp)[]): any {
130
130
  return { ...node, [head]: rest.length ? omitAtPath(node[head], rest, keys) : func.omit(node[head], keys) };
131
131
  }
132
132
 
133
+ // ─── External-API guards (issues #31, #32, #33) ───────────────────────────
134
+ //
135
+ // The internal `rest` keeps its full surface (scripts and services may compute,
136
+ // replace, etc.); these checks only protect what a **client** sends to the HTTP
137
+ // API.
138
+
139
+ const RESERVED_UPLOAD_FIELDS = new Set(["_id", "_file", "createdAt", "updatedAt"]);
140
+
141
+ /** Framework-managed upload metadata cannot be named by the client (#31). */
142
+ function assertUploadMetadata(data: Record<string, any>): void {
143
+ for (const key of Object.keys(data)) {
144
+ if (RESERVED_UPLOAD_FIELDS.has(key) || key.startsWith("$")) {
145
+ throw new AppError(`Metadata field '${key}' is reserved`, { status: 400, code: "UPLOAD_FIELD_RESERVED" });
146
+ }
147
+ }
148
+ }
149
+
150
+ /** A plain object (not an array, not null) — used to validate client bodies (#24). */
151
+ function requirePlainObject(value: unknown, label: string): void {
152
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
153
+ throw new AppError(`\`${label}\` must be an object`, { status: 400, code: "INVALID_BODY" });
154
+ }
155
+ }
156
+
157
+ function requireStringArray(value: unknown, label: string): void {
158
+ if (!Array.isArray(value) || value.some((id) => typeof id !== "string" || !id.trim())) {
159
+ throw new AppError(`\`${label}\` must be an array of ids`, { status: 400, code: "INVALID_BODY" });
160
+ }
161
+ }
162
+
163
+ /** A client `id`/`_id` must be a string — an operator object (`$ne`) would match anything (#47). */
164
+ function requireId(body: any): void {
165
+ const id = body?.id ?? body?._id;
166
+ if (typeof id !== "string" || !id.trim() || id.startsWith("$")) {
167
+ throw new AppError("`id` must be a string", { status: 400, code: "INVALID_ID" });
168
+ }
169
+ }
170
+
171
+ /** Safe cookie defaults (overridable) — the doc promises HttpOnly/SameSite; enforce them (#48). */
172
+ function cookieDefaults(options: any = {}): Record<string, any> {
173
+ const base: Record<string, any> = { httpOnly: true, sameSite: "lax", path: "/" };
174
+ if ((Bun.env.NODE_ENV ?? process.env.NODE_ENV) === "production") base.secure = true;
175
+ return { ...base, ...(options ?? {}) };
176
+ }
177
+
178
+ /** Update operators a client may use (external API). `$rename` is deliberately absent (see #43). */
179
+ const CLIENT_UPDATE_OPERATORS = new Set([
180
+ "$set", "$unset", "$inc", "$mul", "$min", "$max", "$currentDate",
181
+ "$push", "$addToSet", "$pull", "$pullAll", "$pop",
182
+ ]);
183
+
184
+ /**
185
+ * External mutations must be an allow-listed operator document (#32):
186
+ * MongoDB refuses replacement documents in `findAndModify` (500 today), and the
187
+ * operators other than `$set` escaped Joi validation and `readOnlyFields`.
188
+ */
189
+ function assertOperatorUpdate(update: unknown, col: any): void {
190
+ if (!update || typeof update !== "object" || Array.isArray(update)) {
191
+ throw new AppError("`update` must be a MongoDB update document", { status: 400, code: "INVALID_UPDATE" });
192
+ }
193
+ const operators = Object.keys(update);
194
+ if (!operators.length || !operators.every((operator) => CLIENT_UPDATE_OPERATORS.has(operator))) {
195
+ throw new AppError("`update` must only use supported MongoDB update operators", { status: 400, code: "INVALID_UPDATE" });
196
+ }
197
+
198
+ const readOnly = (col?.api?.readOnlyFields ?? []).map((field: string | RegExp) =>
199
+ typeof field === "string" ? (name: string) => name === field : (name: string) => field.test(name),
200
+ );
201
+ const required = new Set((col?.fields ?? []).filter((field: any) => field.required).map((field: any) => field.name));
202
+
203
+ for (const operator of operators) {
204
+ const value = (update as Record<string, any>)[operator];
205
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
206
+ for (const target of Object.keys(value)) {
207
+ if (readOnly.some((match) => match(target))) {
208
+ throw new AppError(`Field '${target}' is read-only`, { status: 400, code: "READONLY_FIELD" });
209
+ }
210
+ if (operator === "$unset" && required.has(target)) {
211
+ throw new AppError(`Field '${target}' is required`, { status: 400, code: "INVALID_UPDATE" });
212
+ }
213
+ }
214
+ }
215
+ }
216
+
217
+ type PrivateMatcher = (name: string) => boolean;
218
+
219
+ /** `privateFields` (`string` | `RegExp`) as name matchers. */
220
+ function privateMatchers(col: any): PrivateMatcher[] {
221
+ return (col?.api?.privateFields ?? []).map((field: string | RegExp) =>
222
+ typeof field === "string"
223
+ ? (name: string) => name === field
224
+ : (name: string) => {
225
+ field.lastIndex = 0; // a `g`/`y` regex would otherwise keep state between calls
226
+ return field.test(name);
227
+ },
228
+ );
229
+ }
230
+
231
+ /** The join aliases a read may embed, with the private fields of their target. */
232
+ function collectJoinAliases(col: any, action: string, body: any): Array<{ alias: string; from: string; matchers: PrivateMatcher[] }> {
233
+ const joins: Array<{ alias: string; from: string; matchers: PrivateMatcher[] }> = [];
234
+ const targetMatchers = (slug: string) =>
235
+ privateMatchers(cfg.collections?.find((c) => c.slug === slug && c._tenant_ === col._tenant_));
236
+
237
+ if (action === "find" || action === "findOne") {
238
+ for (const inc of body?.params?.$include ?? []) {
239
+ if (typeof inc === "string") {
240
+ const field = col.fields?.find((f: any) => f.name === inc);
241
+ if (field?.relation?.to) joins.push({ alias: inc, from: field.relation.to, matchers: targetMatchers(field.relation.to) });
242
+ } else if (inc?.from) {
243
+ joins.push({ alias: inc.as || inc.localField, from: inc.from, matchers: targetMatchers(inc.from) });
244
+ }
245
+ }
246
+ }
247
+
248
+ const walk = (stages: any): void => {
249
+ if (!Array.isArray(stages)) return;
250
+ for (const stage of stages) {
251
+ if (!stage || typeof stage !== "object") continue;
252
+ const lookup = stage.$lookup ?? stage.$graphLookup;
253
+ if (lookup?.from && typeof lookup.as === "string") {
254
+ joins.push({ alias: lookup.as, from: lookup.from, matchers: targetMatchers(lookup.from) });
255
+ }
256
+ if (lookup?.pipeline) walk(lookup.pipeline);
257
+ if (stage.$facet && typeof stage.$facet === "object") {
258
+ for (const branch of Object.values(stage.$facet)) walk(branch);
259
+ }
260
+ }
261
+ };
262
+ walk(body?.pipeline);
263
+ return joins;
264
+ }
265
+
266
+ /**
267
+ * A client read may only join collections it could read itself (#36): the target's
268
+ * `api.access` rule is evaluated for the same caller, exactly like a `find` on it.
269
+ * Internal collections (`_audit_`, `_workflows_`, `_locks_`) are not declared and
270
+ * are therefore refused. The in-process `rest` is untouched.
271
+ */
272
+ async function assertJoinedCollectionsAllowed(
273
+ col: any,
274
+ action: string,
275
+ body: any,
276
+ c: any,
277
+ rest: InstanceType<typeof useRest>,
278
+ tenant_id: string,
279
+ ): Promise<void> {
280
+ const seen = new Set<string>();
281
+ for (const join of collectJoinAliases(col, action, body)) {
282
+ if (!join.from || seen.has(join.from)) continue;
283
+ seen.add(join.from);
284
+
285
+ const target = cfg.collections?.find((candidate) => candidate.slug === join.from && candidate._tenant_ === col._tenant_);
286
+ if (target) {
287
+ await evaluateAccess(target.api?.access as any, 'find', rest, `lookup:${join.from}`, c, {
288
+ collection: join.from,
289
+ tenant: tenant_id,
290
+ lookup: true,
291
+ });
292
+ continue;
293
+ }
294
+
295
+ const fileTarget = cfg.fileCollections?.find((candidate) => candidate.slug === join.from && candidate._tenant_ === col._tenant_);
296
+ if (fileTarget) {
297
+ await evaluateAccess(fileTarget.api?.access as any, 'read', rest, `lookup:${join.from}`, c, {
298
+ collection: join.from,
299
+ tenant: tenant_id,
300
+ lookup: true,
301
+ });
302
+ continue;
303
+ }
304
+
305
+ throw new AppError(`Joining '${join.from}' is not allowed`, { status: 403, code: "LOOKUP_TARGET_FORBIDDEN" });
306
+ }
307
+ }
308
+
309
+ /** Expression operators that can move a field value under another name without a `$path`. */
310
+ const PRIVATE_MOVING_OPERATORS = new Set(["$getField", "$objectToArray", "$arrayToObject"]);
311
+
312
+ /**
313
+ * A client read may not reach a private field **under another name** (#33):
314
+ * masking is by key name, so `$addFields: { leak: "$password" }` would slip
315
+ * through. A reference to a private field — own or through a join alias — is
316
+ * refused, as are the operators that can move a field without an explicit path.
317
+ * Internal `rest` calls are untouched.
318
+ */
319
+ function assertNoPrivateFieldAliasing(col: any, action: string, body: any): void {
320
+ const own = privateMatchers(col);
321
+ const joins = collectJoinAliases(col, action, body);
322
+ const guarded = own.length > 0 || joins.some((join) => join.matchers.length > 0);
323
+ if (!guarded) return;
324
+
325
+ const fail = () => {
326
+ throw new AppError("The request references a private field", { status: 400, code: "PRIVATE_FIELD_REFERENCE" });
327
+ };
328
+
329
+ const isPrivatePath = (path: string): boolean => {
330
+ const segments = path.split(".");
331
+ const head = segments[0] ?? "";
332
+ if (own.some((match) => match(head))) return true;
333
+ const join = joins.find((candidate) => candidate.alias === head);
334
+ return !!join && segments.length > 1 && join.matchers.some((match) => match(segments[1]!));
335
+ };
336
+
337
+ const walk = (node: any): void => {
338
+ if (typeof node === "string") {
339
+ if (node.startsWith("$") && !node.startsWith("$$")) {
340
+ if (isPrivatePath(node.slice(1))) fail();
341
+ } else if (node.startsWith("$$ROOT.") && isPrivatePath(node.slice(7))) {
342
+ fail();
343
+ } else if (node.startsWith("$$CURRENT.") && isPrivatePath(node.slice(10))) {
344
+ fail();
345
+ }
346
+ return;
347
+ }
348
+ if (!node || typeof node !== "object") return;
349
+ if (Array.isArray(node)) {
350
+ for (const value of node) walk(value);
351
+ return;
352
+ }
353
+ for (const [key, value] of Object.entries(node)) {
354
+ if (PRIVATE_MOVING_OPERATORS.has(key)) fail();
355
+ walk(value);
356
+ }
357
+ };
358
+
359
+ walk(action === "aggregate" ? body?.pipeline : body?.params);
360
+ }
361
+
133
362
  // ─── Access control ─────────────────────────────────────────────────────────
134
363
  // `evaluateAccess` / `errorResponse` / `getAccessToken` live in `./access`
135
364
  // (shared with the agents API).
@@ -284,7 +513,7 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
284
513
  }
285
514
 
286
515
  if (!cfg.tenants.find(t => t.id === tenant_id)) {
287
- throw new AppError('Tenant `' + tenant_id + '` not found', { status: 400, code: 'TENANT_NOT_FOUND' });
516
+ throw new AppError('Resource not found', { status: 400, code: 'TENANT_NOT_FOUND' });
288
517
  }
289
518
 
290
519
  // Store tenant in request context so nested useRest() calls (hooks, actions) resolve it
@@ -292,11 +521,11 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
292
521
 
293
522
  const col = getCollection(collection, tenant_id)
294
523
  if (!col) {
295
- throw new AppError('Collection `' + collection + '` not found', { status: 400, code: 'COLLECTION_NOT_FOUND' });
524
+ throw new AppError('Resource not found', { status: 400, code: 'COLLECTION_NOT_FOUND' });
296
525
  }
297
526
 
298
527
  if (!ActionsValues.includes(action as (typeof ActionsValues)[number]) && !Object.hasOwn(col?.actions ?? {}, action)) {
299
- throw new AppError('Action `' + action + '` not found', { status: 400, code: 'ACTION_NOT_FOUND' });
528
+ throw new AppError('Resource not found', { status: 400, code: 'ACTION_NOT_FOUND' });
300
529
  }
301
530
 
302
531
  const fieldOrder = ['_id', ...col.fields.map(f => f.name)];
@@ -314,7 +543,7 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
314
543
 
315
544
  await col.api.auth.onLogout({
316
545
  rest, payload: body?.payload, error: fn.error, jwt: func.jwt,
317
- cookies: { delete: (name: string) => cookie.deleteCookie(c, name) },
546
+ cookies: { delete: (name: string, opts?: any) => cookie.deleteCookie(c, name, cookieDefaults(opts)) },
318
547
  });
319
548
 
320
549
  await logActivity({ rest, tenant_id, action: 'logout', collection, status: 'success', input: { payload: body?.payload }, duration: Date.now() - logStart });
@@ -332,12 +561,19 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
332
561
  if (!col?.api?.auth?.enabled) throw new AppError('Auth is not enabled', { status: 400, code: 'AUTH_NOT_ENABLED' });
333
562
  if (!col?.api?.auth?.onLogin) throw new AppError('Auth onLogin is not defined', { status: 400, code: 'AUTH_HANDLER_NOT_DEFINED' });
334
563
 
564
+ // Tenant-bound tokens (#35): the claim is signed by the framework, so a
565
+ // token can never open another tenant's routes.
566
+ const loginJwt = {
567
+ ...func.jwt,
568
+ sign: (payload: Record<string, unknown>, options?: any) =>
569
+ func.jwt.sign({ ...payload, tenant: tenant_id }, options),
570
+ };
335
571
  const authResult = await col.api.auth.onLogin({
336
- rest, payload: body?.payload, error: fn.error, jwt: func.jwt,
572
+ rest, payload: body?.payload, error: fn.error, jwt: loginJwt,
337
573
  cookies: {
338
- set: (name: string, value: string, opts?: any) => cookie.setCookie(c, name, value, opts),
574
+ set: (name: string, value: string, opts?: any) => cookie.setCookie(c, name, value, cookieDefaults(opts)),
339
575
  get: (name: string) => cookie.getCookie(c, name),
340
- delete: (name: string) => cookie.deleteCookie(c, name),
576
+ delete: (name: string, opts?: any) => cookie.deleteCookie(c, name, cookieDefaults(opts)),
341
577
  },
342
578
  });
343
579
 
@@ -361,7 +597,15 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
361
597
 
362
598
  // ── Access control ──
363
599
  // Flaw 7: single implementation via evaluateAccess (checks expired/forged tokens)
364
- await evaluateAccess(col?.api?.access as any, action, rest, 'Action', c);
600
+ await evaluateAccess(col?.api?.access as any, action, rest, 'Action', c, { collection, tenant: tenant_id, body });
601
+
602
+ // ── External-API guards (issues #31–#33) ──
603
+ if (action === 'updateOne' || action === 'updateMany') {
604
+ assertOperatorUpdate(body?.update, col);
605
+ } else if (action === 'find' || action === 'findOne' || action === 'aggregate') {
606
+ assertNoPrivateFieldAliasing(col, action, body);
607
+ await assertJoinedCollectionsAllowed(col, action, body, c, rest, tenant_id);
608
+ }
365
609
 
366
610
  // ── Execute action ──
367
611
  if (col.actions?.[action]) {
@@ -372,6 +616,19 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
372
616
  const handler = crudHandlers[action];
373
617
  if (!handler) throw new AppError('Action `' + action + '` not found', { status: 400, code: 'ACTION_NOT_FOUND' });
374
618
 
619
+ // Client body shape + id guards (#24, #47) — custom actions are untouched
620
+ if (action === 'insertOne') {
621
+ requirePlainObject(body?.data, 'data');
622
+ } else if (action === 'insertMany') {
623
+ if (!Array.isArray(body?.data)) {
624
+ throw new AppError("`data` must be an array", { status: 400, code: "INVALID_BODY" });
625
+ }
626
+ } else if (action === 'updateOne' || action === 'deleteOne' || action === 'findOne') {
627
+ requireId(body);
628
+ } else if (action === 'updateMany' || action === 'deleteMany') {
629
+ requireStringArray(body?.ids ?? body?._ids, 'ids');
630
+ }
631
+
375
632
  // Strip readOnlyFields for mutations
376
633
  if (action === 'insertOne' || action === 'insertMany') {
377
634
  stripReadOnlyData(col, body);
@@ -432,10 +689,10 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
432
689
  // Only namespaces declared with `define.Vars` are reachable, and their
433
690
  // `api.access` gates every action (no rules = denied).
434
691
  const definition = getVarsDefinition(tenant_id, ns);
435
- if (!definition) throw new AppError(`Variables namespace '${ns}' not found`, { status: 400, code: 'NAMESPACE_NOT_FOUND' });
692
+ if (!definition) throw new AppError('Resource not found', { status: 400, code: 'NAMESPACE_NOT_FOUND' });
436
693
 
437
694
  rest = new useRest({ internal: false, tenant_id });
438
- await evaluateAccess(definition.api?.access as any, action, rest, `vars:${ns}`, c);
695
+ await evaluateAccess(definition.api?.access as any, action, rest, `vars:${ns}`, c, { namespace: ns, body, tenant: tenant_id });
439
696
 
440
697
  const vars = rest.vars;
441
698
  const { key, value, meta, ttl, scope, where, entries, by } = body ?? {};
@@ -465,7 +722,6 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
465
722
  collection: `_vars_:${ns}`,
466
723
  status: 'success',
467
724
  input: { ns, key, scope, ttl },
468
- result: ['get', 'all', 'entry', 'entries'].includes(action) ? response : undefined,
469
725
  duration: Date.now() - logStart,
470
726
  token: { decoded: accessToken.decoded, value: null, provided: true, expired: false },
471
727
  });
@@ -513,7 +769,7 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
513
769
 
514
770
  const serviceInstance = cfg.services?.find(s => s.name === service && s._tenant_ === tenant_id) as Service;
515
771
  const { action } = c.req.param() as { action: string };
516
- if (!serviceInstance) throw new AppError('Service `' + service + '` not found', { status: 400, code: 'SERVICE_NOT_FOUND' });
772
+ if (!serviceInstance) throw new AppError('Resource not found', { status: 400, code: 'SERVICE_NOT_FOUND' });
517
773
  if (!serviceInstance.enabled) throw new AppError('Service `' + service + '` is not enabled', { status: 400, code: 'SERVICE_NOT_ENABLED' });
518
774
  if (!serviceInstance.actions?.[action]) throw new AppError('Service `' + service + '` is not defined', { status: 400, code: 'SERVICE_NOT_DEFINED' });
519
775
 
@@ -521,7 +777,7 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
521
777
 
522
778
  // Access control (same logic as checkFileAccess)
523
779
  const serviceAccess = serviceInstance.api?.access as Record<string, boolean | Function> | undefined;
524
- await evaluateAccess(serviceAccess as any, action, rest, action, c);
780
+ await evaluateAccess(serviceAccess as any, action, rest, action, c, { service, tenant: tenant_id, body });
525
781
 
526
782
  // No `logActivity` here: the call is delegated to `rest.runService()`, which
527
783
  // audits it once (`action: 'runService'`, `internal: false`, params only)
@@ -545,7 +801,7 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
545
801
 
546
802
  const colUpload = getFileCollection(collection, tenant_id);
547
803
  if (!colUpload) {
548
- throw new AppError('File collection `' + collection + '` not found', { status: 400, code: 'FILE_COLLECTION_NOT_FOUND' });
804
+ throw new AppError('Resource not found', { status: 400, code: 'FILE_COLLECTION_NOT_FOUND' });
549
805
  }
550
806
  await checkFileAccess(colUpload?.api?.access as any, 'upload', tenant_id, c);
551
807
 
@@ -555,13 +811,13 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
555
811
  }
556
812
 
557
813
  // Security: reject oversized bodies BEFORE parseBody buffers them in memory.
558
- // `maxSize` doubles as the TOTAL request allowance → a multi-file upload must
559
- // fit within it (each file is also validated individually in handleUpload).
814
+ // `upload.maxSize` is the per-file cap AND the upload request allowance →
815
+ // a multi-file upload must fit within it (each file is validated in handleUpload).
560
816
  const contentLength = Number(c.req.header('Content-Length') ?? 0);
561
- const maxUploadSize = colUpload.upload?.maxSize ?? 10 * 1024 * 1024; // 10MB default
817
+ const maxRequestSize = colUpload.upload?.maxSize ?? 100 * 1024 * 1024; // 100MB upload default
562
818
  // multipart overhead (boundaries/headers) ≈ 64KB max
563
- if (Number.isFinite(contentLength) && contentLength > 0 && contentLength > maxUploadSize + 64 * 1024) {
564
- throw new AppError(`Request too large (max ${Math.round(maxUploadSize / 1024 / 1024)}MB)`, {
819
+ if (Number.isFinite(contentLength) && contentLength > 0 && contentLength > maxRequestSize + 64 * 1024) {
820
+ throw new AppError(`Request too large (max ${Math.round(maxRequestSize / 1024 / 1024)}MB)`, {
565
821
  code: 'FILE_TOO_LARGE', status: 413,
566
822
  });
567
823
  }
@@ -591,6 +847,23 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
591
847
  data[key] = list.length === 1 ? list[0] : list;
592
848
  }
593
849
 
850
+ // Framework-managed fields are not client metadata (#31)
851
+ assertUploadMetadata(data);
852
+
853
+ // Only declared fields, and never a read-only one (#46)
854
+ const declared = new Set((colUpload.fields ?? []).map((field: any) => field.name));
855
+ const readOnlyMatchers = ((colUpload.api?.readOnlyFields ?? []) as (string | RegExp)[]).map((field) =>
856
+ typeof field === "string" ? (name: string) => name === field : (name: string) => field.test(name),
857
+ );
858
+ for (const key of Object.keys(data)) {
859
+ if (!declared.has(key)) {
860
+ throw new AppError(`Unknown metadata field '${key}'`, { status: 400, code: "UPLOAD_FIELD_UNKNOWN" });
861
+ }
862
+ if (readOnlyMatchers.some((match) => match(key))) {
863
+ throw new AppError(`Field '${key}' is read-only`, { status: 400, code: "READONLY_FIELD" });
864
+ }
865
+ }
866
+
594
867
  // One document per file (per-file validation: MIME, size, storage, replication).
595
868
  // 1 file → single object (backward compatible), N files → array (SDK contract).
596
869
  const results: FileResult[] = [];
@@ -645,7 +918,16 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
645
918
 
646
919
  c.header('Content-Type', result.mimetype);
647
920
  if (result.size) c.header('Content-Length', String(result.size));
648
- c.header('Cache-Control', 'public, max-age=31536000, immutable');
921
+ // Public immutable caching only when the read rule is literally public (#49);
922
+ // a function rule (token-dependent) must never be cached by a shared proxy.
923
+ const fileAccess = colServe.api?.access as Record<string, any> | undefined;
924
+ const readRule = fileAccess?.read ?? fileAccess?.['*'];
925
+ if (readRule === true) {
926
+ c.header('Cache-Control', 'public, max-age=31536000, immutable');
927
+ } else {
928
+ c.header('Cache-Control', 'private, no-store');
929
+ c.header('Vary', 'Authorization, Cookie');
930
+ }
649
931
  // Flaw 4: SVG served as attachment (no inline execution)
650
932
  if (result.attachment) {
651
933
  c.header('Content-Disposition', `attachment; filename="${filename}"`);