@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.
- package/README.md +90 -5
- package/database/decorator.ts +1 -1
- package/database/mongodbadapter.ts +64 -40
- package/index.ts +1 -1
- package/lib/define.ts +60 -3
- package/lib/files.ts +115 -32
- package/lib/mcp.ts +59 -0
- package/lib/scriptWorker.ts +76 -0
- package/lib/scripts.ts +77 -12
- package/package.json +6 -3
- package/probe-cache.ts +16 -0
- package/server/api.ts +35 -28
- package/server/boot.ts +78 -16
- package/server/cluster.ts +177 -0
- package/server/config.ts +0 -1
- package/server/env.ts +6 -0
- package/server/hono.ts +192 -18
- package/server/mcp.ts +232 -0
- package/server/metrics.ts +110 -0
- package/server/security.ts +13 -9
- package/tests/api.test.ts +14 -12
- package/tests/cache.test.ts +124 -0
- package/tests/crypto.test.ts +101 -0
- package/tests/fixtures/heavy/collections/items.model.ts +8 -0
- package/tests/fixtures/heavy/scripts/heavy-import.run.ts +14 -0
- package/tests/fixtures/mcp-tenant/mcp/resources/orders.summary.resource.ts +17 -0
- package/tests/fixtures/mcp-tenant/mcp/resources/users.id.resource.ts +18 -0
- package/tests/fixtures/mcp-tenant/mcp/tools/badge.tool.ts +16 -0
- package/tests/fixtures/mcp-tenant/mcp/tools/echo.tool.ts +19 -0
- package/tests/fixtures/metrics-worker.ts +21 -0
- package/tests/hono.test.ts +211 -0
- package/tests/mcp.test.ts +186 -0
- package/tests/metrics-cluster.test.ts +103 -0
- package/tests/scripts-heavy.test.ts +49 -0
- package/tests/security.test.ts +102 -0
- package/tests/ts1011-test.ts +15 -0
- package/tests/utils.test.ts +113 -0
- package/types/api.d.ts +11 -0
- package/types/collection.d.ts +20 -5
- package/types/config.d.ts +20 -3
- package/types/hook.d.ts +16 -13
- package/types/mcp.d.ts +81 -0
- package/types/scripts.d.ts +14 -1
- package/utils/cache.ts +20 -1
- package/utils/crypto.ts +25 -8
- package/utils/func.ts +178 -18
package/tests/api.test.ts
CHANGED
|
@@ -11,6 +11,7 @@ let rest: InstanceType<typeof useRest>;
|
|
|
11
11
|
|
|
12
12
|
beforeAll(async () => {
|
|
13
13
|
formatConfig({
|
|
14
|
+
server: { port: 4000 },
|
|
14
15
|
tenants: [{ id: TEST_TENANT, dir: "src", database: { uri: TEST_DB } }],
|
|
15
16
|
});
|
|
16
17
|
await syncTenants();
|
|
@@ -45,7 +46,7 @@ describe("insertOne", () => {
|
|
|
45
46
|
expect(doc._id.length).toBe(24);
|
|
46
47
|
});
|
|
47
48
|
it("sets createdAt and updatedAt", async () => {
|
|
48
|
-
const doc = await rest.insertOne("items", { title: "ts" });
|
|
49
|
+
const doc: any = await rest.insertOne("items", { title: "ts" });
|
|
49
50
|
expect(doc.createdAt).toBeString();
|
|
50
51
|
expect(doc.updatedAt).toBeString();
|
|
51
52
|
});
|
|
@@ -84,8 +85,8 @@ describe("findOne", () => {
|
|
|
84
85
|
|
|
85
86
|
describe("updateOne", () => {
|
|
86
87
|
it("$set title and changes updatedAt", async () => {
|
|
87
|
-
const doc = await rest.insertOne("items", { title: "old" });
|
|
88
|
-
const upd = await rest.updateOne("items", doc._id, { $set: { title: "new" } });
|
|
88
|
+
const doc: any = await rest.insertOne("items", { title: "old" });
|
|
89
|
+
const upd: any = await rest.updateOne("items", doc._id, { $set: { title: "new" } });
|
|
89
90
|
expect(upd.title).toBe("new");
|
|
90
91
|
expect(upd.updatedAt).not.toBe(doc.updatedAt);
|
|
91
92
|
});
|
|
@@ -93,17 +94,18 @@ describe("updateOne", () => {
|
|
|
93
94
|
|
|
94
95
|
describe("deleteOne", () => {
|
|
95
96
|
it("deletes and returns _id", async () => {
|
|
96
|
-
const doc = await rest.insertOne("items", { title: "del-me" });
|
|
97
|
-
|
|
97
|
+
const doc: any = await rest.insertOne("items", { title: "del-me" });
|
|
98
|
+
const deleted: any = await rest.deleteOne("items", doc._id);
|
|
99
|
+
expect(deleted?._id).toBe(doc._id);
|
|
98
100
|
});
|
|
99
101
|
});
|
|
100
102
|
|
|
101
103
|
describe("insertMany", () => {
|
|
102
104
|
it("inserts and returns array with _id", async () => {
|
|
103
|
-
const docs = await rest.insertMany("items", [{ title: "m1" }, { title: "m2" }]);
|
|
105
|
+
const docs: any[] = await rest.insertMany("items", [{ title: "m1" }, { title: "m2" }]);
|
|
104
106
|
expect(docs.length).toBe(2);
|
|
105
|
-
expect(docs[0]
|
|
106
|
-
expect(docs[1]
|
|
107
|
+
expect(docs[0]!._id).toBeString();
|
|
108
|
+
expect(docs[1]!._id).toBeString();
|
|
107
109
|
});
|
|
108
110
|
});
|
|
109
111
|
|
|
@@ -173,7 +175,7 @@ describe("bulkUpdate", () => {
|
|
|
173
175
|
|
|
174
176
|
describe("stats", () => {
|
|
175
177
|
it("returns collection stats", async () => {
|
|
176
|
-
const s = await rest.stats(
|
|
178
|
+
const s: any = await rest.stats();
|
|
177
179
|
expect(s).toBeDefined();
|
|
178
180
|
expect(typeof s).toBe("object");
|
|
179
181
|
});
|
|
@@ -184,7 +186,7 @@ describe("audit", () => {
|
|
|
184
186
|
await rest.audit.addActivities([{
|
|
185
187
|
internal: false,
|
|
186
188
|
trace: { id: crypto.randomUUID() },
|
|
187
|
-
request:
|
|
189
|
+
request: undefined,
|
|
188
190
|
meta: {},
|
|
189
191
|
operation: {
|
|
190
192
|
tenant: TEST_TENANT,
|
|
@@ -199,7 +201,7 @@ describe("audit", () => {
|
|
|
199
201
|
},
|
|
200
202
|
ts: new Date(),
|
|
201
203
|
}]);
|
|
202
|
-
const activities = await rest.audit.getActivities({ $limit: 10 });
|
|
204
|
+
const activities = await rest.audit.getActivities({ $match: {}, $limit: 10 });
|
|
203
205
|
expect(Array.isArray(activities)).toBe(true);
|
|
204
206
|
expect(activities.length).toBeGreaterThanOrEqual(1);
|
|
205
207
|
});
|
|
@@ -266,7 +268,7 @@ describe("transactions", () => {
|
|
|
266
268
|
|
|
267
269
|
describe("watch", () => {
|
|
268
270
|
it("returns a change stream", async () => {
|
|
269
|
-
const stream = await rest.watch("items", [{ $match: { operationType: "insert" } }]);
|
|
271
|
+
const stream = await rest.watch("items", [{ $match: { operationType: "insert" } }], {} as any);
|
|
270
272
|
expect(stream).toBeDefined();
|
|
271
273
|
await stream.close();
|
|
272
274
|
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
2
|
+
import { Cache, useMemoryCache, useFilesystemCache } from "../utils/cache";
|
|
3
|
+
|
|
4
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
5
|
+
|
|
6
|
+
describe("Cache (memory)", () => {
|
|
7
|
+
let c: Cache;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
c = useMemoryCache();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("set + get round-trip", async () => {
|
|
14
|
+
await c.set({ key: "k", value: { id: 1 }, ttl: "5m" });
|
|
15
|
+
expect(await c.get({ key: "k" })).toEqual({ id: 1 });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("setForever never expires", async () => {
|
|
19
|
+
await c.setForever({ key: "cfg", value: { theme: "dark" } });
|
|
20
|
+
expect(await c.get({ key: "cfg" })).toEqual({ theme: "dark" });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("get returns null on miss", async () => {
|
|
24
|
+
expect(await c.get({ key: "nope" })).toBeUndefined();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("has / missing", async () => {
|
|
28
|
+
await c.set({ key: "a", value: 1 });
|
|
29
|
+
expect(await c.has({ key: "a" })).toBe(true);
|
|
30
|
+
expect(await c.missing({ key: "a" })).toBe(false);
|
|
31
|
+
expect(await c.missing({ key: "b" })).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("ttl expiry (ms)", async () => {
|
|
35
|
+
await c.set({ key: "x", value: 1, ttl: 50 });
|
|
36
|
+
expect(await c.get({ key: "x" })).toBe(1);
|
|
37
|
+
await sleep(80);
|
|
38
|
+
expect(await c.get({ key: "x" })).toBeUndefined();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("getOrSet uses factory and caches", async () => {
|
|
42
|
+
let calls = 0;
|
|
43
|
+
const factory = async () => {
|
|
44
|
+
calls++;
|
|
45
|
+
return { n: calls };
|
|
46
|
+
};
|
|
47
|
+
expect(await c.getOrSet({ key: "g", factory })).toEqual({ n: 1 });
|
|
48
|
+
expect(await c.getOrSet({ key: "g", factory })).toEqual({ n: 1 });
|
|
49
|
+
expect(calls).toBe(1);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("getOrSetForever", async () => {
|
|
53
|
+
const value = await c.getOrSetForever({ key: "f", factory: () => 42 });
|
|
54
|
+
expect(value).toBe(42);
|
|
55
|
+
expect(await c.get({ key: "f" })).toBe(42);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("delete removes entry", async () => {
|
|
59
|
+
await c.set({ key: "d", value: 1 });
|
|
60
|
+
await c.delete({ key: "d" });
|
|
61
|
+
expect(await c.get({ key: "d" })).toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("deleteMany removes several keys", async () => {
|
|
65
|
+
await c.set({ key: "d1", value: 1 });
|
|
66
|
+
await c.set({ key: "d2", value: 2 });
|
|
67
|
+
await c.deleteMany({ keys: ["d1", "d2"] });
|
|
68
|
+
expect(await c.missing({ key: "d1" })).toBe(true);
|
|
69
|
+
expect(await c.missing({ key: "d2" })).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("pull returns and removes", async () => {
|
|
73
|
+
await c.set({ key: "p", value: 7 });
|
|
74
|
+
expect(await c.pull("p")).toBe(7);
|
|
75
|
+
expect(await c.get({ key: "p" })).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("clear empties everything", async () => {
|
|
79
|
+
await c.set({ key: "a", value: 1 });
|
|
80
|
+
await c.set({ key: "b", value: 2 });
|
|
81
|
+
await c.clear();
|
|
82
|
+
expect(await c.get({ key: "a" })).toBeUndefined();
|
|
83
|
+
expect(await c.get({ key: "b" })).toBeUndefined();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("getOrSet recovers after ttl expiry (grace accepted)", async () => {
|
|
87
|
+
// miss → factory
|
|
88
|
+
const v1 = await c.getOrSet({ key: "e", factory: () => "fresh", ttl: 30, grace: 500 });
|
|
89
|
+
expect(v1).toBe("fresh");
|
|
90
|
+
|
|
91
|
+
await sleep(60); // past ttl
|
|
92
|
+
expect(await c.get({ key: "e" })).toBeUndefined(); // expired
|
|
93
|
+
|
|
94
|
+
// getOrSet re-runs the factory after expiry
|
|
95
|
+
const v2 = await c.getOrSet({ key: "e", factory: () => "fresh2" });
|
|
96
|
+
expect(v2).toBe("fresh2");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("namespace groups keys", async () => {
|
|
100
|
+
const users = c.namespace("users");
|
|
101
|
+
await users.set({ key: "1", value: { name: "John" }, ttl: "5m" });
|
|
102
|
+
await users.set({ key: "2", value: { name: "Jane" } });
|
|
103
|
+
expect(await users.get({ key: "1" })).toEqual({ name: "John" });
|
|
104
|
+
expect(await c.get({ key: "1" })).toBeUndefined(); // not in root
|
|
105
|
+
await users.clear();
|
|
106
|
+
expect(await users.missing({ key: "1" })).toBe(true);
|
|
107
|
+
expect(await users.missing({ key: "2" })).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("Cache (filesystem)", () => {
|
|
112
|
+
it("persists across instances", async () => {
|
|
113
|
+
const dir = "/tmp/anteros-cache-test";
|
|
114
|
+
const a = useFilesystemCache({ directory: dir });
|
|
115
|
+
await a.set({ key: "persist", value: { ok: true }, ttl: "10m" });
|
|
116
|
+
await a.disconnect();
|
|
117
|
+
|
|
118
|
+
const b = useFilesystemCache({ directory: dir });
|
|
119
|
+
expect(await b.get({ key: "persist" })).toEqual({ ok: true });
|
|
120
|
+
await b.delete({ key: "persist" });
|
|
121
|
+
expect(await b.get({ key: "persist" })).toBeUndefined();
|
|
122
|
+
await b.disconnect();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { useSymCrypt, useAsymCrypt } from "../utils/crypto";
|
|
3
|
+
|
|
4
|
+
describe("crypto — symmetric AES-256-GCM", () => {
|
|
5
|
+
it("round-trips a string", async () => {
|
|
6
|
+
const c = useSymCrypt({ secret: "test-secret" });
|
|
7
|
+
const enc = await c.encrypt("hello world");
|
|
8
|
+
const dec = await c.decrypt(enc);
|
|
9
|
+
expect(dec).toBe("hello world");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("round-trips an object (parseJson)", async () => {
|
|
13
|
+
const c = useSymCrypt({ secret: "s" });
|
|
14
|
+
const enc = await c.encrypt({ a: 1, b: "x", nested: { ok: true } });
|
|
15
|
+
const dec = await c.decrypt<{ a: number; b: string; nested: { ok: boolean } }>(enc, true);
|
|
16
|
+
expect(dec).toEqual({ a: 1, b: "x", nested: { ok: true } });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("produces different ciphertexts each time (random IV)", async () => {
|
|
20
|
+
const c = useSymCrypt({ secret: "s" });
|
|
21
|
+
const a = await c.encrypt("same");
|
|
22
|
+
const b = await c.encrypt("same");
|
|
23
|
+
expect(a).not.toBe(b);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("rejects tampered ciphertext (AES-GCM tag)", async () => {
|
|
27
|
+
const c = useSymCrypt({ secret: "s" });
|
|
28
|
+
const enc = await c.encrypt("data");
|
|
29
|
+
const buf = Buffer.from(enc, "base64");
|
|
30
|
+
buf[buf.length - 1]! ^= 0xff; // corrupt the tag
|
|
31
|
+
await expect(c.decrypt(buf.toString("base64"))).rejects.toThrow();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("cannot decrypt with a different secret", async () => {
|
|
35
|
+
const c1 = useSymCrypt({ secret: "one" });
|
|
36
|
+
const c2 = useSymCrypt({ secret: "two" });
|
|
37
|
+
const enc = await c1.encrypt("secret");
|
|
38
|
+
await expect(c2.decrypt(enc)).rejects.toThrow();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("binds ciphertext to its context via AAD (substitution-proof)", async () => {
|
|
42
|
+
const c = useSymCrypt({ secret: "s" });
|
|
43
|
+
// Alice encrypts with her context
|
|
44
|
+
const enc = await c.encrypt({ apiKey: "sk-alice" }, "users:alice:apiKey");
|
|
45
|
+
|
|
46
|
+
// Same context → OK
|
|
47
|
+
const dec = await c.decrypt<{ apiKey: string }>(enc, true, "users:alice:apiKey");
|
|
48
|
+
expect(dec.apiKey).toBe("sk-alice");
|
|
49
|
+
|
|
50
|
+
// Swapped into Bob's context (substitution) → rejected
|
|
51
|
+
await expect(c.decrypt(enc, true, "users:bob:apiKey")).rejects.toThrow();
|
|
52
|
+
// Missing context → rejected
|
|
53
|
+
await expect(c.decrypt(enc, true)).rejects.toThrow();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("rejects truncated ciphertext with a clear error", async () => {
|
|
57
|
+
const c = useSymCrypt({ secret: "s" });
|
|
58
|
+
await expect(c.decrypt("YWJjZA==")).rejects.toThrow();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("crypto — asymmetric RSA-OAEP envelope", () => {
|
|
63
|
+
it("round-trips via exportPrivateKey / reuse", async () => {
|
|
64
|
+
const a = useAsymCrypt();
|
|
65
|
+
const enc = await a.encrypt({ secret: 42, list: [1, 2] });
|
|
66
|
+
const priv = await a.exportPrivateKey();
|
|
67
|
+
const b = useAsymCrypt({ privateKey: priv });
|
|
68
|
+
const dec = await b.decrypt<{ secret: number; list: number[] }>(enc, true);
|
|
69
|
+
expect(dec).toEqual({ secret: 42, list: [1, 2] });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("encrypts with an external public key and decrypts with the private key", async () => {
|
|
73
|
+
const a = useAsymCrypt();
|
|
74
|
+
const pubJwk = await a.exportPublicKey();
|
|
75
|
+
const pubKey = await a.importPublicKey(pubJwk);
|
|
76
|
+
const enc = await a.encrypt("external-msg", pubKey);
|
|
77
|
+
const dec = await a.decrypt(enc);
|
|
78
|
+
expect(dec).toBe("external-msg");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("rejects tampered ciphertext", async () => {
|
|
82
|
+
const a = useAsymCrypt();
|
|
83
|
+
const enc = await a.encrypt("data");
|
|
84
|
+
const buf = Buffer.from(enc, "base64");
|
|
85
|
+
buf[0]! ^= 0xff; // corrupt the wrapped key
|
|
86
|
+
await expect(a.decrypt(buf.toString("base64"))).rejects.toThrow();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("binds ciphertext to its context via AAD (asym)", async () => {
|
|
90
|
+
const a = useAsymCrypt();
|
|
91
|
+
const enc = await a.encrypt({ doc: "sensitive" }, undefined, "tenant:acme:record:42");
|
|
92
|
+
|
|
93
|
+
const ok = await a.decrypt<{ doc: string }>(enc, true, "tenant:acme:record:42");
|
|
94
|
+
expect(ok.doc).toBe("sensitive");
|
|
95
|
+
|
|
96
|
+
// Wrong context → rejected
|
|
97
|
+
await expect(a.decrypt(enc, true, "tenant:acme:record:43")).rejects.toThrow();
|
|
98
|
+
// Missing context → rejected
|
|
99
|
+
await expect(a.decrypt(enc, true)).rejects.toThrow();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
_isScript_: true,
|
|
3
|
+
enabled: true,
|
|
4
|
+
heavy: true,
|
|
5
|
+
exec: async ({ rest, progress }: any) => {
|
|
6
|
+
const items = Array.from({ length: 10 }, (_, i) => ({ name: `item-${i}` }));
|
|
7
|
+
await rest.insertMany('items', items);
|
|
8
|
+
progress?.(50, { inserted: 10 });
|
|
9
|
+
|
|
10
|
+
await rest.insertMany('items', items.map((it) => ({ ...it, name: it.name + '-b' })));
|
|
11
|
+
progress?.(100, { inserted: 20 });
|
|
12
|
+
return { ok: true, total: 20 };
|
|
13
|
+
},
|
|
14
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { define } from "../../../../../index";
|
|
2
|
+
|
|
3
|
+
// Static resource — fixed URI
|
|
4
|
+
export default define.McpResource({
|
|
5
|
+
name: "orders-summary",
|
|
6
|
+
description: "Summary of orders",
|
|
7
|
+
uri: "orders://summary",
|
|
8
|
+
read: async ({ params }) => {
|
|
9
|
+
return {
|
|
10
|
+
contents: [{
|
|
11
|
+
uri: "orders://summary",
|
|
12
|
+
mimeType: "application/json",
|
|
13
|
+
text: JSON.stringify({ total: 42, pending: 7, params }),
|
|
14
|
+
}],
|
|
15
|
+
};
|
|
16
|
+
},
|
|
17
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { define, v } from "../../../../../index";
|
|
2
|
+
|
|
3
|
+
// Template resource — dynamic URI (params extracted from the URI)
|
|
4
|
+
export default define.McpResource({
|
|
5
|
+
name: "users-by-id",
|
|
6
|
+
description: "Fetch a user by id",
|
|
7
|
+
uri: "users://{id}",
|
|
8
|
+
mimeType: "application/json",
|
|
9
|
+
read: async ({ params, uri }) => {
|
|
10
|
+
return {
|
|
11
|
+
contents: [{
|
|
12
|
+
uri,
|
|
13
|
+
mimeType: "application/json",
|
|
14
|
+
text: JSON.stringify({ id: params.id, name: `User ${params.id}` }),
|
|
15
|
+
}],
|
|
16
|
+
};
|
|
17
|
+
},
|
|
18
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { define, v } from "../../../../../index";
|
|
2
|
+
|
|
3
|
+
export default define.McpTool({
|
|
4
|
+
name: "badge",
|
|
5
|
+
description: "Returns an image content block (protocol passthrough)",
|
|
6
|
+
inputSchema: v.object({}),
|
|
7
|
+
exec: async () => {
|
|
8
|
+
return {
|
|
9
|
+
content: [{
|
|
10
|
+
type: "image",
|
|
11
|
+
data: "aGVsbG8=", // "hello" base64
|
|
12
|
+
mimeType: "image/png",
|
|
13
|
+
}],
|
|
14
|
+
};
|
|
15
|
+
},
|
|
16
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { define, v } from "../../../../../index";
|
|
2
|
+
|
|
3
|
+
export default define.McpTool({
|
|
4
|
+
name: "echo",
|
|
5
|
+
description: "Echoes the message back to the caller",
|
|
6
|
+
inputSchema: v.object({
|
|
7
|
+
msg: v.string().required().description("The message to echo"),
|
|
8
|
+
times: v.number().integer().min(1).max(5).default(1),
|
|
9
|
+
}),
|
|
10
|
+
exec: async ({ args }) => {
|
|
11
|
+
// Protocol format: { content: [...] }
|
|
12
|
+
return {
|
|
13
|
+
content: [{
|
|
14
|
+
type: "text",
|
|
15
|
+
text: JSON.stringify({ echoed: args.msg, times: args.times, at: new Date().toISOString() }),
|
|
16
|
+
}],
|
|
17
|
+
};
|
|
18
|
+
},
|
|
19
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Fixture: minimal worker that reports a fake metrics snapshot over IPC,
|
|
2
|
+
// then stays alive so the master can supervise it.
|
|
3
|
+
const payload = {
|
|
4
|
+
type: "metrics",
|
|
5
|
+
pid: process.pid,
|
|
6
|
+
metrics: {
|
|
7
|
+
pid: process.pid,
|
|
8
|
+
startedAt: Date.now(),
|
|
9
|
+
uptime: 3,
|
|
10
|
+
env: "test",
|
|
11
|
+
connections: 0,
|
|
12
|
+
pendingRequests: 0,
|
|
13
|
+
pendingWebSockets: 0,
|
|
14
|
+
requests: { total: 7, active: 0, errors: 1, byMethod: { GET: 5, POST: 2 }, byStatus: { "200": 6, "500": 1 } },
|
|
15
|
+
memory: { rss: 1024, heapUsed: 512, heapTotal: 1024, external: 64 },
|
|
16
|
+
cpu: { percent: 12.5, cores: 8, user: 100, system: 50 },
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
process.send?.(payload);
|
|
21
|
+
setInterval(() => {}, 1000); // stay alive until killed
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { createApp } from "../server/hono";
|
|
3
|
+
import { cfg } from "../server/config";
|
|
4
|
+
import { AppError } from "../lib/error";
|
|
5
|
+
import { HTTPException } from "hono/http-exception";
|
|
6
|
+
|
|
7
|
+
const base = (server: any) => server.url.href.replace(/\/$/, "");
|
|
8
|
+
|
|
9
|
+
function resetServerConfig() {
|
|
10
|
+
cfg.server = {
|
|
11
|
+
...cfg.server,
|
|
12
|
+
cors: undefined,
|
|
13
|
+
ipRestriction: undefined,
|
|
14
|
+
trustProxy: undefined,
|
|
15
|
+
rateLimit: undefined,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
resetServerConfig();
|
|
21
|
+
delete process.env.REDIS_URL;
|
|
22
|
+
delete process.env.REDIS_HOST;
|
|
23
|
+
delete process.env.REDIS_PORT;
|
|
24
|
+
delete process.env.REDIS_PASSWORD;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
resetServerConfig();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// ─── Error handling (C2) ─────────────────────────────────────────────────
|
|
32
|
+
// Note: these use a real Bun.serve — ipRestriction returns 403 when there is
|
|
33
|
+
// no real socket address (app.request() without a server).
|
|
34
|
+
|
|
35
|
+
async function bootAppWith(register: (app: any) => void) {
|
|
36
|
+
const app = createApp();
|
|
37
|
+
register(app);
|
|
38
|
+
const server = Bun.serve({ port: 0, fetch: app.fetch });
|
|
39
|
+
return { url: `${base(server)}`, server };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("onError", () => {
|
|
43
|
+
it("maps AppError to its status + code + message", async () => {
|
|
44
|
+
const { url, server } = await bootAppWith((app) => {
|
|
45
|
+
app.get("/app-error", () => {
|
|
46
|
+
throw new AppError("Validation failed", { status: 400, code: "VALIDATION_ERROR" });
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
const res = await fetch(`${url}/app-error`);
|
|
50
|
+
expect(res.status).toBe(400);
|
|
51
|
+
const body: any = await res.json();
|
|
52
|
+
expect(body.code).toBe("VALIDATION_ERROR");
|
|
53
|
+
expect(body.message).toBe("Validation failed");
|
|
54
|
+
server.stop(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("lets HTTPException respond with its own status", async () => {
|
|
58
|
+
const { url, server } = await bootAppWith((app) => {
|
|
59
|
+
app.get("/exc", () => {
|
|
60
|
+
throw new HTTPException(401, { message: "Unauthorized" });
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
const res = await fetch(`${url}/exc`);
|
|
64
|
+
expect(res.status).toBe(401);
|
|
65
|
+
server.stop(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("returns 500 INTERNAL_SERVER_ERROR for unexpected errors", async () => {
|
|
69
|
+
const { url, server } = await bootAppWith((app) => {
|
|
70
|
+
app.get("/crash", () => {
|
|
71
|
+
throw new Error("boom");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
const res = await fetch(`${url}/crash`);
|
|
75
|
+
expect(res.status).toBe(500);
|
|
76
|
+
const body: any = await res.json();
|
|
77
|
+
expect(body.code).toBe("INTERNAL_SERVER_ERROR");
|
|
78
|
+
server.stop(true);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ─── CORS (C1) ───────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
describe("CORS origin", () => {
|
|
85
|
+
it("throws on credentials + wildcard", () => {
|
|
86
|
+
cfg.server.cors = { origin: "*", credentials: true };
|
|
87
|
+
expect(() => createApp()).toThrow(/CORS misconfiguration/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("accepts a string origin with credentials", () => {
|
|
91
|
+
cfg.server.cors = { origin: "https://example.com", credentials: true };
|
|
92
|
+
expect(() => createApp()).not.toThrow();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("accepts a string[] origin", () => {
|
|
96
|
+
cfg.server.cors = { origin: ["https://a.com", "https://b.com"], credentials: true };
|
|
97
|
+
expect(() => createApp()).not.toThrow();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("accepts a function origin", () => {
|
|
101
|
+
cfg.server.cors = {
|
|
102
|
+
origin: (ctx) => (ctx.origin === "https://ok.com" ? "https://ok.com" : "https://fallback.com"),
|
|
103
|
+
credentials: true,
|
|
104
|
+
};
|
|
105
|
+
expect(() => createApp()).not.toThrow();
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ─── Client IP / trustProxy (S1) ─────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
describe("client IP resolution", () => {
|
|
112
|
+
it("ignores proxy headers by default (spoof-proof)", async () => {
|
|
113
|
+
const app = createApp();
|
|
114
|
+
app.get("/echo-ip", (c) => c.json({ ip: c.get("clientIp") }));
|
|
115
|
+
const server = Bun.serve({ port: 0, fetch: app.fetch });
|
|
116
|
+
|
|
117
|
+
cfg.server.trustProxy = false;
|
|
118
|
+
const res = await fetch(`${base(server)}/echo-ip`, { headers: { "X-Forwarded-For": "1.2.3.4" } });
|
|
119
|
+
const body: any = await res.json();
|
|
120
|
+
expect(body.ip).not.toBe("1.2.3.4");
|
|
121
|
+
|
|
122
|
+
server.stop(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("uses the header when trustProxy is enabled", async () => {
|
|
126
|
+
const app = createApp();
|
|
127
|
+
app.get("/echo-ip", (c) => c.json({ ip: c.get("clientIp") }));
|
|
128
|
+
const server = Bun.serve({ port: 0, fetch: app.fetch });
|
|
129
|
+
|
|
130
|
+
cfg.server.trustProxy = true;
|
|
131
|
+
const res = await fetch(`${base(server)}/echo-ip`, { headers: { "X-Forwarded-For": "1.2.3.4" } });
|
|
132
|
+
const body: any = await res.json();
|
|
133
|
+
expect(body.ip).toBe("1.2.3.4");
|
|
134
|
+
|
|
135
|
+
server.stop(true);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ─── Health ──────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
describe("GET /health", () => {
|
|
142
|
+
it("returns metrics + client ip", async () => {
|
|
143
|
+
const { url, server } = await bootAppWith(() => {});
|
|
144
|
+
const res = await fetch(`${url}/health`);
|
|
145
|
+
expect(res.status).toBe(200);
|
|
146
|
+
const body: any = await res.json();
|
|
147
|
+
expect(typeof body.pid).toBe("number");
|
|
148
|
+
expect(typeof body.ip).toBe("string");
|
|
149
|
+
expect(body.requests).toBeDefined();
|
|
150
|
+
expect(body.memory).toBeDefined();
|
|
151
|
+
expect(body.cpu).toBeDefined();
|
|
152
|
+
server.stop(true);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// ─── Rate limiting ───────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
describe("rate limiting (in-memory)", () => {
|
|
159
|
+
it("serves X-RateLimit-* headers and blocks after max", async () => {
|
|
160
|
+
cfg.server.rateLimit = { enabled: true, windowMs: 60_000, max: 3 };
|
|
161
|
+
const app = createApp();
|
|
162
|
+
app.get("/echo", (c) => c.json({ ok: true }));
|
|
163
|
+
const server = Bun.serve({ port: 0, fetch: app.fetch });
|
|
164
|
+
const url = `${base(server)}/echo`;
|
|
165
|
+
|
|
166
|
+
const statuses: number[] = [];
|
|
167
|
+
let first: Response | undefined;
|
|
168
|
+
for (let i = 0; i < 4; i++) {
|
|
169
|
+
const r = await fetch(url);
|
|
170
|
+
if (i === 0) first = r;
|
|
171
|
+
statuses.push(r.status);
|
|
172
|
+
}
|
|
173
|
+
expect(statuses).toEqual([200, 200, 200, 429]);
|
|
174
|
+
|
|
175
|
+
// headers present on the first request
|
|
176
|
+
expect(first!.headers.get("X-RateLimit-Limit")).toBe("3");
|
|
177
|
+
expect(first!.headers.get("X-RateLimit-Remaining")).toBe("2");
|
|
178
|
+
|
|
179
|
+
server.stop(true);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("login limiter uses a distinct counter (not blocked by global)", async () => {
|
|
183
|
+
cfg.server.rateLimit = { enabled: true, windowMs: 60_000, max: 100, login: { max: 2 } };
|
|
184
|
+
const app = createApp();
|
|
185
|
+
app.post("/api/x/login", (c) => c.json({ ok: true }));
|
|
186
|
+
app.get("/echo", (c) => c.json({ ok: true }));
|
|
187
|
+
const server = Bun.serve({ port: 0, fetch: app.fetch });
|
|
188
|
+
const url = `${base(server)}`;
|
|
189
|
+
|
|
190
|
+
const statuses: number[] = [];
|
|
191
|
+
for (let i = 0; i < 3; i++) {
|
|
192
|
+
statuses.push((await fetch(`${url}/api/x/login`, { method: "POST" })).status);
|
|
193
|
+
}
|
|
194
|
+
expect(statuses).toEqual([200, 200, 429]);
|
|
195
|
+
|
|
196
|
+
// same IP on a normal route → still allowed (distinct key)
|
|
197
|
+
expect((await fetch(`${url}/echo`)).status).toBe(200);
|
|
198
|
+
|
|
199
|
+
server.stop(true);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("falls back to in-memory when Redis is configured but unreachable (auto-detect)", async () => {
|
|
203
|
+
cfg.server.rateLimit = { enabled: true, max: 100, redis: { host: "localhost", port: 1 } };
|
|
204
|
+
const app = createApp();
|
|
205
|
+
app.get("/echo", (c) => c.json({ ok: true }));
|
|
206
|
+
const server = Bun.serve({ port: 0, fetch: app.fetch });
|
|
207
|
+
const res = await fetch(`${base(server)}/echo`);
|
|
208
|
+
expect(res.status).toBe(200);
|
|
209
|
+
server.stop(true);
|
|
210
|
+
});
|
|
211
|
+
});
|