@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,188 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
+ import { MongoClient, ObjectId } from "mongodb";
3
+ import { useRest } from "../database/rest";
4
+ import { formatConfig, cfg } from "../server/config";
5
+ import {
6
+ startReplication,
7
+ stopReplication,
8
+ replicateTenant,
9
+ getReplicationState,
10
+ } from "../database/replication";
11
+
12
+ const TEST_TENANT = "repl";
13
+ const SRC_DB = "mongodb://localhost:27017/_REPL_TEST_SRC";
14
+ const DEST_DB = "mongodb://localhost:27017/_REPL_TEST_DEST";
15
+ const DEST_ID = "backup";
16
+
17
+ let rest: InstanceType<typeof useRest>;
18
+ let sourceClient: MongoClient;
19
+ let destClient: MongoClient;
20
+
21
+ beforeAll(async () => {
22
+ formatConfig({
23
+ server: { port: 4000 },
24
+ tenants: [{
25
+ id: TEST_TENANT,
26
+ dir: "src",
27
+ database: { uri: SRC_DB },
28
+ replication: {
29
+ runOnBoot: false,
30
+ schedule: { interval: "1h" },
31
+ destinations: [{ id: DEST_ID, uri: DEST_DB }],
32
+ },
33
+ }],
34
+ });
35
+
36
+ // Connect the source DB manually with a short timeout so a missing MongoDB
37
+ // fails fast and clearly (instead of hanging or process.exit(1)).
38
+ sourceClient = new MongoClient(SRC_DB, { serverSelectionTimeoutMS: 3000 });
39
+ await sourceClient.connect();
40
+ const tenant: any = cfg.tenants?.[0];
41
+ tenant.database.client = sourceClient;
42
+ tenant.database.db = sourceClient.db();
43
+
44
+ (cfg as any).collections = [
45
+ {
46
+ _tenant_: TEST_TENANT,
47
+ slug: "orders",
48
+ fields: [
49
+ { name: "title", type: "string" },
50
+ { name: "status", type: "string" },
51
+ { name: "customer", type: "relationship", relation: { to: "customers" } },
52
+ { name: "paidAt", type: "date" },
53
+ ],
54
+ replication: { enabled: true },
55
+ },
56
+ ];
57
+
58
+ await startReplication();
59
+
60
+ rest = new useRest({ tenant_id: TEST_TENANT });
61
+ destClient = new MongoClient(DEST_DB, { serverSelectionTimeoutMS: 3000 });
62
+ await destClient.connect();
63
+ });
64
+
65
+ afterAll(async () => {
66
+ await stopReplication();
67
+ try { await sourceClient.db().dropDatabase(); } catch (_) {}
68
+ try { await destClient.db().dropDatabase(); } catch (_) {}
69
+ try { await destClient.close(); } catch (_) {}
70
+ try { await sourceClient.close(); } catch (_) {}
71
+ });
72
+
73
+ describe("replication", () => {
74
+ it("replicates inserted documents", async () => {
75
+ const marker = `ins-${crypto.randomUUID()}`;
76
+ const source: any = await rest.insertOne("orders", { title: marker, status: "new" });
77
+ await replicateTenant(TEST_TENANT);
78
+
79
+ const dest = await destClient.db().collection("orders").findOne({ title: marker });
80
+ expect(dest).toBeDefined();
81
+ expect((dest as any)?.status).toBe("new");
82
+ // The destination keeps the **same `_id`** (the upsert is keyed on it), same BSON type
83
+ expect((dest as any)?._id.toString()).toBe(source._id);
84
+ expect((dest as any)?._id).toBeInstanceOf(ObjectId);
85
+ });
86
+
87
+ it("keeps a custom `_id` type (string) as-is", async () => {
88
+ const marker = `custom-${crypto.randomUUID()}`;
89
+ const customId = `order-${crypto.randomUUID()}`; // not a 24-hex string
90
+ await sourceClient.db().collection("orders").insertOne({
91
+ _id: customId as any,
92
+ title: marker,
93
+ status: "new",
94
+ updatedAt: new Date(),
95
+ });
96
+ await replicateTenant(TEST_TENANT);
97
+
98
+ const dest: any = await destClient.db().collection("orders").findOne({ title: marker });
99
+ expect(dest).toBeDefined();
100
+ expect(dest._id).toBe(customId);
101
+ expect(typeof dest._id).toBe("string");
102
+ });
103
+
104
+ it("writes BSON types on the destination — ObjectId relations and Dates stay typed", async () => {
105
+ const marker = `bson-${crypto.randomUUID()}`;
106
+ const customerId = new ObjectId();
107
+ const paidAt = new Date("2026-03-04T05:06:07.000Z");
108
+
109
+ // Through the framework: the relation string and the ISO date are converted
110
+ // to BSON by `toBson` before hitting MongoDB.
111
+ const source: any = await rest.insertOne("orders", {
112
+ title: marker,
113
+ status: "paid",
114
+ customer: customerId.toHexString(),
115
+ paidAt: paidAt.toISOString(),
116
+ });
117
+ await replicateTenant(TEST_TENANT);
118
+
119
+ // …and the destination must hold the same BSON types, not strings
120
+ const dest: any = await destClient.db().collection("orders").findOne({ title: marker });
121
+ expect(dest).toBeDefined();
122
+ expect(dest._id).toBeInstanceOf(ObjectId);
123
+ expect(dest._id.toString()).toBe(source._id);
124
+ expect(dest.customer).toBeInstanceOf(ObjectId);
125
+ expect(dest.customer.toString()).toBe(customerId.toHexString());
126
+ expect(dest.paidAt).toBeInstanceOf(Date);
127
+ expect(dest.paidAt.getTime()).toBe(paidAt.getTime());
128
+ expect(dest.createdAt).toBeInstanceOf(Date);
129
+ expect(dest.updatedAt).toBeInstanceOf(Date);
130
+
131
+ // The source itself stores the very same types (the copy is faithful)
132
+ const raw: any = await sourceClient.db().collection("orders").findOne({ title: marker });
133
+ expect(raw.customer).toBeInstanceOf(ObjectId);
134
+ expect(raw.paidAt).toBeInstanceOf(Date);
135
+ expect(typeof raw.customer).toBe(typeof dest.customer);
136
+ });
137
+
138
+ it("replicates updates", async () => {
139
+ const marker = `upd-${crypto.randomUUID()}`;
140
+ const doc: any = await rest.insertOne("orders", { title: marker, status: "draft" });
141
+ await replicateTenant(TEST_TENANT);
142
+
143
+ await rest.updateOne("orders", doc._id, { $set: { status: "paid" } });
144
+ await replicateTenant(TEST_TENANT);
145
+
146
+ const dest = await destClient.db().collection("orders").findOne({ title: marker });
147
+ expect((dest as any)?.status).toBe("paid");
148
+ });
149
+
150
+ it("replicates deletes via tombstone", async () => {
151
+ const marker = `del-${crypto.randomUUID()}`;
152
+ const doc: any = await rest.insertOne("orders", { title: marker, status: "x" });
153
+ await replicateTenant(TEST_TENANT);
154
+
155
+ const before = await destClient.db().collection("orders").findOne({ title: marker });
156
+ expect(before).toBeDefined();
157
+
158
+ await rest.deleteOne("orders", doc._id);
159
+ await replicateTenant(TEST_TENANT);
160
+
161
+ const after = await destClient.db().collection("orders").findOne({ title: marker });
162
+ expect(after).toBeNull();
163
+ });
164
+
165
+ it("does not re-copy unchanged documents (cursor is persisted)", async () => {
166
+ const marker = `resume-${crypto.randomUUID()}`;
167
+ await rest.insertOne("orders", { title: marker, status: "new" });
168
+
169
+ const first = await replicateTenant(TEST_TENANT);
170
+ const second = await replicateTenant(TEST_TENANT);
171
+
172
+ const r1 = first.find((r) => r.collection === "orders" && r.destination === DEST_ID);
173
+ const r2 = second.find((r) => r.collection === "orders" && r.destination === DEST_ID);
174
+ expect(r1?.inserted).toBeGreaterThanOrEqual(1);
175
+ expect(r2?.inserted).toBe(0);
176
+ expect(r2?.updated).toBe(0);
177
+ });
178
+
179
+ it("stores state in _replication_", async () => {
180
+ await rest.insertOne("orders", { title: `state-${crypto.randomUUID()}`, status: "new" });
181
+ await replicateTenant(TEST_TENANT);
182
+
183
+ const state = await getReplicationState(TEST_TENANT, DEST_ID, "orders");
184
+ expect(state.length).toBe(1);
185
+ expect(state[0]!.status).toBe("success");
186
+ expect(state[0]!.cursor).toBeDefined();
187
+ });
188
+ });
@@ -1,9 +1,11 @@
1
1
  import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
