@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
@@ -0,0 +1,291 @@
1
+ import { describe, it, expect, afterAll } from "bun:test";
2
+ import { Agent, define, v } from "../index";
3
+ import { cfg } from "../server/config";
4
+ import { createAgents, syncAgents } from "../lib/agents";
5
+ import { AppError } from "../lib/error";
6
+ import { joiToJsonSchema } from "../lib/jsonSchema";
7
+ import {
8
+ anthropicText,
9
+ anthropicTool,
10
+ anthropicToolCalls,
11
+ at,
12
+ fakeProvider,
13
+ openaiText,
14
+ openaiTool,
15
+ openaiToolCalls,
16
+ request,
17
+ type Fake,
18
+ } from "./fixtures/fake-provider";
19
+
20
+ /**
21
+ * The tool runtime — what a tool receives, what the model reads back, and the
22
+ * guards around it (codes, media, bounded concurrency, counters, descriptions).
23
+ */
24
+
25
+ const servers: Fake[] = [];
26
+ function track(fake: Fake): Fake {
27
+ servers.push(fake);
28
+ return fake;
29
+ }
30
+
31
+ afterAll(() => {
32
+ for (const server of servers) server.stop();
33
+ });
34
+
35
+ function makeAgent(
36
+ tools: Record<string, any>,
37
+ provider: { baseUrl: string; protocol?: "openai" | "anthropic" },
38
+ overrides: Record<string, any> = {},
39
+ ) {
40
+ return new Agent({
41
+ id: "tools",
42
+ description: "Tool runtime test agent.",
43
+ instructions: "Use the tools.",
44
+ provider: {
45
+ model: "test-model",
46
+ apiKey: "test-key",
47
+ protocol: provider.protocol ?? "openai",
48
+ baseUrl: provider.baseUrl,
49
+ options: { retries: 0 },
50
+ },
51
+ tools,
52
+ ...overrides,
53
+ } as any);
54
+ }
55
+
56
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
57
+
58
+ describe("tool errors carry a code", () => {
59
+ it("serializes the AppError code for the model, and counts it", async () => {
60
+ const provider = track(fakeProvider((_body, _req, hits) =>
61
+ hits === 1 ? openaiTool("order", { id: "A-1" }) : openaiText("sorry")));
62
+
63
+ const agent = makeAgent({
64
+ order: define.Tool({
65
+ id: "order",
66
+ description: "Reads an order",
67
+ inputSchema: v.object({ id: v.string().required() }),
68
+ execute: () => {
69
+ throw new AppError("No order 'A-1'", { status: 404, code: "ORDER_NOT_FOUND" });
70
+ },
71
+ }),
72
+ }, { baseUrl: provider.url });
73
+
74
+ const result = await agent.generate("go");
75
+
76
+ expect(at(result.toolResults, 0).errorCode).toBe("ORDER_NOT_FOUND");
77
+ expect(request(provider, 1).body.messages[3].content).toBe("Error [ORDER_NOT_FOUND]: No order 'A-1'");
78
+ expect(result.toolStats).toMatchObject({ calls: 1, errors: 1, byCode: { ORDER_NOT_FOUND: 1 }, timeouts: 0 });
79
+ });
80
+
81
+ it("codes a timeout, and keeps the run going", async () => {
82
+ const provider = track(fakeProvider((_body, _req, hits) =>
83
+ hits === 1 ? openaiTool("slow", {}) : openaiText("gave up")));
84
+
85
+ const agent = makeAgent({
86
+ slow: define.Tool({
87
+ id: "slow",
88
+ description: "Never answers in time",
89
+ inputSchema: v.object({}),
90
+ timeoutMs: 5,
91
+ execute: () => sleep(50),
92
+ }),
93
+ }, { baseUrl: provider.url });
94
+
95
+ const result = await agent.generate("go");
96
+
97
+ expect(at(result.toolResults, 0).errorCode).toBe("AGENT_TOOL_TIMEOUT");
98
+ expect(result.toolStats.timeouts).toBe(1);
99
+ expect(result.finishReason).toBe("stop");
100
+ });
101
+ });
102
+
103
+ describe("tool media results", () => {
104
+ const chart = () => define.Tool({
105
+ id: "chart",
106
+ description: "Builds a chart",
107
+ inputSchema: v.object({}),
108
+ execute: () => ({
109
+ content: [
110
+ { type: "text", text: "Here is the chart" },
111
+ { type: "image", data: "QUJD", mimeType: "image/png" },
112
+ ],
113
+ }),
114
+ });
115
+
116
+ it("hands an image to Anthropic as a tool_result block", async () => {
117
+ const provider = track(fakeProvider((_body, _req, hits) =>
118
+ hits === 1 ? anthropicTool("chart", {}) : anthropicText("done")));
119
+
120
+ const agent = makeAgent({ chart: chart() }, { baseUrl: provider.url, protocol: "anthropic" });
121
+ await agent.generate("go");
122
+
123
+ const messages = request(provider, 1).body.messages;
124
+ const toolResult = messages
125
+ .flatMap((message: any) => Array.isArray(message.content) ? message.content : [])
126
+ .find((block: any) => block.type === "tool_result");
127
+
128
+ expect(toolResult.content).toEqual([
129
+ { type: "text", text: "Here is the chart" },
130
+ { type: "image", source: { type: "base64", media_type: "image/png", data: "QUJD" } },
131
+ ]);
132
+ });
133
+
134
+ it("degrades to a text marker on OpenAI, whose tool message is text-only", async () => {
135
+ const provider = track(fakeProvider((_body, _req, hits) =>
136
+ hits === 1 ? openaiTool("chart", {}) : openaiText("done")));
137
+
138
+ const agent = makeAgent({ chart: chart() }, { baseUrl: provider.url });
139
+ await agent.generate("go");
140
+
141
+ expect(request(provider, 1).body.messages[3].content).toBe("Here is the chart\n[image: image/png]");
142
+ });
143
+ });
144
+
145
+ describe("bounded tool parallelism", () => {
146
+ it("runs read-only tools together, at most `maxParallelToolCalls` at a time", async () => {
147
+ const state = { running: 0, peak: 0 };
148
+ const provider = track(fakeProvider((_body, _req, hits) => hits === 1
149
+ ? openaiToolCalls(Array.from({ length: 6 }, (_, index) => ({ name: "read", args: {}, id: `c${index}` })))
150
+ : openaiText("done")));
151
+
152
+ const agent = makeAgent({
153
+ read: define.Tool({
154
+ id: "read",
155
+ description: "Reads something",
156
+ readOnly: true,
157
+ inputSchema: v.object({}),
158
+ execute: async () => {
159
+ state.running += 1;
160
+ state.peak = Math.max(state.peak, state.running);
161
+ await sleep(10);
162
+ state.running -= 1;
163
+ return { ok: true };
164
+ },
165
+ }),
166
+ }, { baseUrl: provider.url }, { maxParallelToolCalls: 2 });
167
+
168
+ const result = await agent.generate("go");
169
+
170
+ expect(result.toolResults).toHaveLength(6);
171
+ expect(state.peak).toBe(2);
172
+ });
173
+
174
+ it("keeps writes sequential, even in a batch", async () => {
175
+ const state = { running: 0, peak: 0 };
176
+ const provider = track(fakeProvider((_body, _req, hits) => hits === 1
177
+ ? openaiToolCalls([{ name: "write", args: {} }, { name: "write", args: {} }, { name: "write", args: {} }])
178
+ : openaiText("done")));
179
+
180
+ const agent = makeAgent({
181
+ write: define.Tool({
182
+ id: "write",
183
+ description: "Writes something",
184
+ inputSchema: v.object({}),
185
+ execute: async () => {
186
+ state.running += 1;
187
+ state.peak = Math.max(state.peak, state.running);
188
+ await sleep(5);
189
+ state.running -= 1;
190
+ return { ok: true };
191
+ },
192
+ }),
193
+ }, { baseUrl: provider.url });
194
+
195
+ await agent.generate("go");
196
+
197
+ expect(state.peak).toBe(1);
198
+ });
199
+
200
+ it("bounds the calls of a step that forced parallelism", async () => {
201
+ const state = { running: 0, peak: 0 };
202
+ const provider = track(fakeProvider((_body, _req, hits) => hits === 1
203
+ ? openaiToolCalls(Array.from({ length: 5 }, (_, index) => ({ name: "job", args: {}, id: `j${index}` })))
204
+ : openaiText("done")));
205
+
206
+ const agent = makeAgent({
207
+ job: define.Tool({
208
+ id: "job",
209
+ description: "Runs a job",
210
+ inputSchema: v.object({}),
211
+ execute: async () => {
212
+ state.running += 1;
213
+ state.peak = Math.max(state.peak, state.running);
214
+ await sleep(10);
215
+ state.running -= 1;
216
+ return { ok: true };
217
+ },
218
+ }),
219
+ }, { baseUrl: provider.url }, { parallelTools: true, maxParallelToolCalls: 2 });
220
+
221
+ await agent.generate("go");
222
+
223
+ expect(state.peak).toBe(2);
224
+ });
225
+ });
226
+
227
+ describe("tool stats", () => {
228
+ it("aggregates calls, errors and duration", async () => {
229
+ const provider = track(fakeProvider((_body, _req, hits) => hits === 1
230
+ ? openaiToolCalls([{ name: "ok", args: {} }, { name: "boom", args: {} }])
231
+ : openaiText("done")));
232
+
233
+ const agent = makeAgent({
234
+ ok: define.Tool({
235
+ id: "ok",
236
+ description: "Works",
237
+ inputSchema: v.object({}),
238
+ execute: () => ({ ok: true }),
239
+ }),
240
+ boom: define.Tool({
241
+ id: "boom",
242
+ description: "Fails",
243
+ inputSchema: v.object({}),
244
+ execute: () => {
245
+ throw new AppError("kaput", { status: 500, code: "KAPUT" });
246
+ },
247
+ }),
248
+ }, { baseUrl: provider.url });
249
+
250
+ const result = await agent.generate("go");
251
+
252
+ expect(result.toolStats.calls).toBe(2);
253
+ expect(result.toolStats.errors).toBe(1);
254
+ expect(result.toolStats.byCode).toEqual({ KAPUT: 1 });
255
+ expect(result.toolStats.durationMs).toBeGreaterThanOrEqual(0);
256
+ });
257
+ });
258
+
259
+ describe("tool schema fidelity", () => {
260
+ it("closes the object and publishes defaults", () => {
261
+ const schema = joiToJsonSchema(v.object({
262
+ city: v.string().required(),
263
+ days: v.number().default(1),
264
+ }));
265
+ expect(schema.additionalProperties).toBe(false);
266
+ expect(schema.properties.days.default).toBe(1);
267
+ expect(schema.required).toEqual(["city"]);
268
+ });
269
+
270
+ it("keeps the object open when Joi allows unknown keys", () => {
271
+ const schema = joiToJsonSchema(v.object({ a: v.string() }).unknown());
272
+ expect(schema.additionalProperties).toBe(true);
273
+ });
274
+ });
275
+
276
+ describe("tool descriptions at load time", () => {
277
+ it("skips a file tool without a description, and keeps the described one", async () => {
278
+ const previous = cfg.tenants;
279
+ try {
280
+ cfg.tenants = [{ id: "tool-tenant", dir: "packages/core/tests/fixtures/tool-tenant", database: { uri: "mongodb://localhost:27017/none" } }] as any;
281
+ await syncAgents();
282
+
283
+ const report = createAgents("tool-tenant").get("report")!;
284
+ expect(report.hasTool("chart")).toBe(true);
285
+ expect(report.hasTool("hidden")).toBe(false);
286
+ } finally {
287
+ cfg.tenants = previous;
288
+ await syncAgents().catch(() => { });
289
+ }
290
+ });
291
+ });
@@ -0,0 +1,33 @@
1
+ import { z } from "zod";
2
+ import { define, v } from "../index";
3
+
4
+ /**
5
+ * Compile-time contract of `define.Tool` — checked by `tsc`, not by `bun test`.
6
+ *
7
+ * Every `@ts-expect-error` fails the type-check when the error disappears, so a
8
+ * regression in the exported types is caught even though nothing runs.
9
+ */
10
+
11
+ // The description is required: a tool the model cannot understand is not usable.
12
+ // @ts-expect-error — `description` is missing
13
+ define.Tool({ id: "no-description", execute: () => 1 });
14
+
15
+ // A zod schema types the handler's input (`z.output`).
16
+ const typed = define.Tool({
17
+ id: "typed",
18
+ description: "Typed by its schema",
19
+ inputSchema: z.object({ city: z.string(), days: z.number().optional() }),
20
+ execute: ({ city, days }) => city.toUpperCase() + (days ?? 0),
21
+ });
22
+ // @ts-expect-error — `city` is a string, not a number
23
+ typed.execute!({ city: 42 }, {} as any);
24
+
25
+ // Joi cannot infer types, so an explicit parameter does the job.
26
+ const explicit = define.Tool<{ city: string }>({
27
+ id: "explicit",
28
+ description: "Explicit input type",
29
+ inputSchema: v.object({ city: v.string().required() }),
30
+ execute: ({ city }) => city.length,
31
+ });
32
+ // @ts-expect-error — `city` is a string, not a number
33
+ explicit.execute!({ city: 1 }, {} as any);
@@ -6,6 +6,7 @@ import { syncTenants } from "../database/tenant";
6
6
  import { getFileCollection } from "../database/file";
