@anteros/core 0.0.41 → 0.0.42

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 (102) 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 +110 -32
  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 +1327 -0
  11. package/lib/agentMemory.ts +586 -0
  12. package/lib/agents.ts +157 -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/messages.ts +299 -0
  25. package/lib/providers.ts +867 -0
  26. package/lib/routes.ts +14 -1
  27. package/lib/scriptWorker.ts +4 -1
  28. package/lib/scripts.ts +6 -1
  29. package/lib/sockets.ts +3 -1
  30. package/lib/workflow.ts +209 -37
  31. package/lib/zip.ts +109 -0
  32. package/package.json +1 -1
  33. package/server/access.ts +96 -0
  34. package/server/agents.ts +451 -0
  35. package/server/api.ts +14 -72
  36. package/server/boot.ts +11 -0
  37. package/server/hono.ts +8 -3
  38. package/server/mcp.ts +17 -63
  39. package/server/routes.ts +19 -24
  40. package/tests/agent-memory.test.ts +617 -0
  41. package/tests/agent-working-memory.test.ts +402 -0
  42. package/tests/agent.test.ts +1239 -0
  43. package/tests/agents-api.test.ts +593 -0
  44. package/tests/api.test.ts +51 -0
  45. package/tests/attachment.test.ts +241 -0
  46. package/tests/audit-operations.test.ts +1 -1
  47. package/tests/audit-redact.test.ts +29 -3
  48. package/tests/audit-run-action.test.ts +1 -1
  49. package/tests/endpoints.test.ts +80 -0
  50. package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
  51. package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
  52. package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
  53. package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
  54. package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
  55. package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
  56. package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
  57. package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
  58. package/tests/fixtures/attachment/data.csv +2 -0
  59. package/tests/fixtures/fake-provider.ts +128 -0
  60. package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
  61. package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
  62. package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
  63. package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
  64. package/tests/fixtures/vars-tenant/vars/private.var.ts +4 -1
  65. package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
  66. package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
  67. package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
  68. package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
  69. package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
  70. package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
  71. package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
  72. package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
  73. package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
  74. package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
  75. package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
  76. package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
  77. package/tests/hono.test.ts +6 -3
  78. package/tests/locks.test.ts +120 -0
  79. package/tests/mcp.test.ts +11 -11
  80. package/tests/messages.test.ts +258 -0
  81. package/tests/replication-control.test.ts +2 -2
  82. package/tests/replication-meta.test.ts +243 -0
  83. package/tests/upload.test.ts +44 -12
  84. package/tests/vars-api.test.ts +1 -1
  85. package/tests/vars-replication.test.ts +3 -2
  86. package/tests/workflow-engine.test.ts +340 -0
  87. package/tests/workflow-retention.test.ts +103 -0
  88. package/tests/workflow.test.ts +186 -0
  89. package/types/agent.d.ts +837 -0
  90. package/types/config.d.ts +16 -0
  91. package/types/file.d.ts +7 -7
  92. package/types/hook.d.ts +6 -0
  93. package/types/lifecycle.d.ts +6 -0
  94. package/types/mcp.d.ts +11 -1
  95. package/types/replication.d.ts +29 -0
  96. package/types/route.d.ts +7 -1
  97. package/types/scripts.d.ts +6 -0
  98. package/types/service.d.ts +7 -1
  99. package/types/tenant.d.ts +13 -0
  100. package/types/vars.d.ts +2 -2
  101. package/types/websocket.d.ts +6 -0
  102. package/types/workflow.d.ts +50 -3