+ import { ObjectId } from "mongodb";
2
3
  import { createApp } from "../server/hono";
3
4
  import { formatConfig, cfg } from "../server/config";
4
5
  import { syncTenants } from "../database/tenant";
5
6
  import { getFileCollection } from "../database/file";
6
7
  import { useRest } from "../database/rest";
8
+ import { createApi } from "../lib/api";
7
9
 
8
10
  const TENANT = "up-test";
9
11
  const DB = "mongodb://localhost:27017/_DB_UP_TEST";
@@ -25,7 +27,7 @@ beforeAll(async () => {
25
27
  _tenant_: TENANT,
26
28
  _isFileCollection_: true,
27
29
  slug: SLUG,
28
- fields: [{ name: "label", type: "string" }],
30
+ fields: [{ name: "label", type: "string" }, { name: "takenAt", type: "date" }],
29
31
  upload: { allowedMimeTypes: ["image/png", "text/plain"], maxSize: 5 * 1024 * 1024 },
30
32
  storage: { driver: "disk" },
31
33
  api: { access: { "*": true } },
@@ -56,13 +58,72 @@ function pngFile(name: string) {
56
58
  return new File([bytes], name, { type: "image/png" });
57
59
  }
58
60
 
61
+ describe("upload — audit trail", () => {
62
+ /** Latest audit entry for an action, polling until it lands */
63
+ async function auditEntry(action: string, id?: string) {
64
+ const filter: any = { "operation.action": action };
65
+ if (id) filter["operation.result._id"] = id;
66
+ for (let i = 0; i < 40; i++) {
67
+ const found: any = await rest.db.collection("_audit_").findOne(filter, { sort: { ts: -1 } });
68
+ if (found) return found;
69
+ await Bun.sleep(25);
70
+ }
71
+ return null;
72
+ }
73
+
74
+ it("records an upload with the file metadata and the generated id — never the document", async () => {
75
+ const form = new FormData();
76
+ form.append("file", pngFile("audited.png"));
77
+ form.append("label", "audited");
78
+
79
+ const res = await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form });
80
+ expect(res.status).toBe(200);
81
+ const body: any = await res.json();
82
+
83
+ const logged: any = await auditEntry("upload", body._id);
84
+ expect(logged).not.toBeNull();
85
+ expect(logged.operation.collection).toBe(SLUG);
86
+ expect(logged.operation.collectionType).toBe("file");
87
+ expect(logged.operation.status).toBe("success");
88
+ expect(logged.internal).toBe(false); // HTTP upload
89
+
90
+ // parameters only: the file's metadata and which custom fields were sent
91
+ expect(logged.operation.input.file.name).toBe("audited.png");
92
+ expect(logged.operation.input.file.mimetype).toBe("image/png");
93
+ expect(typeof logged.operation.input.file.size).toBe("number");
94
+ expect(logged.operation.input.fields).toEqual(["label"]);
95
+ expect(logged.operation.input.label).toBeUndefined(); // the value is NOT stored
96
+
97
+ // and the generated id, never the stored document
98
+ expect(logged.operation.result).toEqual({ _id: body._id });
99
+ });
100
+
101
+ it("records a file deletion", async () => {
102
+ const form = new FormData();
103
+ form.append("file", pngFile("deleted.png"));
104
+ const uploaded: any = await (
105
+ await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form })
106
+ ).json();
107
+
108
+ const res = await fetch(`${url}/api/${TENANT}/files/${SLUG}/${uploaded._id}`, { method: "DELETE" });
109
+ expect(res.status).toBe(200);
110
+
111
+ const logged: any = await auditEntry("deleteFile");
112
+ expect(logged).not.toBeNull();
113
+ expect(logged.operation.input.id).toBe(uploaded._id);
114
+ expect(logged.operation.input.filename).toBeString();
115
+ expect(logged.operation.result).toEqual({ deleted: true });
116
+ expect(logged.internal).toBe(false);
117
+ });
118
+ });
119
+
59
120
  describe("upload — single file (unchanged contract)", () => {
60
121
  it("returns a single object", async () => {
61
122
  const form = new FormData();
62
123
  form.append("file", pngFile("one.png"));
63
124
  form.append("label", "single");
64
125
 
65
- const res = await fetch(`${url}/upload/${TENANT}/${SLUG}`, { method: "POST", body: form });
126
+ const res = await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form });
66
127
  expect(res.status).toBe(200);
67
128
 
68
129
  const body: any = await res.json();
@@ -70,18 +131,25 @@ describe("upload — single file (unchanged contract)", () => {
70
131
  expect(body._id).toBeString();
71
132
  expect(body._file.name).toBe("one.png");
72
133
  expect(body._file.mimetype).toBe("image/png");
73
- expect(body._file.url).toContain(`/files/${TENANT}/${SLUG}/`);
134
+ expect(body._file.url).toContain(`/api/${TENANT}/files/${SLUG}/`);
135
+ expect(body.createdAt).toBeString();
136
+ expect(body.updatedAt).toBeString();
74
137
 
75
138
  // metadata persisted on the document
76
139
  const doc: any = await rest.findOne(SLUG, body._id);
77
140
  expect(doc.label).toBe("single");
141
+
142
+ // Stored as BSON Dates (same end result as collections via toBson)
143
+ const raw: any = await rest.db.collection(SLUG).findOne({ _id: new ObjectId(body._id) });
144
+ expect(raw.createdAt).toBeInstanceOf(Date);
145
+ expect(raw.updatedAt).toBeInstanceOf(Date);
78
146
  });
79
147
 
80
148
  it("accepts the 'upload' field name too", async () => {
81
149
  const form = new FormData();
82
150
  form.append("upload", pngFile("alt.png"));
83
151
 
84
- const res = await fetch(`${url}/upload/${TENANT}/${SLUG}`, { method: "POST", body: form });
152
+ const res = await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form });
85
153
  expect(res.status).toBe(200);
86
154
  const body: any = await res.json();
87
155
  expect(Array.isArray(body)).toBe(false);
@@ -97,7 +165,7 @@ describe("upload — multiple files", () => {
97
165
  form.append("file", pngFile("c.png"));
98
166
  form.append("label", "batch");
99
167
 
100
- const res = await fetch(`${url}/upload/${TENANT}/${SLUG}`, { method: "POST", body: form });
168
+ const res = await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form });
101
169
  expect(res.status).toBe(200);
102
170
 
103
171
  const body: any = await res.json();
@@ -111,7 +179,10 @@ describe("upload — multiple files", () => {
111
179
  for (const id of ids) {
112
180
  const doc: any = await rest.findOne(SLUG, id);
113
181
  expect(doc.label).toBe("batch");
114
- expect(doc._file.url).toContain(`/files/${TENANT}/${SLUG}/`);
182
+ expect(doc._file.url).toContain(`/api/${TENANT}/files/${SLUG}/`);
183
+ // Uploads carry timestamps (required by the replication cursor)
184
+ expect(doc.createdAt).toBeString();
185
+ expect(doc.updatedAt).toBeString();
115
186
  }
116
187
  });
117
188
 
@@ -119,7 +190,7 @@ describe("upload — multiple files", () => {
119
190
  const form = new FormData();
120
191
  form.append("label", "nofile");
121
192
 
122
- const res = await fetch(`${url}/upload/${TENANT}/${SLUG}`, { method: "POST", body: form });
193
+ const res = await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form });
123
194
  expect(res.status).toBe(400);
124
195
  const body: any = await res.json();
125
196
  expect(body.code).toBe("FILE_REQUIRED");
@@ -130,9 +201,71 @@ describe("upload — multiple files", () => {
130
201
  form.append("file", pngFile("ok.png"));
131
202
  form.append("file", new File([Buffer.from("nope")], "bad.exe", { type: "application/x-msdownload" }));
132
203
 
133
- const res = await fetch(`${url}/upload/${TENANT}/${SLUG}`, { method: "POST", body: form });
204
+ const res = await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form });
134
205
  expect(res.status).toBe(400);
135
206
  const body: any = await res.json();
136
207
  expect(body.code).toBe("MIMETYPE_NOT_ALLOWED");
137
208
  });