7
7
  import { useRest } from "../database/rest";
8
8
  import { createApi } from "../lib/api";
9
+ import { jwt } from "../utils/func";
9
10
 
10
11
  const TENANT = "up-test";
11
12
  const DB = "mongodb://localhost:27017/_DB_UP_TEST";
@@ -14,10 +15,11 @@ const SLUG = "photos";
14
15
  let rest: InstanceType<typeof useRest>;
15
16
  let server: any;
16
17
  let url = "";
18
+ let token = "";
17
19
 
18
20
  beforeAll(async () => {
19
21
  formatConfig({
20
- server: { port: 4000 },
22
+ server: { port: 4000, jwt: { secret: "upload-test-secret" } },
21
23
  tenants: [{ id: TENANT, dir: "up-tenant", database: { uri: DB } }],
22
24
  });
23
25
  await syncTenants();
@@ -27,13 +29,34 @@ beforeAll(async () => {
27
29
  _tenant_: TENANT,
28
30
  _isFileCollection_: true,
29
31
  slug: SLUG,
30
- fields: [{ name: "label", type: "string" }, { name: "takenAt", type: "date" }],
31
- upload: { allowedMimeTypes: ["image/png", "text/plain"], maxSize: 5 * 1024 * 1024 },
32
+ fields: [
33
+ { name: "label", type: "string" },
34
+ { name: "takenAt", type: "date" },
35
+ { name: "owner", type: "string" },
36
+ ],
37
+ upload: { allowedMimeTypes: ["image/png", "text/plain", "text/html"], maxSize: 5 * 1024 * 1024 },
38
+ storage: { driver: "disk" },
39
+ api: { access: { "*": true }, readOnlyFields: ["owner"] },
40
+ }, {
41
+ _tenant_: TENANT,
42
+ _isFileCollection_: true,
43
+ slug: "private-photos",
44
+ fields: [{ name: "label", type: "string" }],
45
+ upload: { allowedMimeTypes: ["image/png"], maxSize: 5 * 1024 * 1024 },
46
+ storage: { driver: "disk" },
47
+ api: { access: { upload: true, read: (ctx: any) => !!ctx.token?.value } },
48
+ }, {
49
+ _tenant_: TENANT,
50
+ _isFileCollection_: true,
51
+ slug: "large-photos",
52
+ fields: [{ name: "label", type: "string" }],
53
+ upload: { allowedMimeTypes: ["image/png"], maxSize: 100 * 1024 * 1024 },
32
54
  storage: { driver: "disk" },
33
55
  api: { access: { "*": true } },
34
56
  }];
35
57
 
36
58
  rest = new useRest({ internal: false, tenant_id: TENANT });
59
+ token = await jwt.sign({ sub: "upload-tester" });
37
60
  const app = createApp();
38
61
  server = Bun.serve({ port: 0, fetch: app.fetch });
39
62
  url = server.url.href.replace(/\/$/, "");
@@ -45,6 +68,8 @@ afterAll(async () => {
45
68
  try {
46
69
  const fs = await import("node:fs/promises");
47
70
  await fs.rm(`${process.cwd()}/storage/${TENANT}/${SLUG}`, { recursive: true, force: true });
71
+ await fs.rm(`${process.cwd()}/storage/${TENANT}/private-photos`, { recursive: true, force: true });
72
+ await fs.rm(`${process.cwd()}/storage/${TENANT}/large-photos`, { recursive: true, force: true });
48
73
  } catch {}
49
74
  try { server.stop(true); } catch {}
50
75
  });
@@ -269,3 +294,150 @@ describe("api.files — the in-process facade", () => {
269
294
  expect((await fs.readdir(dir)).some((name) => name.startsWith(uploaded._id))).toBe(false);
270
295
  });
271
296
  });
