@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,304 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
+ import { createApp } from "../server/hono";
3
+ import { formatConfig, cfg } from "../server/config";
4
+ import { syncTenants } from "../database/tenant";
5
+ import { useRest } from "../database/rest";
6
+
7
+ /**
8
+ * External-API guards (issues #32 and #33) — and their **external-only** scope:
9
+ * the in-process `rest` keeps the full surface (replacement documents, computed
10
+ * aggregation fields), while a client of `/api/...` cannot use them to bypass
11
+ * validation or to read a `privateFields` value under another name.
12
+ */
13
+
14
+ const TENANT = "api-guards";
15
+ const DB = "mongodb://localhost:27017/_API_GUARDS_TEST";
16
+
17
+ let rest: InstanceType<typeof useRest>;
18
+ let internal: InstanceType<typeof useRest>;
19
+ let server: any;
20
+ let url = "";
21
+ let personId = "";
22
+
23
+ async function call(collection: string, action: string, body?: any) {
24
+ const res = await fetch(`${url}/api/${TENANT}/collections/${collection}/${action}`, {
25
+ method: "POST",
26
+ headers: { "Content-Type": "application/json" },
27
+ body: body === undefined ? undefined : JSON.stringify(body),
28
+ });
29
+ return { status: res.status, body: (await res.json()) as any };
30
+ }
31
+
32
+ beforeAll(async () => {
33
+ formatConfig({
34
+ server: { port: 4000 },
35
+ tenants: [{ id: TENANT, dir: "src", database: { uri: DB } }],
36
+ });
37
+ await syncTenants();
38
+
39
+ (cfg as any).collections = [
40
+ {
41
+ _tenant_: TENANT,
42
+ slug: "people",
43
+ fields: [
44
+ { name: "name", type: "string", required: true },
45
+ { name: "password", type: "string" },
46
+ { name: "secret_token", type: "string" },
47
+ { name: "nickname", type: "string" },
48
+ { name: "role", type: "string" },
49
+ ],
50
+ api: { access: { "*": true }, privateFields: ["password", /^secret_/], readOnlyFields: ["role"] },
51
+ },
52
+ {
53
+ _tenant_: TENANT,
54
+ slug: "notes",
55
+ fields: [
56
+ { name: "title", type: "string" },
57
+ { name: "owner", type: "relationship", relation: { to: "people" } },
58
+ { name: "secretRef", type: "relationship", relation: { to: "secrets" } },
59
+ ],
60
+ api: { access: { "*": true } },
61
+ },
62
+ {
63
+ _tenant_: TENANT,
64
+ slug: "secrets",
65
+ fields: [{ name: "code", type: "string" }],
66
+ api: { access: { find: false } },
67
+ },
68
+ ];
69
+
70
+ rest = new useRest({ internal: false, tenant_id: TENANT });
71
+ internal = new useRest({ internal: true, tenant_id: TENANT });
72
+
73
+ const app = createApp();
74
+ server = Bun.serve({ port: 0, fetch: app.fetch });
75
+ url = server.url.href.replace(/\/$/, "");
76
+
77
+ for (const name of ["people", "notes", "secrets", "_locks_", "_audit_"]) {
78
+ try { await rest.db.collection(name).drop(); } catch {}
79
+ }
80
+ const person: any = await rest.insertOne("people", {
81
+ name: "Ada",
82
+ password: "hash-ada",
83
+ secret_token: "tok-ada",
84
+ });
85
+ personId = person._id;
86
+ const secret: any = await rest.insertOne("secrets", { code: "s3cret" });
87
+ await rest.insertOne("notes", { title: "n1", owner: personId, secretRef: secret._id });
88
+ });
89
+
90
+ afterAll(async () => {
91
+ for (const name of ["people", "notes", "_locks_", "_audit_"]) {
92
+ try { await rest.db.collection(name).drop(); } catch {}
93
+ }
94
+ try { server.stop(true); } catch {}
95
+ });
96
+
97
+ describe("external mutations — operator documents only (#32)", () => {
98
+ it("refuses an empty update, a replacement document and an array", async () => {
99
+ const cases = [{ update: {} }, { update: { name: "Replaced" } }, { update: [] }];
100
+ for (const body of cases) {
101
+ const res = await call("people", "updateOne", { id: personId, ...body });
102
+ expect({ body, status: res.status, code: res.body.code })
103
+ .toEqual({ body, status: 400, code: "INVALID_UPDATE" });
104
+ }
105
+ });
106
+
107
+ it("still accepts an operator update", async () => {
108
+ const res = await call("people", "updateOne", { id: personId, update: { $set: { name: "Ada 2" } } });
109
+ expect(res.status).toBe(200);
110
+ expect((await rest.findOne("people", personId))?.name).toBe("Ada 2");
111
+ });
112
+
113
+ it("applies the same rule to updateMany", async () => {
114
+ const bad = await call("people", "updateMany", { ids: [personId], update: { name: "x" } });
115
+ expect(bad.status).toBe(400);
116
+ expect(bad.body.code).toBe("INVALID_UPDATE");
117
+ });
118
+
119
+ it("refuses `$rename` and unsetting a required field", async () => {
120
+ const rename = await call("people", "updateOne", { id: personId, update: { $rename: { nickname: "password" } } });
121
+ expect(rename.status).toBe(400);
122
+ expect(rename.body.code).toBe("INVALID_UPDATE");
123
+
124
+ const unset = await call("people", "updateOne", { id: personId, update: { $unset: { name: "" } } });
125
+ expect(unset.status).toBe(400);
126
+ expect(unset.body.code).toBe("INVALID_UPDATE");
127
+ });
128
+
129
+ it("leaves the in-process `rest` unchanged (internal callers keep `$rename` etc.)", async () => {
130
+ const doc: any = await rest.insertOne("people", { name: "Bob", password: "hash-bob", nickname: "bobby" });
131
+ await internal.updateOne("people", doc._id, { $rename: { nickname: "name" } } as any);
132
+ expect((await rest.findOne("people", doc._id))?.name).toBe("bobby");
133
+ });
134
+ });
135
+
136
+ describe("external reads — private fields cannot be aliased (#33)", () => {
137
+ it("refuses a reference to a private field in an expression", async () => {
138
+ const pipelines = [
139
+ [{ $addFields: { leak: "$password" } }],
140
+ [{ $project: { leak: { $concat: ["$password"] } } }],
141
+ [{ $group: { _id: "$password" } }],
142
+ [{ $replaceRoot: { newRoot: { leak: "$password" } } }],
143
+ [{ $addFields: { leak: "$$ROOT.password" } }],
144
+ ];
145
+ for (const pipeline of pipelines) {
146
+ const res = await call("people", "aggregate", { pipeline });
147
+ expect({ pipeline, status: res.status, code: res.body.code })
148
+ .toEqual({ pipeline, status: 400, code: "PRIVATE_FIELD_REFERENCE" });
149
+ }
150
+ });
151
+
152
+ it("refuses operators that move a field without a `$path`", async () => {
153
+ const res = await call("people", "aggregate", { pipeline: [{ $addFields: { leak: { $getField: "password" } } }] });
154
+ expect(res.status).toBe(400);
155
+ expect(res.body.code).toBe("PRIVATE_FIELD_REFERENCE");
156
+ });
157
+
158
+ it("refuses a joined private field under an alias", async () => {
159
+ const res = await call("notes", "aggregate", {
160
+ pipeline: [
161
+ { $lookup: { from: "people", as: "p" } },
162
+ { $addFields: { leak: "$p.secret_token" } },
163
+ ],
164
+ });
165
+ expect(res.status).toBe(400);
166
+ expect(res.body.code).toBe("PRIVATE_FIELD_REFERENCE");
167
+ });
168
+
169
+ it("still allows an aggregate that does not touch a private field", async () => {
170
+ const res = await call("people", "aggregate", {
171
+ pipeline: [{ $group: { _id: "$name", n: { $sum: 1 } } }],
172
+ });
173
+ expect(res.status).toBe(200);
174
+ expect(Array.isArray(res.body)).toBe(true);
175
+ });
176
+
177
+ it("still masks a directly-projected private field", async () => {
178
+ const res = await call("people", "find", { params: { $project: { name: 1, password: 1, secret_token: 1 } } });
179
+ expect(res.status).toBe(200);
180
+ for (const doc of res.body) {
181
+ expect(doc.password).toBeUndefined();
182
+ expect(doc.secret_token).toBeUndefined();
183
+ }
184
+ });
185
+
186
+ it("leaves the in-process `rest` unchanged (internal aggregates may compute)", async () => {
187
+ const docs: any[] = await internal.aggregate("people", [
188
+ { $match: { name: { $in: ["Ada 2", "Ada", "Bobby"] } } },
189
+ { $addFields: { leak: "$password" } },
190
+ ]);
191
+ expect(docs.length).toBeGreaterThan(0);
192
+ expect(docs[0].leak).toBeString();
193
+ });
194
+ });
195
+
196
+ describe("external reads — joins honour the target access rule (#36)", () => {
197
+ it("refuses a lookup on a collection the caller cannot read", async () => {
198
+ const res = await call("notes", "aggregate", {
199
+ pipeline: [{ $lookup: { from: "secrets", as: "s" } }],
200
+ });
201
+ expect(res.status).toBe(401);
202
+ expect(res.body.code).toBe("ACCESS_DENIED");
203
+ });
204
+
205
+ it("refuses a lookup on an internal collection", async () => {
206
+ const res = await call("notes", "aggregate", {
207
+ pipeline: [{ $lookup: { from: "_audit_", as: "a" } }],
208
+ });
209
+ expect(res.status).toBe(403);
210
+ expect(res.body.code).toBe("LOOKUP_TARGET_FORBIDDEN");
211
+ });
212
+
213
+ it("refuses an $include on a related collection the caller cannot read", async () => {
214
+ const res = await call("notes", "find", { params: { $include: ["secretRef"] } });
215
+ expect(res.status).toBe(401);
216
+ expect(res.body.code).toBe("ACCESS_DENIED");
217
+ });
218
+
219
+ it("still allows a join on a readable collection", async () => {
220
+ const res = await call("notes", "aggregate", {
221
+ pipeline: [{ $lookup: { from: "people", localField: "owner", foreignField: "_id", as: "p" } }],
222
+ });
223
+ expect(res.status).toBe(200);
224
+ });
225
+
226
+ it("leaves the in-process `rest` unchanged (internal lookups still work)", async () => {
227
+ const docs: any[] = await internal.aggregate("notes", [
228
+ { $lookup: { from: "secrets", localField: "secretRef", foreignField: "_id", as: "s" } },
229
+ ]);
230
+ expect(docs.length).toBeGreaterThan(0);
231
+ expect(Array.isArray(docs[0].s)).toBe(true);
232
+ });
233
+ });
234
+
235
+ describe("external API — body shape and generic errors (#21, #24, #47)", () => {
236
+ it("returns a generic not-found message (no tenant/collection echo)", async () => {
237
+ const res = await call("does-not-exist", "find", { params: {} });
238
+ expect(res.status).toBe(400);
239
+ expect(res.body.code).toBe("COLLECTION_NOT_FOUND");
240
+ expect(res.body.message).toBe("Resource not found");
241
+ expect(res.body.message).not.toContain("does-not-exist");
242
+ });
243
+
244
+ it("refuses malformed insert/update/delete bodies", async () => {
245
+ const cases: Array<[string, any, string]> = [
246
+ ["insertOne", {}, "INVALID_BODY"],
247
+ ["insertOne", { data: "not-an-object" }, "INVALID_BODY"],
248
+ ["insertMany", { data: { name: "x" } }, "INVALID_BODY"],
249
+ ["findOne", { id: { $ne: null } }, "INVALID_ID"],
250
+ ["updateOne", { id: { $ne: null }, update: { $set: { name: "x" } } }, "INVALID_ID"],
251
+ ["deleteOne", { id: { $ne: null } }, "INVALID_ID"],
252
+ ["updateMany", { ids: "not-an-array", update: { $set: { name: "x" } } }, "INVALID_BODY"],
253
+ ["deleteMany", { ids: [{}] }, "INVALID_BODY"],
254
+ ];
255
+
256
+ for (const [action, body, code] of cases) {
257
+ const res = await call("people", action, body);
258
+ expect({ action, status: res.status, code: res.body.code })
259
+ .toEqual({ action, status: 400, code });
260
+ }
261
+ });
262
+
263
+ it("still accepts a well-formed insert", async () => {
264
+ const res = await call("people", "insertOne", { data: { name: "Cara" } });
265
+ expect(res.status).toBe(200);
266
+ });
267
+ });
268
+
269
+ describe("in-process `rest` — `$rename` cannot target a read-only field (#43)", () => {
270
+ it("refuses a rename to a read-only field, allows a normal one", async () => {
271
+ const doc: any = await rest.insertOne("people", { name: "Ren", nickname: "ren" });
272
+
273
+ await expect(internal.updateOne("people", doc._id, { $rename: { nickname: "role" } } as any))
274
+ .rejects.toMatchObject({ code: "READONLY_FIELD" });
275
+
276
+ await internal.updateOne("people", doc._id, { $rename: { nickname: "name" } } as any);
277
+ expect((await rest.findOne("people", doc._id))?.name).toBe("ren");
278
+ });
279
+ });
280
+
281
+ describe("collection definitions — a definition that disappears mid-call", () => {
282
+ it("fails with COLLECTION_NOT_FOUND instead of a TypeError on `col.hooks`", async () => {
283
+ const saved = (cfg as any).collections;
284
+ let reads = 0;
285
+ Object.defineProperty(cfg, "collections", {
286
+ configurable: true,
287
+ get() {
288
+ reads += 1;
289
+ return reads === 1 ? saved : [];
290
+ },
291
+ });
292
+
293
+ try {
294
+ const code = await internal.find("people").then(
295
+ () => "resolved",
296
+ (error: any) => error?.code,
297
+ );
298
+ // One read for the guard, one for the method's own (checked) resolution.
299
+ expect({ reads, code }).toEqual({ reads: 2, code: "COLLECTION_NOT_FOUND" });
300
+ } finally {
301
+ Object.defineProperty(cfg, "collections", { configurable: true, writable: true, value: saved });
302
+ }
303
+ });
304
+ });
@@ -1,5 +1,8 @@
1
1
  import { describe, it, expect } from "bun:test";