138
209
  });
210
+
211
+ describe("upload — custom field types (toBson, like collections)", () => {
212
+ it("converts a `date` field to a BSON Date", async () => {
213
+ const form = new FormData();
214
+ form.append("file", pngFile("dated.png"));
215
+ form.append("label", "dated");
216
+ form.append("takenAt", "2024-05-01T10:00:00.000Z");
217
+
218
+ const res = await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form });
219
+ expect(res.status).toBe(200);
220
+ const body: any = await res.json();
221
+
222
+ const raw: any = await rest.db.collection(SLUG).findOne({ _id: new ObjectId(body._id) });
223
+ expect(raw.takenAt).toBeInstanceOf(Date);
224
+ expect(raw.takenAt.toISOString()).toBe("2024-05-01T10:00:00.000Z");
225
+ });
226
+
227
+ it("leaves `_file` untouched, even when a name looks like a date", async () => {
228
+ const form = new FormData();
229
+ form.append("file", pngFile("2024-01-01")); // ← a file literally named like a date
230
+
231
+ const res = await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form });
232
+ expect(res.status).toBe(200);
233
+ const body: any = await res.json();
234
+ expect(body._file.name).toBe("2024-01-01");
235
+
236
+ const raw: any = await rest.db.collection(SLUG).findOne({ _id: new ObjectId(body._id) });
237
+ expect(raw._file.name).toBe("2024-01-01"); // NOT converted to a Date
238
+ expect(typeof raw._file.name).toBe("string");
239
+ });
240
+ });
241
+
242
+ describe("api.files — the in-process facade", () => {
243
+ it("builds a file URL, with the transformations the route understands", () => {
244
+ const api = createApi(rest);
245
+
246
+ expect(api.files.url(SLUG, "f1.png")).toBe(`/api/${TENANT}/files/${SLUG}/f1.png`);
247
+ expect(api.files.url(SLUG, "f1.png", { width: 200, format: "webp" }))
248
+ .toBe(`/api/${TENANT}/files/${SLUG}/f1.png?w=200&format=webp`);
249
+ expect(api.files.url(SLUG, "f1.png", { width: 200, height: 100, format: "avif", quality: 70 }))
250
+ .toBe(`/api/${TENANT}/files/${SLUG}/f1.png?w=200&h=100&format=avif&q=70`);
251
+ });
252
+
253
+ it("deletes the binary and the document — the same path as the route", async () => {
254
+ const form = new FormData();
255
+ form.append("file", pngFile("to-delete.png"));
256
+ const uploaded: any = await (
257
+ await fetch(`${url}/api/${TENANT}/upload/${SLUG}`, { method: "POST", body: form })
258
+ ).json();
259
+
260
+ const fs = await import("node:fs/promises");
261
+ const storage = getFileCollection(SLUG, TENANT)?.storage as any;
262
+ const dir = `${process.cwd()}/${storage?.path ?? "storage"}/${TENANT}/${SLUG}`;
263
+ expect((await fs.readdir(dir)).some((name) => name.startsWith(uploaded._id))).toBe(true);
264
+
265
+ const result = await createApi(rest).files.delete(SLUG, uploaded._id);
266
+
267
+ expect(result).toEqual({ message: "File deleted", ok: true });
268
+ expect(await rest.findOne(SLUG, uploaded._id)).toBeNull();
269
+ expect((await fs.readdir(dir)).some((name) => name.startsWith(uploaded._id))).toBe(false);
270
+ });
271
+ });
@@ -0,0 +1,123 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
+ import { ObjectId } from "mongodb";
3
+ import { createApp } from "../server/hono";
4
+ import { formatConfig, cfg } from "../server/config";
5
+ import { syncTenants } from "../database/tenant";
6
+ import { syncVars } from "../database/vars";
7
+ import { useRest } from "../database/rest";
8
+
9
+ const TENANT = "vars-api";
10
+ const DB = "mongodb://localhost:27017/_VARS_API_TEST";
11
+ const DIR = "packages/core/tests/fixtures/vars-tenant";
12
+
13
+ let rest: InstanceType<typeof useRest>;
14
+ let server: any;
15
+ let url = "";
16
+
17
+ beforeAll(async () => {
18
+ formatConfig({
19
+ server: { port: 4000 },
20
+ tenants: [{ id: TENANT, dir: DIR, database: { uri: DB } }],
21
+ });
22
+ await syncTenants();
23
+ await syncVars();
24
+
25
+ rest = new useRest({ internal: false, tenant_id: TENANT });
26
+ const app = createApp();
27
+ server = Bun.serve({ port: 0, fetch: app.fetch });
28
+ url = server.url.href.replace(/\/$/, "");
29
+ });
30
+
31
+ afterAll(async () => {
32
+ try { await rest.db.dropDatabase(); } catch (_) {}
33
+ try { server.stop(true); } catch (_) {}
34
+ });
35
+
36
+ const post = (action: string, body: any) =>
37
+ fetch(`${url}/api/${TENANT}/vars/${action}`, {
38
+ method: "POST",
39
+ headers: { "Content-Type": "application/json" },
40
+ body: JSON.stringify(body),
41
+ });
42
+
43
+ const json = async (action: string, body: any) => {
44
+ const res = await post(action, body);
45
+ return { status: res.status, body: await res.json() as any };
46
+ };
47
+
48
+ describe("vars HTTP API — access control", () => {
49
+ it("denies a namespace without api.access", async () => {
50
+ const res = await json("get", { ns: "private", key: "secret" });
51
+ expect(res.status).toBe(401);
52
+ expect(res.body.code).toBe("ACCESS_DENIED");
53
+ });
54
+
55
+ it("rejects an unknown namespace", async () => {
56
+ const res = await json("get", { ns: "nope", key: "x" });
57
+ expect(res.status).toBe(400);
58
+ expect(res.body.code).toBe("NAMESPACE_NOT_FOUND");
59
+ });
60
+
61
+ it("requires the namespace", async () => {
62
+ const res = await json("get", { key: "x" });
63
+ expect(res.status).toBe(400);
64
+ expect(res.body.code).toBe("NAMESPACE_REQUIRED");
65
+ });
66
+ });
67
+
68
+ describe("vars HTTP API — CRUD", () => {
69
+ it("set / get / has / del", async () => {
70
+ expect((await json("set", { ns: "config", key: "plan", value: "pro" })).status).toBe(200);
71
+
72
+ const got = await json("get", { ns: "config", key: "plan" });
73
+ expect(got.body.value).toBe("pro");
74
+
75
+ expect((await json("has", { ns: "config", key: "plan" })).body.exists).toBe(true);
76
+ expect((await json("del", { ns: "config", key: "plan" })).body.ok).toBe(true);
77
+ expect((await json("get", { ns: "config", key: "plan" })).body.value).toBeUndefined();
78
+ });
79
+
80
+ it("setMany / all", async () => {
81
+ await json("setMany", { ns: "config", entries: { a: 1, b: 2 } });
82
+ const all = await json("all", { ns: "config" });
83
+ expect(all.body.vars).toMatchObject({ a: 1, b: 2 });
84
+ });
85
+
86
+ it("entry carries the meta", async () => {
87
+ const id = new ObjectId().toHexString();
88
+ await json("set", { ns: "config", key: "lic", value: "L-1", meta: { note: "hi" }, scope: id });
89
+
90
+ const entry = await json("entry", { ns: "config", key: "lic", scope: id });
91
+ expect(entry.body.entry.value).toBe("L-1");
92
+ expect(entry.body.entry.meta).toEqual({ note: "hi" });
93
+ });
94
+
95
+ it("incr", async () => {
96
+ await json("set", { ns: "config", key: "hits", value: 5 });
97
+ expect((await json("incr", { ns: "config", key: "hits" })).body.value).toBe(6);
98
+ });
99
+
100
+ it("scope keeps values isolated", async () => {
101
+ const id = new ObjectId().toHexString();
102
+ await json("set", { ns: "config", key: "lic", value: "SCOPED", scope: id });
103
+ expect((await json("get", { ns: "config", key: "lic", scope: id })).body.value).toBe("SCOPED");
104
+ });
105
+
106
+ it("TTL round-trips as an expiry", async () => {
107
+ await json("set", { ns: "config", key: "tmp", value: "x", ttl: "1h" });
108
+ const entry = await json("entry", { ns: "config", key: "tmp" });
109
+ expect(typeof entry.body.entry.expiresAt).toBe("string");
110
+ expect((await json("expire", { ns: "config", key: "tmp", ttl: "30m" })).body.ok).toBe(true);
111
+ });
112
+
113
+ it("validates the value against its spec", async () => {
114
+ const res = await json("set", { ns: "config", key: "maxUsers", value: "not-a-number" });
115
+ expect(res.status).toBe(400);
116
+ expect(res.body.code).toBe("VALIDATION_ERROR");
117
+ });
118
+
119
+ it("rejects an unknown action", async () => {
120
+ const res = await json("nope", { ns: "config", key: "x" });
121
+ expect(res.status).toBe(400);
122
+ });
123
+ });
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
+ import { MongoClient } from "mongodb";
3
+ import { useRest } from "../database/rest";
4
+ import { formatConfig, cfg } from "../server/config";
5
+ import { syncVars } from "../database/vars";
6
+ import { startReplication, stopReplication, replicateTenant } from "../database/replication";
7
+
8
+ const TEST_TENANT = "vars-repl";
9
+ const SRC_DB = "mongodb://localhost:27017/_VARS_REPL_SRC";
10
+ const DEST_DB = "mongodb://localhost:27017/_VARS_REPL_DEST";
11
+
12
+ let rest: InstanceType<typeof useRest>;
13
+ let sourceClient: MongoClient;
14
+ let destClient: MongoClient;
15
+
16
+ beforeAll(async () => {
17
+ formatConfig({
18
+ server: { port: 4000 },
19
+ tenants: [{
20
+ id: TEST_TENANT,
21
+ dir: "packages/core/tests/fixtures/vars-tenant",
22
+ database: { uri: SRC_DB },
23
+ replication: {
24
+ runOnBoot: false,
25
+ schedule: { interval: "1h" },
26
+ destinations: [{ id: "backup", uri: DEST_DB }],
27
+ },
28
+ }],
29
+ });
30
+
31
+ sourceClient = new MongoClient(SRC_DB, { serverSelectionTimeoutMS: 3000 });
32
+ await sourceClient.connect();
33
+ const tenant: any = cfg.tenants?.[0];
34
+ tenant.database.client = sourceClient;
35
+ tenant.database.db = sourceClient.db();
36
+
37
+ await syncVars();
38
+ await startReplication();
39
+
40
+ rest = new useRest({ tenant_id: TEST_TENANT });
41
+ destClient = new MongoClient(DEST_DB, { serverSelectionTimeoutMS: 3000 });
42
+ await destClient.connect();
43
+ });
44
+
45
+ afterAll(async () => {
46
+ await stopReplication();
47
+ try { await sourceClient.db().dropDatabase(); } catch (_) {}
48
+ try { await destClient.db().dropDatabase(); } catch (_) {}
49
+ try { await destClient.close(); } catch (_) {}
50
+ try { await sourceClient.close(); } catch (_) {}
51
+ });
52
+
53
+ const destVar = (key: string, ns = "config") =>
54
+ destClient.db().collection("_vars_").findOne({ ns, key });
55
+
56
+ describe("vars replication", () => {
57
+ it("replicates the `_vars_` collection (all namespaces)", async () => {
58
+ await rest.vars.set("config", "licence", "REPL-1");
59
+ await replicateTenant(TEST_TENANT);
60
+
61
+ expect((await destVar("licence"))?.value).toBe("REPL-1");
62
+
63
+ // Seeded defaults are replicated too
64
+ expect((await destVar("maxUsers"))?.value).toBe(100);
65
+ });
66
+
67
+ it("propagates variable deletion", async () => {
68
+ await rest.vars.set("config", "tmp", "x", { ttl: "1h" });
69
+ await replicateTenant(TEST_TENANT);
70
+ expect(await destVar("tmp")).toBeDefined();
71
+
72
+ await rest.vars.del("config", "tmp");
73
+ await replicateTenant(TEST_TENANT);
74
+ expect(await destVar("tmp")).toBeNull();
75
+ });
76
+
77
+ it("propagates scoped variables", async () => {
78
+ const mine = "aabbccddeeff001122334455";
79
+ await rest.vars.scope(mine).set("config", "licence", "SCOPED-1");
80
+ await replicateTenant(TEST_TENANT);
81
+
82
+ const dest = await destClient.db().collection("_vars_").findOne({
83
+ ns: "config", key: "licence", scope: { $ne: null },
84
+ });
85
+ expect(dest?.value).toBe("SCOPED-1");
86
+ });
87
+
88
+ it("does NOT replicate namespaces that opt out (`enabled: false`)", async () => {
89
+ // `private.var.ts` declares `replication: { enabled: false }` — it must
90
+ // never reach the destination.
91
+ await rest.vars.set("private", "secret", "top-secret");
92
+ await replicateTenant(TEST_TENANT);
93
+
94
+ expect(await destClient.db().collection("_vars_").findOne({ ns: "private" })).toBeNull();
95
+ });
96
+ });