@@ -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
+ });
@@ -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", compatible: "anthropic", apiKey: "fixture-key" },
9
+ });
@@ -0,0 +1,21 @@
1
+ import { define, v } from "../../../../index";
2
+
3
+ /**
4
+ * Loader fixture — `{tenant.dir}/agents/**\/*.agent.ts`.
5
+ * No call is ever made against the provider here: only the loader is exercised.
6
+ */
7
+ export default define.Agent({
8
+ id: "weather",
9
+ name: "Weather Agent",
10
+ description: "Answers weather questions with a tool call",
11
+ instructions: "You are a concise weather assistant.",
12
+ provider: { model: "test-model", compatible: "openai", apiKey: "fixture-key" },
13
+ tools: {
14
+ forecast: define.Tool({
15
+ id: "forecast",
16
+ description: "Current weather for a city",
17
+ inputSchema: v.object({ city: v.string().required() }),
18
+ execute: async ({ city }) => ({ city, celsius: 21 }),
19
+ }),
20
+ },
21
+ });
@@ -0,0 +1,46 @@
1
+ import { define, v, agents } from "../../../../index";
2
+
3
+ /**
4
+ * HTTP-API fixture — exposed through `POST /api/:tenant_id/agents/:agent/:action`.
5
+ *
6
+ * The provider endpoint comes from the environment so the test can point it at
7
+ * its own fake provider (the port is chosen by the OS).
8
+ */
9
+ export default define.Agent({
10
+ id: "assistant",
11
+ name: "Assistant",
12
+ description: "Fixture agent for the HTTP API",
13
+ instructions: "You are a fixture agent.",
14
+ provider: {
15
+ model: "test-model",
16
+ compatible: "openai",
17
+ apiKey: "test-key",
18
+ baseUrl: Bun.env.AGENT_API_TEST_URL ?? "http://127.0.0.1:1",
19
+ options: { retries: 0 },
20
+ },
21
+ maxSteps: 3,
22
+ memory: new agents.memory.InMemory(),
23
+ tools: {
24
+ echo: define.Tool({
25
+ id: "echo",
26
+ description: "Echoes the given text",
27
+ inputSchema: v.object({ text: v.string().required() }),
28
+ execute: async ({ text }) => ({ echoed: text }),
29
+ }),
30
+ },
31
+ api: {
32
+ access: {
33
+ // public metadata — no token needed
34
+ info: true,
35
+ generate: (ctx) => !!ctx.token.value,
36
+ stream: (ctx) => !!ctx.token.value,
37
+ object: (ctx) => !!ctx.token.value,
38
+ history: (ctx) => !!ctx.token.value,
39
+ threads: (ctx) => !!ctx.token.value,
40
+ clear: (ctx) => !!ctx.token.value,
41
+ },
42
+ object: {
43
+ schema: v.object({ city: v.string().required(), celsius: v.number().required() }),
44
+ },
45
+ },
46
+ });
@@ -0,0 +1,23 @@
1
+ import { define } from "../../../../index";
2
+
3
+ /**
4
+ * Public agent — `generate` is allowed without a token. Declares no memory and
5
+ * no `api.object`, to exercise `AGENT_NO_MEMORY` / `AGENT_NO_OBJECT_SCHEMA`.
6
+ */
7
+ export default define.Agent({
8
+ id: "plain",
9
+ description: "A plain agent with no memory and no object schema.",
10
+ instructions: "You are a plain agent.",
11
+ provider: {
12
+ model: "test-model",
13
+ compatible: "openai",
14
+ apiKey: "test-key",
15
+ baseUrl: Bun.env.AGENT_API_TEST_URL ?? "http://127.0.0.1:1",
16
+ options: { retries: 0 },
17
+ },
18
+ api: {
19
+ // public on purpose: `object` also needs a rule so the *missing schema*
20
+ // error can be exercised (otherwise the action is denied first)
21
+ access: { generate: true, object: true },
22
+ },
23
+ });
@@ -0,0 +1,15 @@
1
+ import { define } from "../../../../index";
2
+
3
+ /** Declares no `api` at all — every action must be denied (secure by default). */
4
+ export default define.Agent({
5
+ id: "private",
6
+ description: "Not exposed over HTTP.",
7
+ instructions: "You are a private agent.",
8
+ provider: {
9
+ model: "test-model",
10
+ compatible: "openai",
11
+ apiKey: "test-key",
12
+ baseUrl: Bun.env.AGENT_API_TEST_URL ?? "http://127.0.0.1:1",
13
+ options: { retries: 0 },
14
+ },
15
+ });
@@ -0,0 +1,26 @@
1
+ import { define } from "../../../../index";
2
+
3
+ /**
4
+ * A collection whose hook uses `agents` — proving the LLM registry is injected
5
+ * into the hook context exactly like `rest` and `io`.
6
+ *
7
+ * The observation is published on `globalThis` (the test reads it back): a hook
8
+ * must not stuff arbitrary keys into `meta.data`, the collection schema rejects
9
+ * them (`allowUnknown: false`).
10
+ */
11
+ export default define.Collection({
12
+ slug: "notes",
13
+ fields: [{ name: "title", type: "string" }],
14
+ hooks: {
15
+ beforeOperation: define.Hook(async ({ agents, api, rest, meta }) => {
16
+ if (meta.action !== "insertOne") return;
17
+ (globalThis as any).__agentsHookProbe = {
18
+ ids: agents.ids().sort(),
19
+ bound: agents.get("assistant")?.getRest() === rest,
20
+ // The in-process facade, bound to the same `rest`
21
+ slug: api.collection("notes").getSlug(),
22
+ notes: await api.collection("notes").countDocuments(),
23
+ };
24
+ }),
25
+ },
26
+ });
@@ -0,0 +1,21 @@
1
+ import { define } from "../../../../index";
2
+
3
+ /** A route reading the LLM registry **and the in-process facade** from its context. */
4
+ export default define.Route({
5
+ method: "GET",
6
+ path: "/probe",
7
+ handler: async ({ agents, api, rest }) => Response.json({
8
+ agents: agents.ids().sort(),
9
+ hasAssistant: !!agents.get("assistant"),
10
+ // `api` groups the same calls as `rest` — bound and namespaced
11
+ slug: api.collection("notes").getSlug(),
12
+ notes: await api.collection("notes").countDocuments(),
13
+ sameAsRest: (await api.collection("notes").find()) .length === (await rest.find("notes")).length,
14
+ agentBound: api.agent("assistant")?.getRest() === rest,
15
+ unknownAgent: api.agent("ghost"),
16
+ vars: typeof api.vars.get,
17
+ serviceRun: typeof api.service("probe").run,
18
+ serviceCall: await api.service("probe").run("echo", { from: "route" }),
19
+ fileUrl: api.files.url("invoices", "f1.pdf", { width: 800 }),
20
+ }),
21
+ });
@@ -0,0 +1,19 @@
1
+ import { define } from "../../../../index";
2
+
3
+ /** A service action reading the LLM registry and the facade from its context. */
4
+ export default define.Service({
5
+ name: "probe",
6
+ enabled: true,
7
+ actions: {
8
+ check: async ({ agents, api, rest }) => ({
9
+ agents: agents.ids().sort(),
10
+ hasAssistant: !!agents.get("assistant"),
11
+ bound: agents.get("assistant")?.getRest() === rest,
12
+ tenant: rest.tenant_id,
13
+ // `api.service(name).run` is the grouped `rest.runService`
14
+ nested: await api.service("probe").run("echo", { from: "api" }),
15
+ varType: typeof api.vars.get,
16
+ }),
17
+ echo: async ({ data }) => data,
18
+ },
19
+ });
@@ -0,0 +1,2 @@
1
+ ref,total
2
+ A-1,99