2
2
  import { deflateRawSync } from "node:zlib";
3
+ import { mkdtemp, rm, symlink } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
3
6
  import { openZip } from "../lib/zip";
4
7
  import { docxText, resolveAttachments, xlsxText, MAX_FILE_BYTES } from "../lib/attachment";
5
8
  import { openaiMessages, anthropicMessages } from "../lib/providers";
@@ -239,3 +242,63 @@ describe("attachments through a provider", () => {
239
242
  });
240
243
  });
241
244
  });
245
+
246
+ describe("resolveAttachments — path containment (issue #29)", () => {
247
+ const base = "packages/core/tests/fixtures/attachment";
248
+
249
+ it("still reads a path inside the base directory", async () => {
250
+ const parts = await resolveAttachments(["data.csv"], { baseDir: base });
251
+ expect(parts).toHaveLength(1);
252
+ expect((parts[0] as any).text).toContain("ref,total");
253
+ });
254
+
255
+ it("refuses a `..` path that escapes the base directory", async () => {
256
+ const error: any = await resolveAttachments(["../../../package.json"], { baseDir: base }).catch((err) => err);
257
+ expect(error.code).toBe("AGENT_FILE_INVALID");
258
+ expect(error.message).toContain("escapes the base directory");
259
+ });
260
+
261
+ it("refuses an absolute path outside the base directory", async () => {
262
+ const error: any = await resolveAttachments(["/etc/hosts"], { baseDir: base }).catch((err) => err);
263
+ expect(error.code).toBe("AGENT_FILE_INVALID");
264
+ });
265
+
266
+ it("refuses a symlink that points outside the base directory", async () => {
267
+ const dir = await mkdtemp(join(tmpdir(), "anteros-attach-"));
268
+ try {
269
+ await symlink("/etc/hosts", join(dir, "link.txt"));
270
+ const error: any = await resolveAttachments(["link.txt"], { baseDir: dir }).catch((err) => err);
271
+ expect(error.code).toBe("AGENT_FILE_INVALID");
272
+ } finally {
273
+ await rm(dir, { recursive: true, force: true });
274
+ }
275
+ });
276
+
277
+ it("refuses a directory", async () => {
278
+ const dir = await mkdtemp(join(tmpdir(), "anteros-attach-"));
279
+ try {
280
+ const error: any = await resolveAttachments(["."], { baseDir: dir }).catch((err) => err);
281
+ expect(error.code).toBe("AGENT_FILE_NOT_FOUND");
282
+ } finally {
283
+ await rm(dir, { recursive: true, force: true });
284
+ }
285
+ });
286
+ });
287
+
288
+ describe("zip — decompression bounds (#41)", () => {
289
+ it("refuses an entry whose declared inflated size exceeds the limit", () => {
290
+ const big = "a".repeat(10 * 1024 * 1024 + 1);
291
+ const bytes = makeZip({ "word/document.xml": big });
292
+ // The archive itself stays small (deflate), the entry expands past the cap.
293
+ expect(bytes.byteLength).toBeLessThan(10 * 1024 * 1024);
294
+
295
+ let error: any;
296
+ try {
297
+ openZip(bytes).read("word/document.xml");
298
+ } catch (err) {
299
+ error = err;
300
+ }
301
+ expect(error?.code).toBe("AGENT_FILE_INVALID");
302
+ expect(error?.message).toContain("expands beyond");
303
+ });
304
+ });
@@ -5,7 +5,7 @@ import { formatConfig, cfg } from "../server/config";
5
5
  import { syncTenants } from "../database/tenant";
