@anteros/core 0.0.41 → 0.0.44

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 (133) hide show
  1. package/database/audit.ts +9 -59
  2. package/database/collection.ts +3 -1
  3. package/database/deleteLog.ts +67 -3
  4. package/database/file.ts +4 -3
  5. package/database/mongodbadapter.ts +103 -42
  6. package/database/replication.ts +724 -58
  7. package/database/ttl.ts +68 -0
  8. package/database/workflow.ts +466 -240
  9. package/index.ts +6 -0
  10. package/lib/agent.ts +1567 -0
  11. package/lib/agentMemory.ts +689 -0
  12. package/lib/agents.ts +180 -0
  13. package/lib/ai.ts +0 -0
  14. package/lib/api.ts +246 -0
  15. package/lib/attachment.ts +351 -0
  16. package/lib/auditResult.ts +13 -0
  17. package/lib/define.ts +43 -1
  18. package/lib/endpoints.ts +80 -0
  19. package/lib/files.ts +2 -1
  20. package/lib/jsonSchema.ts +183 -0
  21. package/lib/lifecycle.ts +7 -1
  22. package/lib/load.ts +17 -0
  23. package/lib/mcp.ts +3 -2
  24. package/lib/mcpClient.ts +128 -0
  25. package/lib/messages.ts +299 -0
  26. package/lib/providers.ts +970 -0
  27. package/lib/routes.ts +14 -1
  28. package/lib/scriptWorker.ts +4 -1
  29. package/lib/scripts.ts +6 -1
  30. package/lib/skills.ts +481 -0
  31. package/lib/sockets.ts +3 -1
  32. package/lib/workflow.ts +209 -37
  33. package/lib/zip.ts +109 -0
  34. package/package.json +1 -1
  35. package/server/access.ts +96 -0
  36. package/server/agents.ts +472 -0
  37. package/server/api.ts +80 -65
  38. package/server/boot.ts +11 -0
  39. package/server/hono.ts +8 -3
  40. package/server/mcp.ts +17 -63
  41. package/server/routes.ts +19 -24
  42. package/tests/agent-mcp.test.ts +116 -0
  43. package/tests/agent-memory.test.ts +617 -0
  44. package/tests/agent-provider.test.ts +288 -0
  45. package/tests/agent-runtime.test.ts +369 -0
  46. package/tests/agent-skills.test.ts +177 -0
  47. package/tests/agent-summarize.test.ts +124 -0
  48. package/tests/agent-working-memory.test.ts +404 -0
  49. package/tests/agent.test.ts +1241 -0
  50. package/tests/agents-api.test.ts +593 -0
  51. package/tests/api-private-fields.test.ts +196 -0
  52. package/tests/api.test.ts +51 -0
  53. package/tests/attachment.test.ts +241 -0
  54. package/tests/audit-operations.test.ts +1 -1
  55. package/tests/audit-redact.test.ts +29 -3
  56. package/tests/audit-run-action.test.ts +1 -1
  57. package/tests/endpoints.test.ts +80 -0
  58. package/tests/file-collection-replication.test.ts +2 -0
  59. package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
  60. package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
  61. package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
  62. package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
  63. package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
  64. package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
  65. package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
  66. package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
  67. package/tests/fixtures/attachment/data.csv +2 -0
  68. package/tests/fixtures/fake-provider.ts +128 -0
  69. package/tests/fixtures/repl-only/app.ts +3 -0
  70. package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
  71. package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
  72. package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
  73. package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
  74. package/tests/fixtures/skills-tenant/agents/docs.agent.ts +23 -0
  75. package/tests/fixtures/skills-tenant/agents/incomplete.agent.ts +14 -0
  76. package/tests/fixtures/skills-tenant/broken/bad-name.md +6 -0
  77. package/tests/fixtures/skills-tenant/broken/dup-a.md +6 -0
  78. package/tests/fixtures/skills-tenant/broken/dup-b.md +6 -0
  79. package/tests/fixtures/skills-tenant/broken/empty-body.md +4 -0
  80. package/tests/fixtures/skills-tenant/broken/long-compat.md +7 -0
  81. package/tests/fixtures/skills-tenant/broken/long-description.md +6 -0
  82. package/tests/fixtures/skills-tenant/broken/mismatch/SKILL.md +6 -0
  83. package/tests/fixtures/skills-tenant/broken/no-description.md +5 -0
  84. package/tests/fixtures/skills-tenant/broken/no-name.md +5 -0
  85. package/tests/fixtures/skills-tenant/broken/plain.md +4 -0
  86. package/tests/fixtures/skills-tenant/broken/unknown-key.md +9 -0
  87. package/tests/fixtures/skills-tenant/docs/pdf-forms.md +18 -0
  88. package/tests/fixtures/skills-tenant/guide/ml.md +9 -0
  89. package/tests/fixtures/skills-tenant/guides/pdf/SKILL.md +9 -0
  90. package/tests/fixtures/skills-tenant/runbooks/incident.md +9 -0
  91. package/tests/fixtures/skills-tenant/runbooks/notes.txt +2 -0
  92. package/tests/fixtures/vars-tenant/vars/private.var.ts +4 -1
  93. package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
  94. package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
  95. package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
  96. package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
  97. package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
  98. package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
  99. package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
  100. package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
  101. package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
  102. package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
  103. package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
  104. package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
  105. package/tests/hono.test.ts +6 -3
  106. package/tests/locks.test.ts +120 -0
  107. package/tests/mcp.test.ts +11 -11
  108. package/tests/messages.test.ts +258 -0
  109. package/tests/replication-change-stream.test.ts +240 -0
  110. package/tests/replication-control.test.ts +4 -2
  111. package/tests/replication-initial-sync.test.ts +2 -0
  112. package/tests/replication-meta.test.ts +246 -0
  113. package/tests/replication.test.ts +2 -0
  114. package/tests/upload.test.ts +44 -12
  115. package/tests/vars-api.test.ts +1 -1
  116. package/tests/vars-replication.test.ts +5 -2
  117. package/tests/workflow-engine.test.ts +340 -0
  118. package/tests/workflow-retention.test.ts +103 -0
  119. package/tests/workflow.test.ts +186 -0
  120. package/types/agent.d.ts +1067 -0
  121. package/types/config.d.ts +16 -0
  122. package/types/file.d.ts +7 -7
  123. package/types/hook.d.ts +6 -0
  124. package/types/lifecycle.d.ts +6 -0
  125. package/types/mcp.d.ts +11 -1
  126. package/types/replication.d.ts +70 -4
  127. package/types/route.d.ts +7 -1
  128. package/types/scripts.d.ts +6 -0
  129. package/types/service.d.ts +7 -1
  130. package/types/tenant.d.ts +13 -0
  131. package/types/vars.d.ts +2 -2
  132. package/types/websocket.d.ts +6 -0
  133. package/types/workflow.d.ts +50 -3
