@anteros/core 0.0.40 → 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 (130) hide show
  1. package/database/audit.ts +143 -0
  2. package/database/collection.ts +6 -4
  3. package/database/deleteLog.ts +98 -0
  4. package/database/file.ts +7 -6
  5. package/database/mongodbadapter.ts +420 -185
  6. package/database/replication.ts +858 -0
  7. package/database/schema.ts +140 -125
  8. package/database/tenant.ts +4 -0
  9. package/database/ttl.ts +68 -0
  10. package/database/vars.ts +405 -0
  11. package/database/workflow.ts +466 -240
  12. package/index.ts +16 -0
  13. package/lib/agent.ts +1327 -0
  14. package/lib/agentMemory.ts +586 -0
  15. package/lib/agents.ts +157 -0
  16. package/lib/ai.ts +0 -0
  17. package/lib/api.ts +246 -0
  18. package/lib/attachment.ts +351 -0
  19. package/lib/audit.ts +271 -0
  20. package/lib/auditResult.ts +133 -0
  21. package/lib/define.ts +62 -1
  22. package/lib/endpoints.ts +80 -0
  23. package/lib/files.ts +66 -12
  24. package/lib/jsonSchema.ts +183 -0
  25. package/lib/lifecycle.ts +147 -0
  26. package/lib/load.ts +17 -0
  27. package/lib/mcp.ts +3 -2
  28. package/lib/messages.ts +299 -0
  29. package/lib/providers.ts +867 -0
  30. package/lib/redact.ts +117 -0
  31. package/lib/routes.ts +14 -1
  32. package/lib/scriptWorker.ts +4 -1
  33. package/lib/scripts.ts +6 -1
  34. package/lib/sockets.ts +3 -1
  35. package/lib/workflow.ts +209 -37
  36. package/lib/zip.ts +109 -0
  37. package/package.json +1 -1
  38. package/server/access.ts +96 -0
  39. package/server/agents.ts +451 -0
  40. package/server/api.ts +121 -95
  41. package/server/boot.ts +196 -53
  42. package/server/flags.ts +136 -0
  43. package/server/hono.ts +9 -4
  44. package/server/mcp.ts +17 -63
  45. package/server/routes.ts +19 -24
  46. package/tests/agent-memory.test.ts +617 -0
  47. package/tests/agent-working-memory.test.ts +402 -0
  48. package/tests/agent.test.ts +1239 -0
  49. package/tests/agents-api.test.ts +593 -0
  50. package/tests/api.test.ts +51 -0
  51. package/tests/attachment.test.ts +241 -0
  52. package/tests/audit-file.test.ts +296 -0
  53. package/tests/audit-operations.test.ts +184 -0
  54. package/tests/audit-redact.test.ts +259 -0
  55. package/tests/audit-results.test.ts +188 -0
  56. package/tests/audit-retention-config.test.ts +76 -0
  57. package/tests/audit-run-action.test.ts +120 -0
  58. package/tests/audit.test.ts +320 -0
  59. package/tests/db-cache.test.ts +7 -7
  60. package/tests/endpoints.test.ts +80 -0
  61. package/tests/file-collection-replication.test.ts +98 -0
  62. package/tests/fixtures/agent-tenant/agents/support.agent.ts +9 -0
  63. package/tests/fixtures/agent-tenant/agents/weather.agent.ts +21 -0
  64. package/tests/fixtures/agents-api/agents/assistant.agent.ts +46 -0
  65. package/tests/fixtures/agents-api/agents/plain.agent.ts +23 -0
  66. package/tests/fixtures/agents-api/agents/private.agent.ts +15 -0
  67. package/tests/fixtures/agents-api/collections/notes.model.ts +26 -0
  68. package/tests/fixtures/agents-api/routes/probe.route.ts +21 -0
  69. package/tests/fixtures/agents-api/services/probe.service.ts +19 -0
  70. package/tests/fixtures/attachment/data.csv +2 -0
  71. package/tests/fixtures/fake-provider.ts +128 -0
  72. package/tests/fixtures/lc-disabled/lifecycle.ts +11 -0
  73. package/tests/fixtures/lc-hang/lifecycle.ts +13 -0
  74. package/tests/fixtures/lc-normal/lifecycle.ts +23 -0
  75. package/tests/fixtures/lc-throw/lifecycle.ts +6 -0
  76. package/tests/fixtures/repl-only/app.ts +26 -0
  77. package/tests/fixtures/repl-only/tenant/collections/orders.model.ts +14 -0
  78. package/tests/fixtures/repl-only/tenant/lifecycle.ts +14 -0
  79. package/tests/fixtures/repl-only/tenant/scripts/probe.run.ts +8 -0
  80. package/tests/fixtures/replication-meta/agents/chat.agent.ts +16 -0
  81. package/tests/fixtures/replication-meta/collections/counters.model.ts +13 -0
  82. package/tests/fixtures/replication-meta/collections/orders.model.ts +9 -0
  83. package/tests/fixtures/replication-meta/workflows/run.workflow.ts +8 -0
  84. package/tests/fixtures/vars-tenant/vars/config.var.ts +23 -0
  85. package/tests/fixtures/vars-tenant/vars/private.var.ts +9 -0
  86. package/tests/fixtures/workflow-engine/workflows/failing.workflow.ts +34 -0
  87. package/tests/fixtures/workflow-engine/workflows/flaky.workflow.ts +23 -0
  88. package/tests/fixtures/workflow-engine/workflows/hang.workflow.ts +17 -0
  89. package/tests/fixtures/workflow-engine/workflows/simple.workflow.ts +19 -0
  90. package/tests/fixtures/workflow-engine/workflows/slow.workflow.ts +23 -0
  91. package/tests/fixtures/workflow-engine/workflows/versioned.workflow.ts +19 -0
  92. package/tests/fixtures/workflow-tenant/workflows/broken.workflow.ts +3 -0
  93. package/tests/fixtures/workflow-tenant/workflows/disabled.workflow.ts +8 -0
  94. package/tests/fixtures/workflow-tenant/workflows/invoice.workflow.ts +21 -0
  95. package/tests/fixtures/workflow-tenant/workflows/no-id.workflow.ts +7 -0
  96. package/tests/fixtures/workflow-tenant/workflows/order-duplicate.workflow.ts +13 -0
  97. package/tests/fixtures/workflow-tenant/workflows/order.workflow.ts +17 -0
  98. package/tests/hono.test.ts +6 -3
  99. package/tests/lifecycle.test.ts +96 -0
  100. package/tests/locks.test.ts +120 -0
  101. package/tests/mcp.test.ts +11 -11
  102. package/tests/messages.test.ts +258 -0
  103. package/tests/replication-control.test.ts +128 -0
  104. package/tests/replication-initial-sync.test.ts +86 -0
  105. package/tests/replication-meta.test.ts +243 -0
  106. package/tests/replication-only.test.ts +358 -0
  107. package/tests/replication.test.ts +188 -0
  108. package/tests/upload.test.ts +141 -8
  109. package/tests/vars-api.test.ts +123 -0
  110. package/tests/vars-replication.test.ts +96 -0
  111. package/tests/vars.test.ts +216 -0
  112. package/tests/workflow-engine.test.ts +340 -0
  113. package/tests/workflow-retention.test.ts +103 -0
  114. package/tests/workflow.test.ts +186 -0
  115. package/types/agent.d.ts +837 -0
  116. package/types/collection.d.ts +3 -0
  117. package/types/config.d.ts +80 -0
  118. package/types/file.d.ts +11 -8
  119. package/types/hook.d.ts +6 -0
  120. package/types/lifecycle.d.ts +77 -0
  121. package/types/mcp.d.ts +11 -1
  122. package/types/replication.d.ts +228 -0
  123. package/types/route.d.ts +7 -1
  124. package/types/scripts.d.ts +6 -0
  125. package/types/service.d.ts +7 -1
  126. package/types/tenant.d.ts +36 -0
  127. package/types/vars.d.ts +147 -0
  128. package/types/websocket.d.ts +6 -0
  129. package/types/workflow.d.ts +50 -3
  130. package/utils/func.ts +67 -52