6
6
  import { useRest } from "../database/rest";
7
7
  import { AUDIT_COLLECTION } from "../database/audit";
8
- import { REDACTED, redactPayload } from "../lib/redact";
8
+ import { REDACTED, redactPayload, redactText } from "../lib/redact";
9
9
 
10
10
  const TENANT = "redact-test";
11
11
  const DB = "mongodb://localhost:27017/_AUDIT_REDACT_TEST";
@@ -257,3 +257,28 @@ describe("audit redaction — configuration", () => {
257
257
  expect(logged.operation.input.data.password).toBe(REDACTED);
258
258
  });
259
259
  });
260
+
261
+ describe("audit — error and token redaction (#40)", () => {
262
+ it("masks secrets embedded in a free-text error message", async () => {
263
+ await rest.audit.log({
264
+ action: "custom-redact",
265
+ collection: SLUG,
266
+ error: { message: "login failed password=supersecret token: abc123", code: "LOGIN_FAILED" },
267
+ });
268
+
269
+ const logged: any = await rest.db.collection(AUDIT_COLLECTION).findOne(
270
+ { "operation.action": "custom-redact" },
271
+ { sort: { ts: -1 } },
272
+ );
273
+ expect(logged).not.toBeNull();
274
+ expect(logged.operation.error.message).not.toContain("supersecret");
275
+ expect(logged.operation.error.message).not.toContain("abc123");
276
+ expect(logged.operation.error.message).toContain(REDACTED);
277
+ });
278
+
279
+ it("redacts the whole `token` object (its key is sensitive)", () => {
280
+ expect(redactPayload({ token: { decoded: { secret: "x", sub: "u1" } } }))
281
+ .toEqual({ token: REDACTED });
282
+ expect(redactText("ok")).toBe("ok");
283
+ });
284
+ });
@@ -5,6 +5,7 @@ import { formatConfig, cfg } from "../server/config";
5
5
  import { syncTenants } from "../database/tenant";
