@anteros/core 0.0.19 → 0.0.21

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 (46) hide show
  1. package/README.md +90 -5
  2. package/database/decorator.ts +1 -1
  3. package/database/mongodbadapter.ts +64 -40
  4. package/index.ts +1 -1
  5. package/lib/define.ts +60 -3
  6. package/lib/files.ts +115 -32
  7. package/lib/mcp.ts +59 -0
  8. package/lib/scriptWorker.ts +76 -0
  9. package/lib/scripts.ts +77 -12
  10. package/package.json +6 -3
  11. package/probe-cache.ts +16 -0
  12. package/server/api.ts +35 -28
  13. package/server/boot.ts +78 -16
  14. package/server/cluster.ts +177 -0
  15. package/server/config.ts +0 -1
  16. package/server/env.ts +6 -0
  17. package/server/hono.ts +192 -18
  18. package/server/mcp.ts +232 -0
  19. package/server/metrics.ts +110 -0
  20. package/server/security.ts +13 -9
  21. package/tests/api.test.ts +14 -12
  22. package/tests/cache.test.ts +124 -0
  23. package/tests/crypto.test.ts +101 -0
  24. package/tests/fixtures/heavy/collections/items.model.ts +8 -0
  25. package/tests/fixtures/heavy/scripts/heavy-import.run.ts +14 -0
  26. package/tests/fixtures/mcp-tenant/mcp/resources/orders.summary.resource.ts +17 -0
  27. package/tests/fixtures/mcp-tenant/mcp/resources/users.id.resource.ts +18 -0
  28. package/tests/fixtures/mcp-tenant/mcp/tools/badge.tool.ts +16 -0
  29. package/tests/fixtures/mcp-tenant/mcp/tools/echo.tool.ts +19 -0
  30. package/tests/fixtures/metrics-worker.ts +21 -0
  31. package/tests/hono.test.ts +211 -0
  32. package/tests/mcp.test.ts +186 -0
  33. package/tests/metrics-cluster.test.ts +103 -0
  34. package/tests/scripts-heavy.test.ts +49 -0
  35. package/tests/security.test.ts +102 -0
  36. package/tests/ts1011-test.ts +15 -0
  37. package/tests/utils.test.ts +113 -0
  38. package/types/api.d.ts +11 -0
  39. package/types/collection.d.ts +20 -5
  40. package/types/config.d.ts +20 -3
  41. package/types/hook.d.ts +16 -13
  42. package/types/mcp.d.ts +81 -0
  43. package/types/scripts.d.ts +14 -1
  44. package/utils/cache.ts +20 -1
  45. package/utils/crypto.ts +25 -8
  46. package/utils/func.ts +178 -18