297
+
298
+ describe("upload — reserved metadata fields (#31)", () => {
299
+ async function statusFor(fields: Record<string, string>) {
300
+ const form = new FormData();
301
+ form.append("file", pngFile("reserved.png"));
302
+ for (const [key, value] of Object.entries(fields)) form.append(key, value);
303
+ const before = await rest.db.collection(SLUG).countDocuments();
304
+ const res = await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form });
305
+ const body: any = await res.json();
306
+ const after = await rest.db.collection(SLUG).countDocuments();
307
+ return { status: res.status, code: body.code, before, after };
308
+ }
309
+
310
+ it("refuses a client-provided `_id` (path traversal vector)", async () => {
311
+ const result = await statusFor({ _id: "../../../../v1/routes/pwn" });
312
+ expect(result.status).toBe(400);
313
+ expect(result.code).toBe("UPLOAD_FIELD_RESERVED");
314
+ expect(result.after).toBe(result.before); // nothing written
315
+ });
316
+
317
+ it("refuses `_file` and server timestamps", async () => {
318
+ for (const field of ["_file", "createdAt", "updatedAt"]) {
319
+ const result = await statusFor({ [field]: "x" });
320
+ expect({ field, status: result.status, code: result.code })
321
+ .toEqual({ field, status: 400, code: "UPLOAD_FIELD_RESERVED" });
322
+ }
323
+ });
324
+
325
+ it("refuses `$`-prefixed metadata keys", async () => {
326
+ const result = await statusFor({ "$set": "x" });
327
+ expect(result.status).toBe(400);
328
+ expect(result.code).toBe("UPLOAD_FIELD_RESERVED");
329
+ });
330
+ });
331
+
332
+ describe("serve — inline only for safe images (#37)", () => {
333
+ async function uploadAndServe(file: File) {
334
+ const form = new FormData();
335
+ form.append("file", file);
336
+ const res = await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form });
337
+ const uploaded: any = await res.json();
338
+ if (!uploaded?._file) throw new Error(`upload failed: ${res.status} ${JSON.stringify(uploaded)}`);
339
+ return fetch(`${url}${uploaded._file.url}`);
340
+ }
341
+
342
+ it("serves an uploaded HTML file as an attachment, never inline", async () => {
343
+ const html = new File(
344
+ [Buffer.from("<html><script>alert('xss')</script></html>")],
345
+ "evil.html",
346
+ { type: "text/html" },
347
+ );
348
+ const res = await uploadAndServe(html);
349
+ expect(res.status).toBe(200);
350
+ expect(res.headers.get("content-disposition")).toMatch(/^attachment/i);
351
+ expect(res.headers.get("content-type")).toContain("text/html");
352
+ });
353
+
354
+ it("keeps a raster image inline", async () => {
355
+ const res = await uploadAndServe(pngFile("inline.png"));
356
+ expect(res.status).toBe(200);
357
+ expect(res.headers.get("content-disposition")).toBeNull();
358
+ expect(res.headers.get("content-type")).toContain("image/png");
359
+ });
360
+ });
361
+
362
+ describe("upload — declared/read-only metadata (#46)", () => {
363
+ it("refuses an undeclared metadata field", async () => {
364
+ const form = new FormData();
365
+ form.append("file", pngFile("unknown.png"));
366
+ form.append("nope", "x");
367
+ const res = await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form });
368
+ expect(res.status).toBe(400);
369
+ expect(((await res.json()) as any).code).toBe("UPLOAD_FIELD_UNKNOWN");
370
+ });
371
+
372
+ it("refuses a read-only metadata field", async () => {
373
+ const form = new FormData();
374
+ form.append("file", pngFile("readonly.png"));
375
+ form.append("owner", "someone-else");
376
+ const res = await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form });
377
+ expect(res.status).toBe(400);
378
+ expect(((await res.json()) as any).code).toBe("READONLY_FIELD");
379
+ });
380
+ });
381
+
382
+ describe("serve — cache policy follows the read rule (#49)", () => {
383
+ async function uploadTo(collection: string) {
384
+ const form = new FormData();
385
+ form.append("file", pngFile("cache.png"));
386
+ const res = await fetch(`${url}/api/${TENANT}/upload/${collection}`, { method: "POST", body: form });
387
+ return (await res.json()) as any;
388
+ }
389
+
390
+ it("caches publicly when `read` is `true`", async () => {
391
+ const uploaded = await uploadTo(SLUG);
392
+ const res = await fetch(`${url}${uploaded._file.url}`);
393
+ expect(res.status).toBe(200);
394
+ expect(res.headers.get("cache-control")).toBe("public, max-age=31536000, immutable");
395
+ });
396
+
397
+ it("never caches a token-dependent read rule", async () => {
398
+ const uploaded = await uploadTo("private-photos");
399
+ expect(uploaded._file?.url).toBeString();
400
+
401
+ const anonymous = await fetch(`${url}${uploaded._file.url}`);
402
+ expect(anonymous.status).toBe(401);
403
+
404
+ const res = await fetch(`${url}${uploaded._file.url}`, { headers: { Authorization: `Bearer ${token}` } });
405
+ expect(res.status).toBe(200);
406
+ expect(res.headers.get("cache-control")).toBe("private, no-store");
407
+ expect(res.headers.get("vary")).toContain("Authorization");
408
+ });
409
+ });
410
+
411
+ describe("upload — size is rejected before buffering (#51)", () => {
412
+ it("refuses a file larger than `upload.maxSize`", async () => {
413
+ const png = Buffer.from(
414
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
415
+ "base64",
416
+ );
417
+ const big = new File([Buffer.concat([png, Buffer.alloc(6 * 1024 * 1024)])], "big.png", { type: "image/png" });
418
+ const form = new FormData();
419
+ form.append("file", big);
420
+ const res = await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form });
421
+ expect(res.status).toBe(413);
422
+ expect(((await res.json()) as any).code).toBe("FILE_TOO_LARGE");
423
+ });
424
+ });
425
+
426
+ describe("upload — 100MB per-file allowance, 16MB normal body (#51)", () => {
427
+ it("accepts a 20MB upload even though normal bodies default to 16MB", async () => {
428
+ const header = Buffer.from(
429
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
430
+ "base64",
431
+ );
432
+ const file = new File([header, Buffer.alloc(20 * 1024 * 1024)], "large.png", { type: "image/png" });
433
+ expect(file.size).toBeGreaterThan(16 * 1024 * 1024);
434
+
435
+ const form = new FormData();
436
+ form.append("file", file);
437
+ const res = await fetch(`${url}/api/${TENANT}/upload/large-photos`, { method: "POST", body: form });
438
+
439
+ expect(res.status).toBe(200);
440
+ const body = (await res.json()) as any;
441
+ expect(body._file.size).toBe(file.size);
442
+ });
443
+ });
@@ -5,6 +5,7 @@ import { formatConfig, cfg } from "../server/config";
5
5
  import { syncTenants } from "../database/tenant";