6
6
  import { syncCollections } from "../database/collection";
7
7
  import { resolve } from "../utils/crypto";
8
+ import { decryptFields } from "../database/field.crypto";
8
9
 
9
10
  const TENANT = "enc-test";
10
11
  const DB = "mongodb://localhost:27017/_DB_ENC_TEST";
@@ -74,7 +75,7 @@ describe("field-level encryption", () => {
74
75
 
75
76
  // explicit MANUAL decryption (utils.crypt.resolve())
76
77
  const cipher = resolve();
77
- const dec = await cipher.decrypt<string>(one.ssn, true);
78
+ const dec = await cipher.decrypt<string>(one.ssn, true, "enc-test:patients:ssn");
78
79
  expect(dec).toBe("123-45-6789");
79
80
  });
80
81
 
@@ -147,7 +148,7 @@ describe("field-level encryption — encryptionOptions.path (json sub-paths)", (
147
148
 
148
149
  // manual decryption
149
150
  const cipher = resolve();
150
- const zip = await cipher.decrypt<string>(list[0].address.zip, true);
151
+ const zip = await cipher.decrypt<string>(list[0].address.zip, true, "enc-test:patients:address");
151
152
  expect(zip).toBe("75000");
152
153
  });
153
154
 
@@ -181,7 +182,7 @@ describe("field-level encryption — encryptionOptions.path (json sub-paths)", (
181
182
  expect(up2.address.zip).toMatch(/^[A-Za-z0-9+/=]+$/);
182
183
  const raw = await rest.db.collection("patients").findOne({ _id: new ObjectId(doc._id) });
183
184
  const cipher = resolve();
184
- expect(await cipher.decrypt<string>(raw!.address.zip, true)).toBe("06100");
185
+ expect(await cipher.decrypt<string>(raw!.address.zip, true, "enc-test:patients:address")).toBe("06100");
185
186
  });
