@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
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
- expect((await rest.deleteOne("items", doc._id))._id).toBe(doc._id);
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]._id).toBeString();
106
- expect(docs[1]._id).toBeString();
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("items");
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: null,
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,8 @@
1
+ export default {
2
+ _isCollection_: true,
3
+ slug: 'items',
4
+ type: 'document',
5
+ fields: [
6
+ { name: 'name', type: 'string', required: true },
7
+ ],
8
+ };
@@ -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
+ });