@@ -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
+ });
@@ -0,0 +1,296 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { useRest } from "../database/rest";
5
+ import { formatConfig, cfg } from "../server/config";
6
+ import { syncTenants, getTenant } from "../database/tenant";
7
+ import { resolveAuditFile, writeAuditFile, flushAuditFile, pruneAuditFiles } from "../lib/audit";
8
+ import type { AuditFileOptions } from "../lib/audit";
9
+
10
+ const DIR = path.join(import.meta.dir, ".tmp-audit-file");
11
+ const TODAY = new Date().toISOString().slice(0, 10);
12
+
13
+ /** Minimal activity document (same shape as `ActivityInput`). */
14
+ function activity(action: string, collection = "items") {
15
+ return {
16
+ internal: true,
17
+ trace: { id: crypto.randomUUID() },
18
+ meta: {},
19
+ operation: {
20
+ tenant: "test",
21
+ action,
22
+ collection,
23
+ status: "success",
24
+ input: null,
25
+ result: null,
26
+ error: null,
27
+ duration: 0,
28
+ transaction: false,
29
+ },
30
+ ts: new Date(),
31
+ } as any;
32
+ }
33
+
34
+ function readLines(file: string): any[] {
35
+ return fs.readFileSync(file, "utf-8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l));
36
+ }
37
+
38
+ function filesOf(dir: string, tenantId: string): string[] {
39
+ try {
40
+ return fs.readdirSync(dir).filter((f) => f.startsWith(`audit-${tenantId}-`)).sort();
41
+ } catch {
42
+ return [];
43
+ }
44
+ }
45
+
46
+ beforeAll(() => {
47
+ try { fs.rmSync(DIR, { recursive: true, force: true }); } catch {}
48
+ });
49
+
50
+ afterAll(() => {
51
+ // `cfg.server` is shared by every test file in the same process: never leak
52
+ // the audit sink into the other suites (they would write files too)
53
+ delete (cfg.server as any).audit;
54
+ try { fs.rmSync(DIR, { recursive: true, force: true }); } catch {};
55
+ });
56
+
57
+ // ─── Disabled by default ────────────────────────────────────────────────
58
+
59
+ describe("audit file — disabled by default", () => {
60
+ const TENANT = "af-off";
61
+ let rest: InstanceType<typeof useRest>;
62
+
63
+ beforeAll(async () => {
64
+ formatConfig({
65
+ server: { port: 4000 },
66
+ tenants: [{ id: TENANT, dir: "src", database: { uri: "mongodb://localhost:27017/_AUDIT_FILE_OFF" } }],
67
+ });
68
+ await syncTenants();
69
+ rest = new useRest({ internal: true, tenant_id: TENANT });
70
+ });
71
+
72
+ it("writes nothing when no config is set anywhere", async () => {
73
+ await rest.audit.addActivities([activity("off")]);
74
+ await flushAuditFile();
75
+
76
+ expect(filesOf(DIR, TENANT)).toEqual([]);
77
+ expect(fs.existsSync(path.join(DIR, TENANT))).toBe(false);
78
+ });
79
+
80
+ it("resolves to null", () => {
81
+ expect(resolveAuditFile({ id: TENANT, dir: "src", database: { uri: "" } } as any)).toBeNull();
82
+ });
83
+ });
84
+
85
+ // ─── Configured (tenant level and server default) ───────────────────────
86
+
87
+ describe("audit file — writes and rotation", () => {
88
+ const TENANT = "af-tenant";
89
+ const ROT = "af-rot";
90
+ const OPTOUT = "af-optout";
91
+ const DIR_TENANT = path.join(DIR, "tenant");
92
+ const DIR_ROT = path.join(DIR, "rot");
93
+ const DIR_SERVER = path.join(DIR, "server");
94
+
95
+ let rest: InstanceType<typeof useRest>;
96
+ let rot: InstanceType<typeof useRest>;
97
+ let optout: InstanceType<typeof useRest>;
98
+
99
+ beforeAll(async () => {
100
+ formatConfig({
101
+ server: {
102
+ port: 4000,
103
+ // Server-wide default: everything goes to <DIR>/server unless a tenant says otherwise
104
+ audit: { file: { dir: DIR_SERVER } },
105
+ },
106
+ tenants: [
107
+ {
108
+ id: TENANT,
109
+ dir: "src",
110
+ database: { uri: "mongodb://localhost:27017/_AUDIT_FILE_TENANT" },
111
+ // Tenant wins: own dir + 7d retention (default size cap)
112
+ audit: { file: { dir: DIR_TENANT, retention: "7d" } },
113
+ },
114
+ {
115
+ id: ROT,
116
+ dir: "src",
117
+ database: { uri: "mongodb://localhost:27017/_AUDIT_FILE_ROT" },
118
+ // Cap is set by the rotation test itself, once the real line size is known
119
+ audit: { file: { dir: DIR_ROT } },
120
+ },
121
+ {
122
+ id: OPTOUT,
123
+ dir: "src",
124
+ database: { uri: "mongodb://localhost:27017/_AUDIT_FILE_OPTOUT" },
125
+ audit: { file: false }, // opt out of the server default
126
+ },
127
+ ],
128
+ });
129
+ await syncTenants();
130
+ rest = new useRest({ internal: true, tenant_id: TENANT });
131
+ rot = new useRest({ internal: true, tenant_id: ROT });
132
+ optout = new useRest({ internal: true, tenant_id: OPTOUT });
133
+ });
134
+
135
+ it("appends one JSONL line per activity, in a dated file", async () => {
136
+ await rest.audit.addActivities([activity("first"), activity("second")]);
137
+ await flushAuditFile();
138
+
139
+ const files = filesOf(DIR_TENANT, TENANT);
140
+ expect(files).toEqual([`audit-${TENANT}-${TODAY}.jsonl`]);
141
+
142
+ const lines = readLines(path.join(DIR_TENANT, files[0]!));
143
+ expect(lines.length).toBe(2);
144
+ expect(lines.map((l) => l.operation.action)).toEqual(["first", "second"]);
145
+ expect(typeof lines[0]!.ts).toBe("string"); // Date serialized as ISO
146
+ });
147
+
148
+ it("appends (never truncates) on the next call", async () => {
149
+ await rest.audit.addActivities([activity("third")]);
150
+ await flushAuditFile();
151
+
152
+ const lines = readLines(path.join(DIR_TENANT, `audit-${TENANT}-${TODAY}.jsonl`));
153
+ expect(lines.map((l) => l.operation.action)).toEqual(["first", "second", "third"]);
154
+ });
155
+
156
+ it("rotates when the size cap is reached", async () => {
157
+ // First activity reserves the plain file of the day
158
+ await rot.audit.addActivities([activity("rot-0")]);
159
+ await flushAuditFile();
160
+
161
+ const plain = path.join(DIR_ROT, `audit-${ROT}-${TODAY}.jsonl`);
162
+ const lineBytes = fs.statSync(plain).size;
163
+ // Cap sized so exactly two lines fit per file (line size is known now)
164
+ const cap = lineBytes * 2 + 5;
165
+ ((getTenant(ROT)!.audit!.file) as AuditFileOptions).maxSize = cap;
166
+
167
+ for (let i = 1; i < 4; i++) {
168
+ await rot.audit.addActivities([activity(`rot-${i}`)]);
169
+ await flushAuditFile();
170
+ }
171
+
172
+ // Chronological split: the plain file holds the two first lines,
173
+ // the `-1` rotation the next two
174
+ const files = filesOf(DIR_ROT, ROT);
175
+ const rotated = `audit-${ROT}-${TODAY}-1.jsonl`;
176
+ const name = `audit-${ROT}-${TODAY}.jsonl`;
177
+
178
+ expect(files.length).toBe(2);
179
+ expect(files).toContain(name);
180
+ expect(files).toContain(rotated);
181
+
182
+ const actions = (file: string) =>
183
+ readLines(path.join(DIR_ROT, file)).map((l) => l.operation.action);
184
+ expect(actions(name)).toEqual(["rot-0", "rot-1"]);
185
+ expect(actions(rotated)).toEqual(["rot-2", "rot-3"]);
186
+
187
+ // every file holds valid JSONL, stays under the cap, and no line is lost
188
+ for (const file of files) {
189
+ const raw = fs.readFileSync(path.join(DIR_ROT, file), "utf-8");
190
+ expect(raw.endsWith("\n")).toBe(true);
191
+ expect(raw.length).toBeLessThanOrEqual(cap);
192
+ }
193
+ expect([...actions(name), ...actions(rotated)])
194
+ .toEqual(["rot-0", "rot-1", "rot-2", "rot-3"]);
195
+ });
196
+
197
+ it("carries the `_id` assigned by MongoDB", async () => {
198
+ const files = filesOf(DIR_TENANT, TENANT);
199
+ const line = readLines(path.join(DIR_TENANT, files[0]!))[0]!;
200
+ expect(line._id).toBeString();
201
+ expect(line._id.length).toBe(24);
202
+ });
203
+
204
+ it("uses the server default when the tenant declares nothing", async () => {
205
+ const inherited = "af-inherit";
206
+ // Same server config, another tenant — resolved from `server.audit.file`
207
+ await rest.audit.addActivities([activity("own")]);
208
+ writeAuditFile({ id: inherited, dir: "src", database: { uri: "" } } as any, [activity("srv")]);
209
+ await flushAuditFile();
210
+
211
+ const files = filesOf(DIR_SERVER, inherited);
212
+ expect(files.length).toBe(1);
213
+ expect(readLines(path.join(DIR_SERVER, files[0]!))[0].operation.action).toBe("srv");
214
+ });
215
+
216
+ it("lets a tenant opt out of the server default with `false`", async () => {
217
+ await optout.audit.addActivities([activity("blocked")]);
218
+ await flushAuditFile();
219
+
220
+ expect(filesOf(DIR_SERVER, OPTOUT)).toEqual([]);
221
+ expect(filesOf(DIR_TENANT, OPTOUT)).toEqual([]);
222
+ });
223
+
224
+ it("prunes files older than the retention", async () => {
225
+ const stale = path.join(DIR_TENANT, `audit-${TENANT}-2020-01-01.jsonl`);
226
+ fs.writeFileSync(stale, "{}\n");
227
+ const old = new Date(Date.now() - 60 * 86400_000);
228
+ fs.utimesSync(stale, old, old);
229
+
230
+ const fresh = path.join(DIR_TENANT, `audit-${TENANT}-${TODAY}.jsonl`);
231
+ const before = filesOf(DIR_TENANT, TENANT);
232
+
233
+ const removed = await pruneAuditFiles(DIR_TENANT, TENANT, 7 * 86400_000);
234
+
235
+ expect(removed).toContain(`audit-${TENANT}-2020-01-01.jsonl`);
236
+ expect(fs.existsSync(stale)).toBe(false);
237
+ expect(fs.existsSync(fresh)).toBe(true);
238
+ expect(filesOf(DIR_TENANT, TENANT).length).toBe(before.length - 1);
239
+ });
240
+
241
+ it("only touches its own tenant's files", async () => {
242
+ const stale = path.join(DIR_TENANT, `audit-${OPTOUT}-2020-01-01.jsonl`);
243
+ fs.writeFileSync(stale, "{}\n");
244
+ const old = new Date(Date.now() - 60 * 86400_000);
245
+ fs.utimesSync(stale, old, old);
246
+
247
+ await pruneAuditFiles(DIR_TENANT, TENANT, 7 * 86400_000);
248
+
249
+ expect(fs.existsSync(stale)).toBe(true);
250
+ fs.rmSync(stale, { force: true });
251
+ });
252
+
253
+ it("mirrors the database retention when no file retention is set", () => {
254
+ const inherited = resolveAuditFile({
255
+ id: "x", dir: "src", database: { uri: "" }, audit: { retention: "30d", file: true },
256
+ } as any);
257
+ expect(inherited!.retentionMs).toBe(30 * 86400_000);
258
+ expect(inherited!.dir).toBe(path.join(".logs", "audit"));
259
+ });
260
+ });
261
+
262
+ // ─── Automatic pruning on first write ───────────────────────────────────
263
+
264
+ describe("audit file — automatic pruning", () => {
265
+ const TENANT = "af-prune";
266
+ const DIR_PRUNE = path.join(DIR, "prune");
267
+ let rest: InstanceType<typeof useRest>;
268
+
269
+ beforeAll(async () => {
270
+ formatConfig({
271
+ server: { port: 4000 },
272
+ tenants: [{
273
+ id: TENANT,
274
+ dir: "src",
275
+ database: { uri: "mongodb://localhost:27017/_AUDIT_FILE_PRUNE" },
276
+ audit: { file: { dir: DIR_PRUNE, retention: "1d" } },
277
+ }],
278
+ });
279
+ await syncTenants();
280
+ rest = new useRest({ internal: true, tenant_id: TENANT });
281
+ });
282
+
283
+ it("deletes stale files on the first write, without waiting", async () => {
284
+ fs.mkdirSync(DIR_PRUNE, { recursive: true });
285
+ const stale = path.join(DIR_PRUNE, `audit-${TENANT}-2019-12-31.jsonl`);
286
+ fs.writeFileSync(stale, "{}\n");
287
+ const old = new Date(Date.now() - 30 * 86400_000);
288
+ fs.utimesSync(stale, old, old);
289
+
290
+ await rest.audit.addActivities([activity("trigger-prune")]);
291
+ await flushAuditFile();
292
+
293
+ expect(fs.existsSync(stale)).toBe(false);
294
+ expect(filesOf(DIR_PRUNE, TENANT).some((f) => f.includes(TODAY))).toBe(true);
295
+ });
296
+ });