186
187
 
187
188
  it("rejects $inc on an encrypted sub-path", async () => {
@@ -191,3 +192,46 @@ describe("field-level encryption — encryptionOptions.path (json sub-paths)", (
191
192
  ).rejects.toThrow(/ENCRYPTED_FIELD_OPERATOR|not supported/);
192
193
  });
193
194
  });
195
+
196
+ describe("field-level encryption — AAD and path hardening (#44)", () => {
197
+ it("binds ciphertext to tenant:collection:field", async () => {
198
+ const doc: any = await rest.insertOne("patients", { name: "AAD", ssn: "555-55-5555" });
199
+ const raw = await rest.db.collection("patients").findOne({ _id: new ObjectId(doc._id) });
200
+ const cipher = resolve();
201
+
202
+ expect(await cipher.decrypt<string>(raw!.ssn, true, "enc-test:patients:ssn")).toBe("555-55-5555");
203
+ await expect(cipher.decrypt(raw!.ssn, true, "other-tenant:patients:ssn")).rejects.toThrow(/Decryption failed/);
204
+ await expect(cipher.decrypt(raw!.ssn, true, "enc-test:other:ssn")).rejects.toThrow(/Decryption failed/);
205
+ });
206
+
207
+ it("still decrypts legacy ciphertext without AAD", async () => {
208
+ const cipher = resolve();
209
+ const legacy = await cipher.encrypt(JSON.stringify("legacy-value")); // no AAD (pre-#44)
210
+ const out = await decryptFields({ _tenant_: TENANT, slug: "patients", fields: [{ name: "ssn", encryption: true }] } as any, { ssn: legacy });
211
+ expect(out.ssn).toBe("legacy-value");
212
+ });
213
+
214
+ it("decryptFields decrypts whole fields and configured sub-paths", async () => {
215
+ const doc: any = await rest.insertOne("patients", {
216
+ name: "Dec",
217
+ ssn: "777-77-7777",
218
+ profile: { city: "Paris" },
219
+ address: { zip: "75001", city: "Paris" },
220
+ });
221
+ const raw = await rest.db.collection("patients").findOne({ _id: new ObjectId(doc._id) });
222
+ const col = (cfg as any).collections[0];
223
+ const out = await decryptFields(col, raw);
224
+
225
+ expect(out.ssn).toBe("777-77-7777");
226
+ expect(out.profile).toEqual({ city: "Paris" });
227
+ expect(out.address.zip).toBe("75001");
228
+ expect(out.address.city).toBe("Paris");
229
+ });
230
+
231
+ it("refuses a sub-path write inside an `encryption: true` field", async () => {
232
+ const doc: any = await rest.insertOne("patients", { name: "Sub", ssn: "000-00-0000" });
233
+ await expect(
234
+ rest.updateOne("patients", doc._id, { $set: { "ssn.x": "plain" } as any }),
235
+ ).rejects.toThrow(/ENCRYPTED_FIELD_OPERATOR|sub-path/);
236
+ });
237
+ });
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
+ import { useRest } from "../database/rest";
3
+ import { formatConfig, cfg } from "../server/config";
4
+ import { syncTenants } from "../database/tenant";
5
+ import { syncCollections } from "../database/collection";
6
+
7
+ /**
8
+ * #45 — filters/pipelines that reach MongoDB through the internal methods are
9
+ * sanitized like `find`: no `$where`/`$expr`/`$function`, bounded `$regex`/`$in`.
10
+ */
11
+
12
+ const TENANT = "filters-hardening";
13
+ const DB = "mongodb://localhost:27017/_FILTERS_HARDENING_TEST";
14
+
15
+ let rest: InstanceType<typeof useRest>;
16
+
17
+ beforeAll(async () => {
18
+ formatConfig({
19
+ server: { port: 4000 },
20
+ tenants: [{ id: TENANT, dir: "src", database: { uri: DB } }],
21
+ });
22
+ await syncTenants();
23
+ await syncCollections();
24
+ (cfg as any).collections = [{
25
+ _tenant_: TENANT,
26
+ slug: "items",
27
+ fields: [
28
+ { name: "title", type: "string" },
29
+ { name: "count", type: "number" },
30
+ ],
31
+ api: { access: { "*": true } },
32
+ }];
33
+ rest = new useRest({ internal: false, tenant_id: TENANT });
34
+ try { await rest.db.collection("items").drop(); } catch {}
35
+ });
36
+
37
+ afterAll(async () => {
38
+ try { await rest.db.collection("items").drop(); } catch {}
39
+ try { await rest.db.collection("_locks_").drop(); } catch {}
40
+ });
41
+
42
+ describe("internal methods — safe filters (#45)", () => {
43
+ it("refuses `$where` in findOneAndUpdate / bulkWrite / countDocuments", async () => {
44
+ const calls: Array<[string, () => Promise<any>]> = [
45
+ ["findOneAndUpdate", () => rest.findOneAndUpdate("items", { $where: "true" } as any, { $set: { count: 1 } })],
46
+ ["bulkWrite", () => rest.bulkWrite("items", [{ updateOne: { filter: { $where: "true" }, update: { $set: { count: 1 } } } } as any])],
47
+ ["countDocuments", () => rest.countDocuments("items", { $where: "true" } as any)],
48
+ ];
49
+ for (const [name, call] of calls) {
50
+ const error: any = await call().catch((err) => err);
51
+ expect({ name, code: error?.code }).toEqual({ name, code: "UNAUTHORIZED_MATCH_KEYS" });
52
+ }
53
+ });
54
+
55
+ it("still accepts a normal filter", async () => {
56
+ const doc: any = await rest.insertOne("items", { title: "ok", count: 1 });
57
+ const updated: any = await rest.findOneAndUpdate("items", { _id: { $eq: doc._id } } as any, { $set: { count: 2 } });
58
+ expect(updated?.count).toBe(2);
59
+ });
60
+ });
61
+
62
+ describe("aggregate — bounded operators (#45)", () => {
63
+ it("refuses an over-long `$regex`", async () => {
64
+ const error: any = await rest.aggregate("items", [
65
+ { $match: { title: { $regex: "a".repeat(1001) } } },
66
+ ]).catch((err) => err);
67
+ expect(error?.code).toBe("REGEX_TOO_LONG");
68
+ });
69
+
70
+ it("refuses an oversized `$in`", async () => {
71
+ const error: any = await rest.aggregate("items", [
72
+ { $match: { title: { $in: Array.from({ length: 10001 }, (_, index) => `v${index}`) } } },
73
+ ]).catch((err) => err);
74
+ expect(error?.code).toBe("ARRAY_OPERATOR_TOO_LARGE");
75
+ });
76
+
77
+ it("still accepts a bounded pipeline", async () => {
78
+ const docs = await rest.aggregate("items", [{ $match: { title: { $in: ["ok"] } } }]);
79
+ expect(Array.isArray(docs)).toBe(true);
80
+ });
81
+ });
@@ -0,0 +1,9 @@
1
+ import { define } from "../../../../../../index";
2
+
3
+ /** Its own sub-directory — the sibling tool is resolved there. */
4
+ export default define.Agent({
5
+ id: "billing",
6
+ description: "Handles refunds.",
7
+ instructions: "You handle billing.",
8
+ provider: { model: "test-model", apiKey: "fixture-key", baseUrl: "http://127.0.0.1:1", options: { retries: 0 } },
9
+ });
@@ -0,0 +1,9 @@
1
+ import { define, v } from "../../../../../../index";
2
+
3
+ /** Loaded for `billing.agent.ts`, in the same sub-directory. */
4
+ export default define.Tool({
5
+ id: "refund",
6
+ description: "Refunds an order",
7
+ inputSchema: v.object({ order: v.string().required() }),
8
+ execute: async ({ order }: any) => ({ order, status: "refunded" }),
9
+ });
@@ -0,0 +1,9 @@
1
+ import { define } from "../../../../../index";
2
+
3
+ /** Agent of the `ai/` fixture — its tools come from the sibling `.tool.ts`. */
4
+ export default define.Agent({
5
+ id: "support",
6
+ description: "Answers support questions, with a co-located tool.",
7
+ instructions: "You are a support agent.",
8
+ provider: { model: "test-model", apiKey: "fixture-key", baseUrl: "http://127.0.0.1:1", options: { retries: 0 } },
9
+ });
@@ -0,0 +1,9 @@
1
+ import { define, v } from "../../../../../index";
2
+
3
+ /** Loaded automatically for `support.agent.ts` (same directory, same basename). */
4
+ export default define.Tool({
5
+ id: "greet",
6
+ description: "Greets the caller",
7
+ inputSchema: v.object({ name: v.string().required() }),
8
+ execute: async ({ name }: any) => ({ hello: name }),
9
+ });
@@ -0,0 +1,9 @@
1
+ import { define } from "../../../../../../index";
2
+
3
+ /** MCP resource exposed by URI. */
4
+ export default define.McpResource({
5
+ name: "readme",
6
+ description: "The tenant readme",
7
+ uri: "readme://tenant",
8
+ read: async () => ({ contents: [{ uri: "readme://tenant", text: "hello" }] }),
9
+ });
@@ -0,0 +1,9 @@
1
+ import { define, v } from "../../../../../../index";
2
+
3
+ /** MCP tool served by the tenant's MCP endpoint. */
4
+ export default define.McpTool({
5
+ name: "echo",
6
+ description: "Echoes the given text",
7
+ inputSchema: v.object({ text: v.string().required() }),
8
+ exec: async ({ args }) => ({ content: [{ type: "text", text: String(args.text) }] }),
9
+ });