@@ -0,0 +1,196 @@
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
+ * `api.privateFields` on a **collection reached through a join**.
9
+ *
10
+ * `func.omit` walks the whole response, but only with the matchers of the
11
+ * collection that was queried: an `$include`d document carries the *included*
12
+ * collection's fields, so that collection's `privateFields` must be applied too
13
+ * — at the path the join put it, and nowhere else. Same story for a `$lookup`
14
+ * inside an `aggregate` pipeline.
15
+ */
16
+
17
+ const TENANT = "private-fields";
18
+ const DB = "mongodb://localhost:27017/_API_PRIVATE_FIELDS_TEST";
19
+
20
+ let url = "";
21
+ let server: any;
22
+ let rest: InstanceType<typeof useRest>;
23
+ let internal: InstanceType<typeof useRest>;
24
+
25
+ async function call(collection: string, action: string, body?: any) {
26
+ const res = await fetch(`${url}/api/${TENANT}/collections/${collection}/${action}`, {
27
+ method: "POST",
28
+ headers: { "Content-Type": "application/json" },
29
+ body: body === undefined ? undefined : JSON.stringify(body),
30
+ });
31
+ return { status: res.status, body: (await res.json()) as any };
32
+ }
33
+
34
+ beforeAll(async () => {
35
+ formatConfig({
36
+ server: { port: 4000 },
37
+ tenants: [{ id: TENANT, dir: "src", database: { uri: DB } }],
38
+ });
39
+ await syncTenants();
40
+
41
+ (cfg as any).collections = [
42
+ {
43
+ _tenant_: TENANT,
44
+ slug: "authors",
45
+ fields: [
46
+ { name: "name", type: "string" },
47
+ { name: "password", type: "string" },
48
+ { name: "secret_token", type: "string" },
49
+ ],
50
+ api: { access: { "*": true }, privateFields: ["password", /^secret_/] },
51
+ },
52
+ {
53
+ _tenant_: TENANT,
54
+ slug: "posts",
55
+ fields: [
56
+ { name: "title", type: "string" },
57
+ // A public field that happens to match the *included* collection's
58
+ // matcher — it must survive: only the joined document is masked.
59
+ { name: "secret_note", type: "string" },
60
+ { name: "author", type: "relationship", relation: { to: "authors" } },
61
+ { name: "reviewers", type: "relationship", relation: { to: "authors", hasMany: true } },
62
+ ],
63
+ // The collection being queried declares no private field of its own:
64
+ // only the included one does.
65
+ api: { access: { "*": true } },
66
+ },
67
+ ];
68
+
69
+ rest = new useRest({ internal: false, tenant_id: TENANT });
70
+ internal = new useRest({ internal: true, tenant_id: TENANT });
71
+
72
+ const app = createApp();
73
+ server = Bun.serve({ port: 0, fetch: app.fetch });
74
+ url = server.url.href.replace(/\/$/, "");
75
+ });
76
+
77
+ afterAll(async () => {
78
+ for (const name of ["authors", "posts", "_locks_", "_audit_"]) {
79
+ try { await rest.db.collection(name).drop(); } catch (_) { /* first run */ }
80
+ }
81
+ server?.stop(true);
82
+ });
83
+
84
+ async function seed() {
85
+ await rest.db.collection("authors").deleteMany({});
86
+ await rest.db.collection("posts").deleteMany({});
87
+ const author = await rest.insertOne("authors", {
88
+ name: "Ada",
89
+ password: "s3cret-hash",
90
+ secret_token: "tok_live_123",
91
+ });
92
+ const post = await rest.insertOne("posts", {
93
+ title: "Hello",
94
+ secret_note: "public-note",
95
+ author: author._id,
96
+ reviewers: [author._id],
97
+ });
98
+ return { author, post };
99
+ }
100
+
101
+ describe("api.privateFields across a join", () => {
102
+ it("masks the included collection's private fields on `find`", async () => {
103
+ const { post } = await seed();
104
+
105
+ const res = await call("posts", "find", { params: { $match: { _id: post._id }, $include: ["author"] } });
106
+
107
+ expect(res.status).toBe(200);
108
+ const doc = res.body[0];
109
+ expect(doc.author.name).toBe("Ada");
110
+ expect(doc.author.password).toBeUndefined();
111
+ expect(doc.author.secret_token).toBeUndefined();
112
+ });
113
+
114
+ it("masks them on `findOne` too", async () => {
115
+ const { post } = await seed();
116
+
117
+ const res = await call("posts", "findOne", { id: post._id, params: { $include: ["author"] } });
118
+
119
+ expect(res.status).toBe(200);
120
+ expect(res.body.author.name).toBe("Ada");
121
+ expect(res.body.author.password).toBeUndefined();
122
+ expect(res.body.author.secret_token).toBeUndefined();
123
+ });
124
+
125
+ it("masks every element of a `hasMany` include", async () => {
126
+ const { post } = await seed();
127
+
128
+ const res = await call("posts", "find", { params: { $match: { _id: post._id }, $include: ["reviewers"] } });
129
+
130
+ expect(res.status).toBe(200);
131
+ const reviewers = res.body[0].reviewers;
132
+ expect(Array.isArray(reviewers)).toBe(true);
133
+ expect(reviewers[0].name).toBe("Ada");
134
+ expect(reviewers[0].password).toBeUndefined();
135
+ expect(reviewers[0].secret_token).toBeUndefined();
136
+ });
137
+
138
+ it("masks them on a `$lookup` of an `aggregate` pipeline", async () => {
139
+ const { post } = await seed();
140
+
141
+ const res = await call("posts", "aggregate", {
142
+ pipeline: [
143
+ { $match: { _id: post._id } },
144
+ { $lookup: { from: "authors", localField: "author", foreignField: "_id", as: "author" } },
145
+ { $unwind: "$author" },
146
+ ],
147
+ });
148
+
149
+ expect(res.status).toBe(200);
150
+ const doc = res.body[0];
151
+ expect(doc.author.name).toBe("Ada");
152
+ expect(doc.author.password).toBeUndefined();
153
+ expect(doc.author.secret_token).toBeUndefined();
154
+ });
155
+
156
+ it("still masks the queried collection's own private fields", async () => {
157
+ await seed();
158
+
159
+ const res = await call("authors", "find", {});
160
+
161
+ expect(res.status).toBe(200);
162
+ expect(res.body[0].name).toBe("Ada");
163
+ expect(res.body[0].password).toBeUndefined();
164
+ expect(res.body[0].secret_token).toBeUndefined();
165
+ });
166
+
167
+ it("masks the join only — the queried collection's own matching field survives", async () => {
168
+ const { post } = await seed();
169
+
170
+ const res = await call("posts", "find", { params: { $match: { _id: post._id }, $include: ["author"] } });
171
+
172
+ expect(res.status).toBe(200);
173
+ expect(res.body[0].secret_note).toBe("public-note");
174
+ });
175
+ });
176
+
177
+ describe("api.privateFields and the DB query cache", () => {
178
+ it("does not mutate the cached result (an internal read still sees every field)", async () => {
179
+ const { post } = await seed();
180
+ const params = { $match: { _id: post._id }, $include: ["author"] };
181
+
182
+ const viaHttp = await call("posts", "find", { params, options: { useCache: true } });
183
+ expect(viaHttp.body[0].author.password).toBeUndefined();
184
+
185
+ // Same query, same (warm) cache entry, but through the trusted internal API:
186
+ // the masked fields must still be there — masking is a response concern.
187
+ const viaRest: any = await internal.find("posts", params, { useCache: true });
188
+ expect(viaRest[0].author.password).toBe("s3cret-hash");
189
+ expect(viaRest[0].author.secret_token).toBe("tok_live_123");
190
+
191
+ // And the HTTP layer stays consistent on the second, cached response.
192
+ const again = await call("posts", "find", { params, options: { useCache: true } });
193
+ expect(again.body[0].author.name).toBe("Ada");
194
+ expect(again.body[0].author.password).toBeUndefined();
195
+ });
196
+ });
package/tests/api.test.ts CHANGED
@@ -537,3 +537,54 @@ describe("watch", () => {
537
537
  await stream.close();
538
538
  });