@@ -0,0 +1,186 @@
1
+ import { describe, it, expect, beforeEach } from "bun:test";
2
+ import { define, v } from "../index";
3
+ import { cfg } from "../server/config";
4
+ import { syncMcpTools } from "../lib/mcp";
5
+ import { createApp } from "../server/hono";
6
+
7
+ const TENANT = "mcp-test";
8
+ const TENANT_DIR = "packages/core/tests/fixtures/mcp-tenant";
9
+
10
+ // @hono/mcp frames every message as Server-Sent Events (MCP Streamable HTTP spec)
11
+ function parseSSE(text: string): any[] {
12
+ const messages: any[] = [];
13
+ let data: string[] = [];
14
+ for (const line of text.split(/\r?\n/)) {
15
+ if (line.startsWith("data:")) {
16
+ data.push(line.slice(5).trim());
17
+ } else if (line === "" && data.length) {
18
+ messages.push(JSON.parse(data.join("\n")));
19
+ data = [];
20
+ }
21
+ }
22
+ if (data.length) messages.push(JSON.parse(data.join("\n")));
23
+ return messages;
24
+ }
25
+
26
+ function resetConfig() {
27
+ cfg.server = { ...cfg.server, rateLimit: undefined, cors: undefined, ipRestriction: undefined, trustProxy: undefined };
28
+ cfg.mcpTools = [];
29
+ cfg.tenants = [];
30
+ }
31
+
32
+ beforeEach(() => {
33
+ resetConfig();
34
+ delete process.env.REDIS_URL;
35
+ delete process.env.REDIS_HOST;
36
+ delete process.env.REDIS_PORT;
37
+ delete process.env.REDIS_PASSWORD;
38
+ });
39
+
40
+ describe("define.McpTool", () => {
41
+ it("marks the tool and enables it by default", () => {
42
+ const tool = define.McpTool({
43
+ name: "greet",
44
+ description: "greets",
45
+ inputSchema: v.object({ name: v.string().required() }),
46
+ exec: async () => ({ content: [{ type: "text", text: "hi" }] }),
47
+ });
48
+ expect(tool._isMcpTool_).toBe(true);
49
+ expect(tool.enabled).toBe(true);
50
+ });
51
+ });
52
+
53
+ describe("syncMcpTools", () => {
54
+ it("loads tools + resources from mcp/tools and mcp/resources", async () => {
55
+ cfg.tenants = [{ id: TENANT, dir: TENANT_DIR, database: { uri: "mongodb://localhost:27017/none" } }];
56
+ await syncMcpTools();
57
+ expect(cfg.mcpTools?.length).toBe(2);
58
+ expect(cfg.mcpTools?.map((t) => t.name).sort()).toEqual(["badge", "echo"]);
59
+ expect(cfg.mcpResources?.length).toBe(2);
60
+ expect(cfg.mcpResources?.map((r) => r.uri).sort()).toEqual(["orders://summary", "users://{id}"]);
61
+ expect(cfg.mcpTools?.[0]?._tenant_).toBe(TENANT);
62
+ });
63
+ });
64
+
65
+ describe("MCP protocol (GET/POST /mcp/:tenant_id)", () => {
66
+ it("serves tools/list and tools/call over JSON-RPC", async () => {
67
+ cfg.tenants = [{ id: TENANT, dir: TENANT_DIR, database: { uri: "mongodb://localhost:27017/none" } }];
68
+ await syncMcpTools();
69
+
70
+ const app = createApp();
71
+ const server = Bun.serve({ port: 0, fetch: app.fetch });
72
+ const url = server.url.href.replace(/\/$/, "");
73
+
74
+ // 1. tools/list
75
+ const listRes = await fetch(`${url}/mcp/${TENANT}`, {
76
+ method: "POST",
77
+ headers: { "Content-Type": "application/json" },
78
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }),
79
+ });
80
+ expect(listRes.status).toBe(200);
81
+ const list = parseSSE(await listRes.text())[0];
82
+ const tools = list?.result?.tools ?? [];
83
+ expect(tools.some((t: any) => t.name === "echo")).toBe(true);
84
+ expect(tools.some((t: any) => t.name === "badge")).toBe(true);
85
+
86
+ // 1b. resources/list — static resources
87
+ const resListRes = await fetch(`${url}/mcp/${TENANT}`, {
88
+ method: "POST",
89
+ headers: { "Content-Type": "application/json" },
90
+ body: JSON.stringify({ jsonrpc: "2.0", id: 9, method: "resources/list", params: {} }),
91
+ });
92
+ const resList = parseSSE(await resListRes.text())[0];
93
+ const resources = resList?.result?.resources ?? [];
94
+ expect(resources.some((r: any) => r.uri === "orders://summary")).toBe(true);
95
+
96
+ // 1c. resources/templates/list
97
+ const tmplRes = await fetch(`${url}/mcp/${TENANT}`, {
98
+ method: "POST",
99
+ headers: { "Content-Type": "application/json" },
100
+ body: JSON.stringify({ jsonrpc: "2.0", id: 10, method: "resources/templates/list", params: {} }),
101
+ });
102
+ const tmplList = parseSSE(await tmplRes.text())[0];
103
+ const templates = tmplList?.result?.resourceTemplates ?? [];
104
+ expect(templates.some((t: any) => t.uriTemplate === "users://{id}")).toBe(true);
105
+
106
+ // 1d. resources/read — static URI
107
+ const readRes = await fetch(`${url}/mcp/${TENANT}`, {
108
+ method: "POST",
109
+ headers: { "Content-Type": "application/json" },
110
+ body: JSON.stringify({ jsonrpc: "2.0", id: 11, method: "resources/read", params: { uri: "orders://summary" } }),
111
+ });
112
+ const read = parseSSE(await readRes.text())[0];
113
+ const readText = read?.result?.contents?.[0]?.text ?? "";
114
+ expect(readText).toContain("\"total\":42");
115
+
116
+ // 1e. resources/read — template URI with params
117
+ const readTplRes = await fetch(`${url}/mcp/${TENANT}`, {
118
+ method: "POST",
119
+ headers: { "Content-Type": "application/json" },
120
+ body: JSON.stringify({ jsonrpc: "2.0", id: 12, method: "resources/read", params: { uri: "users://abc123" } }),
121
+ });
122
+ const readTpl = parseSSE(await readTplRes.text())[0];
123
+ expect(readTpl?.result?.contents?.[0]?.text ?? "").toContain("\"id\":\"abc123\"");
124
+
125
+ // 1f. resources/read — unknown URI → error
126
+ const readBadRes = await fetch(`${url}/mcp/${TENANT}`, {
127
+ method: "POST",
128
+ headers: { "Content-Type": "application/json" },
129
+ body: JSON.stringify({ jsonrpc: "2.0", id: 13, method: "resources/read", params: { uri: "nope://x" } }),
130
+ });
131
+ const readBad = parseSSE(await readBadRes.text())[0];
132
+ expect(readBad?.error).toBeDefined();
133
+
134
+ // 2. tools/call (valid args + Joi defaults)
135
+ const callRes = await fetch(`${url}/mcp/${TENANT}`, {
136
+ method: "POST",
137
+ headers: { "Content-Type": "application/json" },
138
+ body: JSON.stringify({
139
+ jsonrpc: "2.0", id: 2, method: "tools/call",
140
+ params: { name: "echo", arguments: { msg: "hello mcp" } },
141
+ }),
142
+ });
143
+ const call = parseSSE(await callRes.text())[0];
144
+ const block = call?.result?.content?.[0] ?? {};
145
+ expect(block.type).toBe("text");
146
+ expect(block.text ?? "").toContain("hello mcp");
147
+ // Joi default applied (times=1)
148
+ expect(block.text ?? "").toContain("\"times\":1");
149
+
150
+ // 2b. protocol content blocks pass through unchanged (image block)
151
+ const badgeRes = await fetch(`${url}/mcp/${TENANT}`, {
152
+ method: "POST",
153
+ headers: { "Content-Type": "application/json" },
154
+ body: JSON.stringify({
155
+ jsonrpc: "2.0", id: 3, method: "tools/call",
156
+ params: { name: "badge", arguments: {} },
157
+ }),
158
+ });
159
+ const badge = parseSSE(await badgeRes.text())[0];
160
+ expect(badge?.result?.content?.[0]?.type).toBe("image");
161
+ expect(badge?.result?.content?.[0]?.mimeType).toBe("image/png");
162
+
163
+ // 2b. invalid args → isError (Joi validation)
164
+ const badRes = await fetch(`${url}/mcp/${TENANT}`, {
165
+ method: "POST",
166
+ headers: { "Content-Type": "application/json" },
167
+ body: JSON.stringify({
168
+ jsonrpc: "2.0", id: 3, method: "tools/call",
169
+ params: { name: "echo", arguments: { msg: 123 } },
170
+ }),
171
+ });
172
+ const bad = parseSSE(await badRes.text())[0];
173
+ expect(bad?.result?.isError).toBe(true);
174
+ expect(bad?.result?.content?.[0]?.text ?? "").toContain("Invalid arguments");
175
+
176
+ // 3. unknown tenant → 404
177
+ const notFound = await fetch(`${url}/mcp/ghost`, {
178
+ method: "POST",
179
+ headers: { "Content-Type": "application/json" },
180
+ body: JSON.stringify({ jsonrpc: "2.0", id: 4, method: "tools/list", params: {} }),
181
+ });
182
+ expect(notFound.status).toBe(404);
183
+
184
+ server.stop(true);
185
+ });
186
+ });
@@ -0,0 +1,103 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import path from "node:path";
3
+ import { getMetrics, registerMetrics, trackRequest } from "../server/metrics";
4
+ import { startMaster, aggregateMetrics, type MasterMetrics } from "../server/cluster";
5
+
6
+ // ─── metrics ────────────────────────────────────────────────────────────
7
+
8
+ describe("metrics", () => {
9
+ it("returns a snapshot with pid, memory and cpu", () => {
10
+ const m = getMetrics();
11
+ expect(m.pid).toBe(process.pid);
12
+ expect(m.uptime).toBeGreaterThanOrEqual(0);
13
+ expect(typeof m.memory.rss).toBe("number");
14
+ expect(typeof m.cpu.percent).toBe("number");
15
+ expect(m.cpu.cores).toBeGreaterThanOrEqual(1);
16
+ expect(typeof m.requests.total).toBe("number");
17
+ });
18
+
19
+ it("trackRequest counts requests by method and status", () => {
20
+ const before = getMetrics().requests.total;
21
+ trackRequest(new Request("http://localhost/", { method: "POST" }), new Response("ok", { status: 201 }));
22
+ const after = getMetrics().requests.total;
23
+ expect(after).toBe(before + 1);
24
+ expect(getMetrics().requests.byMethod.POST).toBeGreaterThanOrEqual(1);
25
+ expect(getMetrics().requests.byStatus["201"]).toBeGreaterThanOrEqual(1);
26
+ });
27
+
28
+ it("trackRequest counts 5xx as errors", () => {
29
+ const before = getMetrics().requests.errors;
30
+ trackRequest(new Request("http://localhost/x"), new Response("boom", { status: 500 }));
31
+ expect(getMetrics().requests.errors).toBe(before + 1);
32
+ });
33
+
34
+ it("registerMetrics exposes pendingRequests", () => {
35
+ const server = Bun.serve({ port: 0, fetch: () => new Response("ok") });
36
+ registerMetrics(server as any);
37
+ expect(typeof getMetrics().pendingRequests).toBe("number");
38
+ server.stop(true);
39
+ });
40
+ });
41
+
42
+ // ─── aggregateMetrics ───────────────────────────────────────────────────
43
+
44
+ function fakeSnapshot(pid: number, total: number, rss: number): MasterMetrics[number] {
45
+ return {
46
+ pid,
47
+ startedAt: Date.now(),
48
+ uptime: 10,
49
+ env: "test",
50
+ connections: 0,
51
+ pendingRequests: 0,
52
+ pendingWebSockets: 0,
53
+ requests: { total, active: 0, errors: total > 10 ? 1 : 0, byMethod: { GET: total }, byStatus: { "200": total } },
54
+ memory: { rss, heapUsed: rss / 2, heapTotal: rss, external: 0 },
55
+ cpu: { percent: 10, cores: 8, user: 100, system: 50 },
56
+ };
57
+ }
58
+
59
+ describe("aggregateMetrics", () => {
60
+ it("returns an empty aggregate without workers", () => {
61
+ const agg = aggregateMetrics({});
62
+ expect(agg.workers).toBe(0);
63
+ expect(agg.requests.total).toBe(0);
64
+ expect(agg.pids).toEqual([]);
65
+ });
66
+
67
+ it("sums worker snapshots", () => {
68
+ const agg = aggregateMetrics({ 1: fakeSnapshot(1, 5, 1000), 2: fakeSnapshot(2, 5, 1000) });
69
+ expect(agg.workers).toBe(2);
70
+ expect(agg.requests.total).toBe(10);
71
+ expect(agg.memory.rss).toBe(2000);
72
+ expect(agg.cpu.percent).toBe(10);
73
+ expect(agg.pids).toHaveLength(2);
74
+ expect(agg.requests.perSecond).toBe(1);
75
+ });
76
+ });
77
+
78
+ // ─── startMaster (real subprocess + IPC) ────────────────────────────────
79
+
80
+ describe("startMaster", () => {
81
+ it("spawns workers, collects IPC metrics and shutdown kills them", async () => {
82
+ const workerPath = path.join(import.meta.dir, "fixtures", "metrics-worker.ts");
83
+ const master = startMaster({
84
+ workers: 1,
85
+ argv: [process.execPath, workerPath],
86
+ env: { ...(process.env as Record<string, string>) },
87
+ });
88
+
89
+ // Wait for the worker to start and report its metrics
90
+ await new Promise((r) => setTimeout(r, 1500));
91
+
92
+ expect(master.workers.length).toBe(1);
93
+
94
+ const metrics = master.metrics();
95
+ const pids = Object.keys(metrics);
96
+ expect(pids.length).toBe(1);
97
+ expect(metrics[Number(pids[0])]!.requests.total).toBe(7);
98
+
99
+ master.shutdown();
100
+ await new Promise((r) => setTimeout(r, 300));
101
+ expect(master.workers.length).toBe(0);
102
+ }, 10_000);
103
+ });
@@ -0,0 +1,49 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
+ import { MongoClient } from "mongodb";
3
+ import { cfg } from "../server/config";
4
+ import { runScripts } from "../lib/scripts";
5
+
6
+ const DB_URI = "mongodb://localhost:27017/anteros_heavy_test";
7
+ const TENANT_DIR = "packages/core/tests/fixtures/heavy";
8
+
9
+ let mongoAvailable = false;
10
+ let client: MongoClient;
11
+
12
+ beforeAll(async () => {
13
+ // Gracefully skip when MongoDB is not reachable
14
+ try {
15
+ client = new MongoClient(DB_URI, { serverSelectionTimeoutMS: 1500 });
16
+ await client.connect();
17
+ mongoAvailable = true;
18
+ } catch {
19
+ mongoAvailable = false;
20
+ }
21
+ });
22
+
23
+ afterAll(async () => {
24
+ if (mongoAvailable && client) {
25
+ try { await client.db().dropDatabase(); } catch {}
26
+ await client.close();
27
+ }
28
+ });
29
+
30
+ describe("heavy scripts (subprocess)", () => {
31
+ it("runs a heavy:true script in a worker process and persists data", async () => {
32
+ if (!mongoAvailable) {
33
+ console.warn("Skipping: MongoDB unavailable");
34
+ return;
35
+ }
36
+
37
+ cfg.tenants = [{ id: "heavy-test", dir: TENANT_DIR, database: { uri: DB_URI } }];
38
+
39
+ await runScripts();
40
+
41
+ const count = await client.db().collection("items").countDocuments();
42
+ expect(count).toBe(20);
43
+
44
+ const sample: any = await client.db().collection("items").findOne();
45
+ expect(sample.name).toBeString();
46
+ expect(sample.createdAt).toBeInstanceOf(Date);
47
+ expect(sample.updatedAt).toBeInstanceOf(Date);
48
+ }, 30_000);
49
+ });
@@ -0,0 +1,102 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { Hono } from "hono";
3
+ import { createMemoryStore, createRedisStore, rateLimit } from "../server/security";
4
+
5
+ // ─── createMemoryStore ───────────────────────────────────────────────────
6
+
7
+ describe("createMemoryStore", () => {
8
+ it("increments within a window", async () => {
9
+ const store = createMemoryStore();
10
+ expect((await store.increment("k", 60_000)).count).toBe(1);
11
+ expect((await store.increment("k", 60_000)).count).toBe(2);
12
+ store.dispose();
13
+ });
14
+
15
+ it("resets after the window expires", async () => {
16
+ const store = createMemoryStore();
17
+ await store.increment("k", 1);
18
+ expect((await store.increment("k", 1)).count).toBe(2);
19
+ // window (1ms) expired → resets to 1
20
+ await Bun.sleep(5);
21
+ const r = await store.increment("k", 1);
22
+ expect(r.count).toBe(1);
23
+ store.dispose();
24
+ });
25
+ });
26
+
27
+ // ─── createRedisStore (mocked RedisClient) ───────────────────────────────
28
+
29
+ describe("createRedisStore", () => {
30
+ it("uses incr / pexpire / pttl", async () => {
31
+ const calls: string[] = [];
32
+ const mock: any = {
33
+ incr: async (k: string) => { calls.push(`incr:${k}`); return 1; },
34
+ pexpire: async (k: string, ms: number) => { calls.push(`pexpire:${k}:${ms}`); return 1; },
35
+ pttl: async (k: string) => { calls.push(`pttl:${k}`); return 4000; },
36
+ };
37
+ const store = createRedisStore(mock);
38
+ const r = await store.increment("key", 60_000);
39
+ expect(r.count).toBe(1);
40
+ expect(r.resetAt).toBeGreaterThan(Date.now());
41
+ expect(calls).toContain("incr:key");
42
+ expect(calls).toContain("pexpire:key:60000");
43
+ expect(calls).toContain("pttl:key");
44
+ });
45
+
46
+ it("only sets expiry on the first increment of a window", async () => {
47
+ let incrCount = 0;
48
+ let pexpireCalls = 0;
49
+ const mock: any = {
50
+ incr: async () => ++incrCount,
51
+ pexpire: async () => { pexpireCalls++; return 1; },
52
+ pttl: async () => 5000,
53
+ };
54
+ const store = createRedisStore(mock);
55
+ await store.increment("k", 1000); // count=1 → pexpire
56
+ await store.increment("k", 1000); // count=2 → no pexpire
57
+ expect(pexpireCalls).toBe(1);
58
+ });
59
+ });
60
+
61
+ // ─── rateLimit middleware ────────────────────────────────────────────────
62
+
63
+ describe("rateLimit middleware", () => {
64
+ it("adds headers and blocks with 429 + Retry-After", async () => {
65
+ const app = new Hono();
66
+ app.use(rateLimit({ windowMs: 60_000, max: 2 }));
67
+ app.get("/", (c) => c.text("ok"));
68
+
69
+ const r1 = await app.request("/");
70
+ expect(r1.headers.get("X-RateLimit-Limit")).toBe("2");
71
+ expect(r1.headers.get("X-RateLimit-Remaining")).toBe("1");
72
+
73
+ await app.request("/");
74
+ const r3 = await app.request("/");
75
+ expect(r3.status).toBe(429);
76
+ expect(r3.headers.get("Retry-After")).toBeTruthy();
77
+ const body: any = await r3.json();
78
+ expect(body.code).toBe("RATE_LIMIT_EXCEEDED");
79
+ });
80
+
81
+ it("respects a custom keyGenerator", async () => {
82
+ const app = new Hono();
83
+ app.use(rateLimit({ windowMs: 60_000, max: 1, keyGenerator: () => "fixed-key" }));
84
+ app.get("/", (c) => c.text("ok"));
85
+ await app.request("/");
86
+ expect((await app.request("/")).status).toBe(429);
87
+ });
88
+
89
+ it("uses a shared Redis store when provided", async () => {
90
+ const calls: string[] = [];
91
+ const store = createRedisStore({
92
+ incr: async (k: string) => { calls.push(`incr:${k}`); return 5; },
93
+ pexpire: async () => 1,
94
+ pttl: async () => 3000,
95
+ } as any);
96
+ const app = new Hono();
97
+ app.use(rateLimit({ windowMs: 60_000, max: 2, store }));
98
+ app.get("/", (c) => c.text("ok"));
99
+ await app.request("/");
100
+ expect(calls.some((c) => c.startsWith("incr:"))).toBe(true);
101
+ });
102
+ });
@@ -0,0 +1,15 @@
1
+ import type { Document, ChangeStreamOptions, ChangeStream } from "mongodb";
2
+
3
+ export class Test {
4
+ #tenant!: { id: string };
5
+ @Check()
6
+ async watch(collection: string, pipeline: any[], options: ChangeStreamOptions): Promise<ChangeStream> {
7
+ return undefined as any;
8
+ }
9
+ @Check()
10
+ async find(collection: string, params: any = {}, options = {}): Promise<Document[]> {
11
+ return [];
12
+ }
13
+ }
14
+
15
+ function Check() { return (target: any, key: string) => {} }
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect, afterEach } from "bun:test";
2
+ import * as func from "../utils/func";
3
+ import utils from "../utils";
4
+ import { cfg } from "../server/config";
5
+
6
+ afterEach(() => {
7
+ cfg.server.jwt = undefined;
8
+ });
9
+
10
+ describe("omit", () => {
11
+ it("removes keys and regex patterns", () => {
12
+ const out = func.omit({ a: 1, b: 2, password: "x", secret_key: "y" }, ["a", /^secret_/]);
13
+ expect(out).toEqual({ b: 2, password: "x" });
14
+ });
15
+ });
16
+
17
+ describe("pick", () => {
18
+ it("picks simple keys", () => {
19
+ expect(func.pick({ a: 1, b: 2, c: 3 }, ["a", "c"])).toEqual({ a: 1, c: 3 });
20
+ });
21
+ });
22
+
23
+ describe("cleanDeep", () => {
24
+ it("removes null / undefined / empty recursively", () => {
25
+ expect(func.cleanDeep({ a: 1, b: null, c: undefined, d: [], e: {}, f: { g: null } })).toEqual({ a: 1 });
26
+ });
27
+ });
28
+
29
+ describe("clone / deepCopy", () => {
30
+ it("clone deep-copies plain objects", () => {
31
+ const src = { a: { b: [1, 2] } };
32
+ const copy = func.clone(src);
33
+ expect(copy).toEqual(src);
34
+ expect(copy).not.toBe(src);
35
+ copy.a!.b.push(3);
36
+ expect(src.a!.b).toEqual([1, 2]);
37
+ });
38
+
39
+ it("deepCopy preserves Date / Map / Set", () => {
40
+ const src = { d: new Date("2024-01-01"), m: new Map([["k", { v: 1 }]]), s: new Set([1, 2]) };
41
+ const copy = func.deepCopy(src);
42
+ expect(copy.d).toBeInstanceOf(Date);
43
+ expect(copy.m).toBeInstanceOf(Map);
44
+ expect(copy.s).toBeInstanceOf(Set);
45
+ expect((copy.m as Map<string, any>).get("k")).toEqual({ v: 1 });
46
+ });
47
+ });
48
+
49
+ describe("deepEquals", () => {
50
+ it("compares deeply", () => {
51
+ expect(utils.deepEquals({ a: 1, b: { c: [1, 2] } }, { a: 1, b: { c: [1, 2] } })).toBe(true);
52
+ expect(utils.deepEquals({ a: 1 }, { a: 2 })).toBe(false);
53
+ });
54
+ });
55
+
56
+ describe("isSlug / slugify", () => {
57
+ it("isSlug validates the slug pattern", () => {
58
+ expect(func.isSlug("my-product")).toBe(true);
59
+ expect(func.isSlug("My-Product")).toBe(false);
60
+ expect(func.isSlug("")).toBe(false);
61
+ });
62
+
63
+ it("slugify converts text", () => {
64
+ expect(func.slugify("Hello World!")).toBe("hello-world");
65
+ });
66
+ });
67
+
68
+ describe("jwt", () => {
69
+ it("signs and verifies", async () => {
70
+ cfg.server.jwt = { secret: "test-secret" };
71
+ const token = await func.jwt.sign({ sub: "u1", role: "admin" });
72
+ const { value, error } = await func.jwt.verify(token);
73
+ expect(error).toBeNull();
74
+ expect(value).not.toBeNull();
75
+ expect(value?.role).toBe("admin");
76
+ expect(value?.sub).toBe("u1");
77
+ });
78
+
79
+ it("detects expired tokens", async () => {
80
+ cfg.server.jwt = { secret: "s" };
81
+ const token = await func.jwt.sign({ sub: "u1" }, { expiresIn: "1s" });
82
+ await Bun.sleep(1100);
83
+ const { value, expired } = await func.jwt.verify(token);
84
+ expect(value).toBeNull();
85
+ expect(expired).toBe(true);
86
+ }, 5000);
87
+
88
+ it("rejects invalid tokens", async () => {
89
+ cfg.server.jwt = { secret: "s" };
90
+ const { value, error, expired } = await func.jwt.verify("not-a-jwt");
91
+ expect(value).toBeNull();
92
+ expect(error).not.toBeNull();
93
+ expect(expired).toBe(false);
94
+ });
95
+
96
+ it("rejects a token signed with a different secret", async () => {
97
+ cfg.server.jwt = { secret: "secret-a" };
98
+ const token = await func.jwt.sign({ sub: "u1" });
99
+ cfg.server.jwt = { secret: "secret-b" };
100
+ const { value, error } = await func.jwt.verify(token);
101
+ expect(value).toBeNull();
102
+ expect(error).not.toBeNull();
103
+ });
104
+ });
105
+
106
+ describe("password", () => {
107
+ it("hashes and verifies", () => {
108
+ const hash = utils.password.hashSync("hunter2");
109
+ expect(hash).not.toBe("hunter2");
110
+ expect(utils.password.verifySync("hunter2", hash)).toBe(true);
111
+ expect(utils.password.verifySync("wrong", hash)).toBe(false);
112
+ });
113
+ });
package/types/api.d.ts CHANGED
@@ -32,6 +32,17 @@ export type ActionsApiList =
32
32
  | "login"