6
6
  import { syncVars } from "../database/vars";
7
7
  import { useRest } from "../database/rest";
8
+ import { jwt } from "../utils/func";
8
9
 
9
10
  const TENANT = "vars-api";
10
11
  const DB = "mongodb://localhost:27017/_VARS_API_TEST";
@@ -13,16 +14,18 @@ const DIR = "packages/core/tests/fixtures/vars-tenant";
13
14
  let rest: InstanceType<typeof useRest>;
14
15
  let server: any;
15
16
  let url = "";
17
+ let token = "";
16
18
 
17
19
  beforeAll(async () => {
18
20
  formatConfig({
19
- server: { port: 4000 },
21
+ server: { port: 4000, jwt: { secret: "vars-api-test-secret" } },
20
22
  tenants: [{ id: TENANT, dir: DIR, database: { uri: DB } }],
21
23
  });
22
24
  await syncTenants();
23
25
  await syncVars();
24
26
 
25
27
  rest = new useRest({ internal: false, tenant_id: TENANT });
28
+ token = await jwt.sign({ sub: "vars-tester" });
26
29
  const app = createApp();
27
30
  server = Bun.serve({ port: 0, fetch: app.fetch });
28
31
  url = server.url.href.replace(/\/$/, "");
@@ -45,6 +48,16 @@ const json = async (action: string, body: any) => {
45
48
  return { status: res.status, body: await res.json() as any };
46
49
  };
47
50
 
51
+ /** A function access rule requires a valid token — used by the `open` namespace `all` rule. */
52
+ const authedJson = async (action: string, body: any) => {
53
+ const res = await fetch(`${url}/api/${TENANT}/vars/${action}`, {
54
+ method: "POST",
55
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
56
+ body: JSON.stringify(body),
57
+ });
58
+ return { status: res.status, body: await res.json() as any };
59
+ };
60
+
48
61
  describe("vars HTTP API — access control", () => {
49
62
  it("denies a namespace without api.access", async () => {
50
63
  const res = await json("get", { ns: "private", key: "secret" });
@@ -121,3 +134,62 @@ describe("vars HTTP API — CRUD", () => {
121
134
  expect(res.status).toBe(400);
122
135
  });
123
136
  });
137
+
138
+ describe("vars HTTP API — scope cannot be a Mongo operator (#34)", () => {
139
+ it("refuses an operator scope on every read/delete action", async () => {
140
+ // Seed a real scoped value in the unscoped namespace.
141
+ expect((await json("set", { ns: "open", key: "token", value: "alice-secret", scope: "alice" })).status).toBe(200);
142
+
143
+ for (const action of ["all", "entries", "clear"]) {
144
+ const res = await authedJson(action, { ns: "open", scope: { $ne: null } });
145
+ expect({ action, status: res.status }).toEqual({ action, status: 400 });
146
+ expect(res.body.code).toBe("VALIDATION_ERROR");
147
+ }
148
+
149
+ // Nothing was deleted and the scoped value is intact.
150
+ const got = await json("get", { ns: "open", key: "token", scope: "alice" });
151
+ expect(got.body.value).toBe("alice-secret");
152
+ });
153
+
154
+ it("refuses an operator scope on writes too", async () => {
155
+ for (const action of ["set", "del"]) {
156
+ const res = await json(action, { ns: "open", key: "token", value: "x", scope: { $ne: null } });
157
+ expect({ action, status: res.status }).toEqual({ action, status: 400 });
158
+ }
159
+ });
160
+
161
+ it("still accepts a string scope on an unscoped namespace", async () => {
162
+ const set = await json("set", { ns: "open", key: "token", value: "bob-secret", scope: "bob" });
163
+ expect(set.status).toBe(200);
164
+ const all = await authedJson("all", { ns: "open", scope: "bob" });
165
+ expect(all.status).toBe(200);
166
+ expect(all.body.vars.token).toBe("bob-secret");
167
+ });
168
+
169
+ it("gives the access rule the namespace and the body", async () => {
170
+ // The `open` namespace rule only allows `all` when it receives `{ namespace, body }`.
171
+ const res = await authedJson("all", { ns: "open", scope: "alice" });
172
+ expect(res.status).toBe(200);
173
+ });
174
+
175
+ it("refuses operators in the `where` meta filter", async () => {
176
+ const res = await json("entries", { ns: "open", where: { note: { $ne: null } } });
177
+ expect(res.status).toBe(400);
178
+ expect(res.body.code).toBe("VALIDATION_ERROR");
179
+ });
180
+ });
181
+
182
+ describe("vars HTTP API — audit (#50)", () => {
183
+ it("never stores the values returned by a read", async () => {
184
+ await json("set", { ns: "config", key: "audit-secret", value: "super-secret-value" });
185
+ await json("get", { ns: "config", key: "audit-secret" });
186
+
187
+ const logged: any = await rest.db.collection("_audit_").findOne(
188
+ { "operation.action": "get", "operation.collection": "_vars_:config" },
189
+ { sort: { ts: -1 } },
190
+ );
191
+ expect(logged).not.toBeNull();
192
+ expect(logged.operation.result == null).toBe(true);
193
+ expect(JSON.stringify(logged)).not.toContain("super-secret-value");
194
+ });
195
+ });
@@ -48,7 +48,7 @@ describe("define.Vars", () => {
48
48
  describe("vars loading & seeding", () => {
49
49
  it("loads the definitions from vars/*.var.ts", () => {
50
50
  const namespaces = (cfg.vars ?? []).map((v) => v.namespace).sort();
51
- expect(namespaces).toEqual(["config", "private"]);
51
+ expect(namespaces).toEqual(["config", "open", "private"]);
52
52
  expect((cfg.vars ?? []).every((v) => v._tenant_ === TEST_TENANT)).toBe(true);
53
53
  });
54
54