539
539
  });
540
+
541
+ /**
542
+ * `rest` is the **original, fully supported surface** — `api` (the facade) and the
543
+ * SDK namespaces only *group* it. Nothing is removed when something is added, so a
544
+ * tenant that wrote `rest.find('orders', …)` never has to migrate: this contract
545
+ * fails loudly if a method ever disappears.
546
+ */
547
+ describe("rest — the original surface (contract)", () => {
548
+ const COLLECTION_METHODS = [
549
+ "find", "findOne", "insertOne", "insertMany",
550
+ "updateOne", "findOneAndUpdate", "updateMany",
551
+ "deleteOne", "deleteMany", "bulkWrite", "bulkUpdate",
552
+ "aggregate", "countDocuments", "watch",
553
+ "dropCollection", "dropIndex", "dropIndexes",
554
+ "runAction",
555
+ ];
556
+
557
+ const FAMILY_METHODS = ["runService", "stats", "workflows", "lock", "unlock"];
558
+ const SESSION_METHODS = ["startSession", "endSession", "startTransaction", "commitTransaction", "abortTransaction"];
559
+ const GETTERS = ["db", "audit", "cache", "vars", "workflow"];
560
+
561
+ it("keeps every method and every member a tenant may already be using", () => {
562
+ for (const name of [...COLLECTION_METHODS, ...FAMILY_METHODS, ...SESSION_METHODS]) {
563
+ expect(typeof (rest as any)[name]).toBe("function");
564
+ }
565
+ for (const name of GETTERS) {
566
+ expect((rest as any)[name]).toBeDefined();
567
+ }
568
+ expect(rest.tenant_id).toBe(TEST_TENANT);
569
+ });
570
+
571
+ it("keeps the collection methods working exactly as before — `api` is additive", async () => {
572
+ const created = await rest.insertOne("items", { title: "legacy-surface", count: 1 });
573
+ const [found] = await rest.find("items", { $match: { title: "legacy-surface" } });
574
+ expect(found!.title).toBe("legacy-surface");
575
+
576
+ await rest.updateOne("items", created._id, { $set: { count: 2 } });
577
+ expect((await rest.findOne("items", created._id)).count).toBe(2);
578
+ expect(await rest.countDocuments("items", { title: "legacy-surface" })).toBe(1);
579
+ expect(await rest.aggregate("items", [{ $match: { title: "legacy-surface" } }, { $count: "n" }]))
580
+ .toEqual([{ n: 1 }]);
581
+
582
+ await rest.deleteOne("items", created._id);
583
+ expect(await rest.findOne("items", created._id)).toBeNull();
584
+ });
585
+
586
+ it("keeps `rest.vars` working, as the facade re-exposes the same object", async () => {
587
+ await rest.vars.set("legacy", "key", "value");
588
+ expect(await rest.vars.get<string>("legacy", "key")).toBe("value");
589
+ });
590
+ });
@@ -0,0 +1,241 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { deflateRawSync } from "node:zlib";
3
+ import { openZip } from "../lib/zip";
4
+ import { docxText, resolveAttachments, xlsxText, MAX_FILE_BYTES } from "../lib/attachment";
5
+ import { openaiMessages, anthropicMessages } from "../lib/providers";
6
+
7
+ /**
8
+ * Attachments — a file becomes text, an image or a document part, and the zip
9
+ * reader behind `.docx` / `.xlsx` is exercised against **real** archives built by
10
+ * `makeZip` (the same bytes Office writes: local headers + central directory).
11
+ */
12
+
13
+ /** Minimal ZIP writer (deflate or stored) — enough to build a .docx/.xlsx fixture. */
14
+ function makeZip(entries: Record<string, string>, options: { store?: boolean } = {}): Uint8Array {
15
+ const encoder = new TextEncoder();
16
+ const locals: Uint8Array[] = [];
17
+ const central: Uint8Array[] = [];
18
+ let offset = 0;
19
+
20
+ const crcTable = (() => {
21
+ const table = new Uint32Array(256);
22
+ for (let i = 0; i < 256; i++) {
23
+ let c = i;
24
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
25
+ table[i] = c >>> 0;
26
+ }
27
+ return table;
28
+ })();
29
+
30
+ const crc32 = (bytes: Uint8Array): number => {
31
+ let crc = 0xffffffff;
32
+ for (const byte of bytes) crc = crcTable[(crc ^ byte) & 0xff]! ^ (crc >>> 8);
33
+ return (crc ^ 0xffffffff) >>> 0;
34
+ };
35
+
36
+ for (const [name, content] of Object.entries(entries)) {
37
+ const raw = encoder.encode(content);
38
+ const method = options.store ? 0 : 8;
39
+ const data = options.store ? raw : new Uint8Array(deflateRawSync(raw));
40
+ const nameBytes = encoder.encode(name);
41
+ const crc = crc32(raw);
42
+
43
+ const local = new Uint8Array(30 + nameBytes.length + data.length);
44
+ const localView = new DataView(local.buffer);
45
+ localView.setUint32(0, 0x04034b50, true);
46
+ localView.setUint16(4, 20, true); // version
47
+ localView.setUint16(8, method, true);
48
+ localView.setUint32(14, crc, true);
49
+ localView.setUint32(18, data.length, true); // compressed
50
+ localView.setUint32(22, raw.length, true); // uncompressed
51
+ localView.setUint16(26, nameBytes.length, true);
52
+ local.set(nameBytes, 30);
53
+ local.set(data, 30 + nameBytes.length);
54
+ locals.push(local);
55
+
56
+ const entry = new Uint8Array(46 + nameBytes.length);
57
+ const entryView = new DataView(entry.buffer);
58
+ entryView.setUint32(0, 0x02014b50, true);
59
+ entryView.setUint16(4, 20, true); // version made by
60
+ entryView.setUint16(6, 20, true); // version needed
61
+ entryView.setUint16(10, method, true);
62
+ entryView.setUint32(16, crc, true);
63
+ entryView.setUint32(20, data.length, true);
64
+ entryView.setUint32(24, raw.length, true);
65
+ entryView.setUint16(28, nameBytes.length, true);
66
+ entryView.setUint32(42, offset, true); // local header offset
67
+ entry.set(nameBytes, 46);
68
+ central.push(entry);
69
+
70
+ offset += local.length;
71
+ }
72
+
73
+ const centralSize = central.reduce((total, entry) => total + entry.length, 0);
74
+ const end = new Uint8Array(22);
75
+ const endView = new DataView(end.buffer);
76
+ endView.setUint32(0, 0x06054b50, true);
77
+ endView.setUint16(8, central.length, true);
78
+ endView.setUint16(10, central.length, true);
79
+ endView.setUint32(12, centralSize, true);
80
+ endView.setUint32(16, offset, true);
81
+
82
+ const total = offset + centralSize + 22;
83
+ const zip = new Uint8Array(total);
84
+ let cursor = 0;
85
+ for (const chunk of [...locals, ...central, end]) {
86
+ zip.set(chunk, cursor);
87
+ cursor += chunk.length;
88
+ }
89
+ return zip;
90
+ }
91
+
92
+ const DOCX = makeZip({
93
+ "[Content_Types].xml": "<Types/>",
94
+ "word/document.xml":
95
+ '<w:body><w:p><w:r><w:t>Invoice INV-2026-001</w:t></w:r></w:p>'
96
+ + "<w:p><w:r><w:t>Total: 1 200 &amp; 50 EUR</w:t></w:r></w:p>"
97
+ + "<w:p><w:r><w:tab/><w:t>Paid</w:t></w:r></w:p></w:body>",
98
+ });
99
+
100
+ const XLSX = makeZip({
101
+ "xl/sharedStrings.xml":
102
+ "<sst><si><t>Product</t></si><si><r><t>Widget</t></r><r><t> A</t></r></si>"
103
+ + "<si><t>Invoice INV-2026-001</t></si></sst>",
104
+ "xl/worksheets/sheet1.xml":
105
+ "<worksheet><sheetData>"
106
+ + '<row r="1"><c r="A1" t="s"><v>0</v></c><c r="B1" t="s"><v>1</v></c></row>'
107
+ + '<row r="2"><c r="A2" t="s"><v>2</v></c><c r="B2"><v>99.5</v></c><c r="C2" t="inlineStr"><is><t>x,y</t></is></c></row>'
108
+ + "</sheetData></worksheet>",
109
+ });
110
+
111
+ const qr = await resolveAttachments({ name: "photo.png", mimeType: "image/png", data: Buffer.from("PNG!").toString("base64") });
112
+
113
+ describe("openZip", () => {
114
+ it("lists and reads the entries of a deflated archive", () => {
115
+ const zip = openZip(XLSX);
116
+ expect(zip.names().sort()).toEqual(["xl/sharedStrings.xml", "xl/worksheets/sheet1.xml"]);
117
+ expect(new TextDecoder().decode(zip.read("xl/sharedStrings.xml")!)).toContain("Invoice");
118
+ expect(zip.read("nope.xml")).toBeNull();
119
+ });
120
+
121
+ it("reads stored entries too, and refuses a non-archive", () => {
122
+ const zip = openZip(makeZip({ "a.txt": "hello" }, { store: true }));
123
+ expect(new TextDecoder().decode(zip.read("a.txt")!)).toBe("hello");
124
+
125
+ expect(() => openZip(new TextEncoder().encode("not a zip at all"))).toThrow(/central directory/);
126
+ });
127
+ });
128
+
129
+ describe("office extraction", () => {
130
+ it("extracts the text of a .docx, one line per paragraph", () => {
131
+ const text = docxText(openZip(DOCX));
132
+ expect(text).toContain("Invoice INV-2026-001");
133
+ expect(text).toContain("Total: 1 200 & 50 EUR"); // entities decoded
134
+ expect(text.split("\n").filter(Boolean)).toHaveLength(3);
135
+ });
136
+
137
+ it("extracts the rows of a .xlsx as CSV, shared strings resolved", () => {
138
+ const text = xlsxText(openZip(XLSX));
139
+ expect(text).toContain("### Sheet 1");
140
+ expect(text).toContain("Product,Widget A"); // concatenated runs
141
+ expect(text).toContain("Invoice INV-2026-001,99.5"); // shared string + number
142
+ expect(text).toContain('"x,y"'); // a value with a comma is quoted
143
+ });
144
+ });
145
+
146
+ describe("resolveAttachments", () => {
147
+ it("turns text formats into a text part naming the file", async () => {
148
+ const parts = await resolveAttachments(
149
+ { name: "data.csv", data: Buffer.from("a,b\n1,2").toString("base64") },
150
+ );
151
+ expect(parts).toHaveLength(1);
152
+ expect(parts[0]).toEqual({ type: "text", text: "--- data.csv ---\na,b\n1,2" });
153
+ });
154
+
155
+ it("accepts UTF-8 payloads without base64", async () => {
156
+ const parts = await resolveAttachments({ name: "notes.md", data: "# Hi", encoding: "utf8" });
157
+ expect((parts[0] as any).text).toBe("--- notes.md ---\n# Hi");
158
+ });
159
+
160
+ it("keeps an image as an image part", () => {
161
+ expect(qr[0]).toEqual({ type: "image", data: Buffer.from("PNG!").toString("base64"), mimeType: "image/png" });
162
+ });
163
+
164
+ it("keeps a PDF as a document part", async () => {
165
+ const parts = await resolveAttachments({ name: "invoice.pdf", data: Buffer.from("%PDF-1.7").toString("base64") });
166
+ expect(parts[0]).toEqual({
167
+ type: "file",
168
+ name: "invoice.pdf",
169
+ mimeType: "application/pdf",
170
+ data: Buffer.from("%PDF-1.7").toString("base64"),
171
+ });
172
+ });
173
+
174
+ it("extracts .docx and .xlsx to text", async () => {
175
+ const [docx, xlsx] = await resolveAttachments([
176
+ { name: "invoice.docx", data: Buffer.from(DOCX).toString("base64") },
177
+ { name: "sales.xlsx", data: Buffer.from(XLSX).toString("base64") },
178
+ ]);
179
+
180
+ expect((docx as any).text).toContain("--- invoice.docx ---");
181
+ expect((docx as any).text).toContain("Invoice INV-2026-001");
182
+ expect((xlsx as any).text).toContain("Product,Widget A");
183
+ });
184
+
185
+ it("reads a path on the server — relative to the base dir", async () => {
186
+ const parts = await resolveAttachments("./fixtures/attachment/data.csv", {
187
+ baseDir: "packages/core/tests",
188
+ });
189
+ expect((parts[0] as any).text).toBe("--- data.csv ---\nref,total\nA-1,99");
190
+ });
191
+
192
+ it("refuses what a model cannot read, and says why", async () => {
193
+ await expect(resolveAttachments(
194
+ { name: "legacy.doc", data: Buffer.from("D0CF11E0").toString("base64") },
195
+ )).rejects.toMatchObject({ code: "AGENT_FILE_UNSUPPORTED" });
196
+
197
+ await expect(resolveAttachments(
198
+ { name: "archive.tar.gz", data: Buffer.from("...").toString("base64") },
199
+ )).rejects.toThrow(/supported attachments/);
200
+
201
+ await expect(resolveAttachments("./fixtures/attachment/missing.pdf", {
202
+ baseDir: "packages/core/tests",
203
+ })).rejects.toMatchObject({ code: "AGENT_FILE_NOT_FOUND" });
204
+ });
205
+
206
+ it("refuses a file over the size limit", async () => {
207
+ const oversized = { name: "big.pdf", data: new Uint8Array(MAX_FILE_BYTES + 1) };
208
+ await expect(resolveAttachments(oversized)).rejects.toMatchObject({ code: "AGENT_FILE_TOO_LARGE" });
209
+ });
210
+ });
211
+
212
+ describe("attachments through a provider", () => {
213
+ it("maps an image and a PDF to both protocols", () => {
214
+ const messages = [{
215
+ role: "user" as const,
216
+ content: [
217
+ { type: "text" as const, text: "What is this?" },
218
+ { type: "image" as const, data: "AAAA", mimeType: "image/png" },
219
+ { type: "file" as const, name: "invoice.pdf", mimeType: "application/pdf", data: "BBBB" },
220
+ ],
221
+ }];
222
+
223
+ const openai = openaiMessages(undefined, messages as any)[0];
224
+ expect(openai.content[0]).toEqual({ type: "text", text: "What is this?" });
225
+ expect(openai.content[1].image_url.url).toBe("data:image/png;base64,AAAA");
226
+ expect(openai.content[2]).toEqual({
227
+ type: "file",
228
+ file: { filename: "invoice.pdf", file_data: "data:application/pdf;base64,BBBB" },
229
+ });
230
+
231
+ const anthropic = anthropicMessages(messages as any).messages[0];
232
+ expect(anthropic.content[1]).toEqual({
233
+ type: "image",
234
+ source: { type: "base64", media_type: "image/png", data: "AAAA" },
235
+ });
236
+ expect(anthropic.content[2]).toEqual({
237
+ type: "document",
238
+ source: { type: "base64", media_type: "application/pdf", data: "BBBB" },
239
+ });
240
+ });
241
+ });
@@ -145,7 +145,7 @@ describe("audit — services", () => {
145
145
  const before = await internalRest.db.collection(AUDIT_COLLECTION)
146
146
  .countDocuments({ "operation.action": "runService" });
147
147
 
148
- const res = await fetch(`${url}/services/${TENANT}/${SERVICE}/charge`, {
148
+ const res = await fetch(`${url}/api/${TENANT}/services/${SERVICE}/charge`, {
149
149
  method: "POST",
150
150
  headers: { "Content-Type": "application/json" },
151
151
  body: JSON.stringify({ data: { amount: 7 } }),
@@ -77,10 +77,36 @@ afterAll(async () => {
77
77
  delete (cfg.server as any).audit;
78
78
  });
79
79
 
80
+ // The URL shape, pinned here — every surface moved to `/api/:tenant_id/<family>/…`
81
+ describe("API layout", () => {
82
+ it("serves nothing on a pre-refactor path", async () => {
83
+ // `/api/<tenant>/<collection>/<action>` used to be a collection action. No alias
84
+ // is mounted, so it is a plain 404 — never a silent misroute on another handler.
85
+ const legacy = await fetch(`${url}/api/${TENANT}/${SLUG}/find`, {
86
+ method: "POST",
87
+ headers: { "Content-Type": "application/json" },
88
+ body: JSON.stringify({ $limit: 1 }),
89
+ });
90
+ expect(legacy.status).toBe(404);
91
+
92
+ // Same for a surface that moved with its own family
93
+ expect((await fetch(`${url}/services/${TENANT}/billing/charge`, { method: "POST" })).status).toBe(404);
94
+ expect((await fetch(`${url}/upload/${TENANT}/${SLUG}`, { method: "POST" })).status).toBe(404);
95
+
96
+ // …and the new shape is what answers
97
+ const current = await fetch(`${url}/api/${TENANT}/collections/${SLUG}/find`, {
98
+ method: "POST",
99
+ headers: { "Content-Type": "application/json" },
100
+ body: JSON.stringify({ $limit: 1 }),
101
+ });
102
+ expect(current.status).toBe(200);
103
+ });
104
+ });
105
+
80
106
  describe("audit redaction — defaults", () => {
81
107
  it("masks the password of a login payload (success path)", async () => {
82
108
  const since = new Date();
83
- const res = await fetch(`${url}/api/${TENANT}/${SLUG}/login`, {
109
+ const res = await fetch(`${url}/api/${TENANT}/collections/${SLUG}/login`, {
84
110
  method: "POST",
85
111
  headers: { "Content-Type": "application/json" },
86
112
  body: JSON.stringify({ payload: { email: "ada@example.com", password: "SuperSecret123!" } }),
@@ -95,7 +121,7 @@ describe("audit redaction — defaults", () => {
95
121
 
96
122
  it("masks the password of a login payload (failure path)", async () => {
97
123
  const since = new Date();
98
- const res = await fetch(`${url}/api/${TENANT}/${SLUG}/login`, {
124
+ const res = await fetch(`${url}/api/${TENANT}/collections/${SLUG}/login`, {
99
125
  method: "POST",
100
126
  headers: { "Content-Type": "application/json" },
101
127
  body: JSON.stringify({ payload: { email: "bad", password: "WrongOne!" } }),
@@ -156,7 +182,7 @@ describe("audit redaction — defaults", () => {
156
182
 
157
183
  it("redacts the request headers and query string", async () => {
158
184
  const since = new Date();
159
- const res = await fetch(`${url}/api/${TENANT}/${SLUG}/find?token=query-secret&page=2`, {
185
+ const res = await fetch(`${url}/api/${TENANT}/collections/${SLUG}/find?token=query-secret&page=2`, {
160
186
  method: "POST",
161
187
  headers: {
162
188
  "Content-Type": "application/json",
@@ -81,7 +81,7 @@ describe("audit — custom actions (runAction)", () => {
81
81
 
82
82
  it("audits the HTTP route as well, and marks it as not internal", async () => {
83
83
  const since = new Date();
84
- const res = await fetch(`${url}/api/${TENANT}/${SLUG}/greet`, {
84
+ const res = await fetch(`${url}/api/${TENANT}/collections/${SLUG}/greet`, {
85
85
  method: "POST",
86
86
  headers: { "Content-Type": "application/json" },
87
87
  body: JSON.stringify({ data: { name: "Grace" } }),
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { endpoints, FAMILIES, isReservedFamily, patterns, segment } from "../lib/endpoints";
3
+
4
+ /**
5
+ * The URL of everything — pinned here so a change of shape is a **deliberate**
6
+ * change (the whole surface moved to `/api/:tenant_id/<family>/…` once already,
7
+ * and every client, doc and skill had to follow).
8
+ */
9
+
10
+ describe("endpoints — the server patterns", () => {
11
+ it("keeps every surface under `/api/:tenant_id/<family>`", () => {
12
+ for (const pattern of Object.values(patterns)) {
13
+ expect(pattern.startsWith("/api/:tenant_id/")).toBe(true);
14
+ }
15
+ });
16
+
17
+ it("names a family the builders also use", () => {
18
+ const families = new Set<string>(FAMILIES);
19
+ for (const pattern of Object.values(patterns)) {
20
+ const [, , , family] = pattern.split("/");
21
+ expect(families.has(family!)).toBe(true);
22
+ }
23
+ });
24
+
25
+ it("registers no pattern that could shadow a collection action", () => {
26
+ // A collection lives under `/collections/`, so `services`, `vars` or `login`
27
+ // are usable as collection names — that is the point of the layout.
28
+ expect(patterns.collection.split("/")[3]).toBe("collections");
29
+ expect(patterns.login).toBe("/api/:tenant_id/collections/:collection/login");
30
+ });
31
+ });
32
+
33
+ describe("endpoints — the builders", () => {
34
+ it("builds the documented paths", () => {
35
+ expect(endpoints.collection("v1", "orders", "find")).toBe("/api/v1/collections/orders/find");
36
+ expect(endpoints.collection("v1", "users", "login")).toBe("/api/v1/collections/users/login");
37
+ expect(endpoints.service("v1", "billing", "charge")).toBe("/api/v1/services/billing/charge");
38
+ expect(endpoints.vars("v1", "get")).toBe("/api/v1/vars/get");
39
+ expect(endpoints.upload("v1", "photos")).toBe("/api/v1/upload/photos");
40
+ expect(endpoints.file("v1", "photos", "507f1f77.jpg")).toBe("/api/v1/files/photos/507f1f77.jpg");
41
+ expect(endpoints.agent("v1", "support", "generate")).toBe("/api/v1/agents/support/generate");
42
+ expect(endpoints.mcp("v1")).toBe("/api/v1/mcp");
43
+ });
44
+
45
+ it("matches the pattern it maps to", () => {
46
+ const mapped: Array<[string, string]> = [
47
+ [patterns.collection, endpoints.collection("t", "c", "a")],
48
+ [patterns.login, endpoints.collection("t", "c", "login")],
49
+ [patterns.service, endpoints.service("t", "s", "a")],
50
+ [patterns.vars, endpoints.vars("t", "a")],
51
+ [patterns.upload, endpoints.upload("t", "c")],
52
+ [patterns.file, endpoints.file("t", "c", "f")],
53
+ [patterns.agent, endpoints.agent("t", "ag", "a")],
54
+ [patterns.mcp, endpoints.mcp("t")],
55
+ ];
56
+
57
+ for (const [pattern, path] of mapped) {
58
+ const expected = pattern.split("/").slice(1);
59
+ expect(path.split("/").slice(1)).toHaveLength(expected.length);
60
+ // Same shape: each `:param` is one segment, the literals are identical
61
+ for (const [index, part] of expected.entries()) {
62
+ if (part.startsWith(":")) continue;
63
+ expect(path.split("/").slice(1)[index]).toBe(part);
64
+ }
65
+ }
66
+ });
67
+
68
+ it("encodes a segment, so an id with a slash cannot forge a path", () => {
69
+ expect(segment("a/b c")).toBe("a%2Fb%20c");
70
+ expect(endpoints.file("v1", "photos", "../etc/passwd")).toBe("/api/v1/files/photos/..%2Fetc%2Fpasswd");
71
+ });
72
+ });
73
+
74
+ describe("endpoints — reserved families", () => {
75
+ it("recognises what a tenant route must not shadow", () => {
76
+ for (const family of FAMILIES) expect(isReservedFamily(family)).toBe(true);
77
+ expect(isReservedFamily("orders")).toBe(false);
78
+ expect(isReservedFamily("Collections")).toBe(false);
79
+ });
80
+ });
@@ -21,6 +21,8 @@ beforeAll(async () => {
21
21
  dir: "src",
22
22
  database: { uri: SRC_DB },
23
23
  replication: {
24
+ // These cases exercise the scan engine (file collections)
25
+ mode: "scan",
24
26
  runOnBoot: false,
25
27
  schedule: { interval: "1h" },
26
28
  destinations: [{ id: "backup", uri: DEST_DB }],
@@ -0,0 +1,9 @@
1
+ import { define } from "../../../../index";
2
+
3
+ /** A second agent of the fixture tenant — no tool, no memory. */
4
+ export default define.Agent({
5
+ id: "support",
6
+ description: "Answers support questions.",
7
+ instructions: "You are a support agent.",
8
+ provider: { model: "claude-test", protocol: "anthropic", apiKey: "fixture-key" },
9
+ });