33
33
  | "logout"
34
34
  | "aggregate"
35
+ | "watch"
36
+ | "countDocuments"
37
+ | "bulkWrite"
38
+ | "bulkUpdate"
39
+ | "replaceOne"
40
+ | "findOneAndUpdate"
41
+ | "dropCollection"
42
+ | "dropIndex"
43
+ | "dropIndexes"
44
+ | "stats"
45
+ | "workflows"
35
46
  | "*"
36
47
 
37
48
 
@@ -7,6 +7,25 @@ import type { Context } from "hono";
7
7
  import type { useRest } from "../database/rest";
8
8
  import type { jwt } from "../utils/func";
9
9
  import type { ActionsApiList, ApiAccess, ApiActions } from "./api";
10
+ import type { Server as SocketIO } from "socket.io";
11
+
12
+ /** Context passed to a collection custom action (`define.Action`). */
13
+ export type CollectionActionContext = {
14
+ rest: InstanceType<typeof useRest>;
15
+ data: any;
16
+ error: typeof fn.error;
17
+ io: SocketIO;
18
+ jwt: typeof jwt;
19
+ token: {
20
+ value: string | null;
21
+ decoded: Record<string, unknown> | null;
22
+ provided: boolean;
23
+ expired: boolean;
24
+ };
25
+ };
26
+
27
+ /** A custom action on a collection — `define.Action(fn)`. */
28
+ export type CollectionAction = (ctx: CollectionActionContext) => Promise<any>;
10
29
  export type Collection = {
11
30
  type?: "document" | "file";
12
31
  slug: string;
@@ -58,11 +77,7 @@ export type Collection = {
58
77
  readOnlyFields?: (string | RegExp)[];
59
78
  };
60
79
  actions?: {
61
- [key: string]: (ctx: {
62
- rest: InstanceType<typeof useRest>;
63
- data: any;
64
- error: typeof fn.error;
65
- }) => Promise<any>;
80
+ [key: string]: CollectionAction;
66
81
  };
67
82
  /**
68
83
  * The tenant id of the collection
package/types/config.d.ts CHANGED
@@ -6,14 +6,21 @@ import type { Route } from "./route";
6
6
  import type { Service } from "./service";
7
7
  import type { Script } from "./scripts";
8
8
  import type { FileCollection } from "./file";
9
+ import type { McpTool, McpResource } from "./mcp";
9
10
  export type ServerConfig = {
10
11
  debug?: boolean;
11
12
  version?: string;
12
- clusterMode?: boolean;
13
13
  server: {
14
14
  name?: string;
15
15
  port: number;
16
- reusePort?: boolean; // override Bun's reusePort (default: clusterMode && not dev)
16
+ /** Bun's SO_REUSEPORT — allows multiple processes to bind the same port. Spawn them yourself. */
17
+ reusePort?: boolean;
18
+ /** Number of worker processes when reusePort is on (default: CPU count) */
19
+ workers?: number;
20
+ /** Master supervision port exposing aggregated /health (default: port + 1) */
21
+ metricsPort?: number;
22
+ /** When true, trusts proxy headers (CF-Connecting-IP / X-Forwarded-For) as the client IP. Default: false. */
23
+ trustProxy?: boolean;
17
24
  body?: {
18
25
  maxSize?: number;
19
26
  };
@@ -35,8 +42,16 @@ export type ServerConfig = {
35
42
  enabled?: boolean;
36
43
  windowMs?: number;
37
44
  max?: number;
38
- /** If true, uses Redis store (requires ioredis client available) */
45
+ /** Redis store override: unset = auto (Redis when `redis` block or REDIS_* env vars are present), false = force in-memory, true = force Redis */
39
46
  useRedis?: boolean;
47
+ /** Redis connection used when useRedis is true (env fallbacks: REDIS_URL / REDIS_HOST / REDIS_PORT / REDIS_PASSWORD) */
48
+ redis?: {
49
+ /** redis:// connection string (takes precedence over host/port/password) */
50
+ url?: string;
51
+ host?: string;
52
+ port?: number;
53
+ password?: string;
54
+ };
40
55
  /** Stricter limits for login endpoints */
41
56
  login?: {
42
57
  windowMs?: number;
@@ -54,4 +69,6 @@ export type Config = ServerConfig & {
54
69
  services?: Service[]
55
70
  scripts?: Script[]
56
71
  fileCollections?: FileCollection[]
72
+ mcpTools?: McpTool[]
73
+ mcpResources?: McpResource[]
57
74
  }