@anteros/core 0.0.20 → 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/mongodbadapter.ts +3 -0
- package/index.ts +1 -1
- package/lib/define.ts +60 -3
- package/lib/files.ts +15 -27
- package/lib/mcp.ts +59 -0
- package/lib/scriptWorker.ts +76 -0
- package/lib/scripts.ts +77 -12
- package/package.json +5 -2
- package/probe-cache.ts +16 -0
- package/server/api.ts +4 -1
- package/server/boot.ts +75 -13
- package/server/cluster.ts +177 -0
- package/server/config.ts +0 -1
- package/server/env.ts +6 -0
- package/server/hono.ts +185 -14
- package/server/mcp.ts +232 -0
- package/server/metrics.ts +110 -0
- package/server/security.ts +13 -9
- 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/utils.test.ts +113 -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 +12 -4
|
@@ -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,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/collection.d.ts
CHANGED
|
@@ -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]:
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
}
|
package/types/hook.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { MongoRest } from "../database/mongodbadapter";
|
|
2
2
|
import type { Server as SocketIO } from "socket.io";
|
|
3
3
|
import type { ActionsApiList } from "./api";
|
|
4
|
-
|
|
4
|
+
|
|
5
|
+
export type HookMeta = {
|
|
5
6
|
action: ActionsApiList;
|
|
6
7
|
collection: string;
|
|
7
8
|
data?: any;
|
|
@@ -14,17 +15,19 @@ type metaHook = {
|
|
|
14
15
|
pipeline?: any[];
|
|
15
16
|
options?: any;
|
|
16
17
|
}
|
|
18
|
+
|
|
19
|
+
/** Context passed to a collection hook (`define.Hook`). */
|
|
20
|
+
export type HookContext = {
|
|
21
|
+
rest: MongoRest;
|
|
22
|
+
action: ActionsApiList;
|
|
23
|
+
meta: HookMeta;
|
|
24
|
+
io: SocketIO;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** A collection hook — `define.Hook(fn)`, used as beforeOperation/afterOperation. */
|
|
28
|
+
export type CollectionHook = (ctx: HookContext) => Promise<void>;
|
|
29
|
+
|
|
17
30
|
export type HooksCollection = {
|
|
18
|
-
beforeOperation?:
|
|
19
|
-
|
|
20
|
-
action: ActionsApiList;
|
|
21
|
-
meta: metaHook;
|
|
22
|
-
io: SocketIO;
|
|
23
|
-
}) => Promise<void>;
|
|
24
|
-
afterOperation?: (ctx: {
|
|
25
|
-
rest: MongoRest;
|
|
26
|
-
action: ActionsApiList;
|
|
27
|
-
meta: metaHook;
|
|
28
|
-
io: SocketIO;
|
|
29
|
-
}) => Promise<void>;
|
|
31
|
+
beforeOperation?: CollectionHook;
|
|
32
|
+
afterOperation?: CollectionHook;
|
|
30
33
|
}
|
package/types/mcp.d.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type Joi from "joi";
|
|
2
|
+
import type { ContentBlock } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
+
import type { Context } from "hono";
|
|
4
|
+
import type { useRest } from "../database/rest";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* MCP tool result — the Model Context Protocol `CallToolResult` shape.
|
|
8
|
+
* `exec` must return this (or a Promise of it).
|
|
9
|
+
*/
|
|
10
|
+
export type McpToolResult = {
|
|
11
|
+
content: ContentBlock[];
|
|
12
|
+
isError?: boolean;
|
|
13
|
+
structuredContent?: unknown;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* MCP tool definition — auto-loaded from `{tenant.dir}/mcp/tools/**\/*.tool.ts`.
|
|
18
|
+
* Served over the MCP protocol at `GET/POST /mcp/:tenant_id` (Streamable HTTP).
|
|
19
|
+
*/
|
|
20
|
+
export type McpTool = {
|
|
21
|
+
_isMcpTool_?: boolean;
|
|
22
|
+
_tenant_?: string;
|
|
23
|
+
/** Tool name — unique per tenant (how the LLM calls it) */
|
|
24
|
+
name: string;
|
|
25
|
+
/** Short description shown to the LLM */
|
|
26
|
+
description?: string;
|
|
27
|
+
enabled?: boolean;
|
|
28
|
+
/** Joi schema describing the tool arguments (same language as collections — `v`) */
|
|
29
|
+
inputSchema: Joi.Schema;
|
|
30
|
+
/** Handler executed when the LLM calls the tool (args are Joi-validated).
|
|
31
|
+
* Must return the MCP protocol result shape: `{ content: [{ type, text | data… }] }`. */
|
|
32
|
+
exec: (ctx: {
|
|
33
|
+
c: Context;
|
|
34
|
+
rest: InstanceType<typeof useRest>;
|
|
35
|
+
/** Parsed & validated arguments */
|
|
36
|
+
args: Record<string, any>;
|
|
37
|
+
}) => McpToolResult | Promise<McpToolResult>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* MCP resource read result — the protocol `ReadResourceResult` shape.
|
|
42
|
+
* `contents` is an array of text or blob (base64) content blocks.
|
|
43
|
+
*/
|
|
44
|
+
export type McpResourceResult = {
|
|
45
|
+
contents: Array<{
|
|
46
|
+
uri: string;
|
|
47
|
+
mimeType?: string;
|
|
48
|
+
text?: string;
|
|
49
|
+
blob?: string;
|
|
50
|
+
}>;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* MCP resource — read-only data exposed by URI (like a file the LLM can open).
|
|
55
|
+
* Auto-loaded from `{tenant.dir}/mcp/resources/**\/*.resource.ts`.
|
|
56
|
+
*
|
|
57
|
+
* The `uri` can be static (`orders://summary`) or a template
|
|
58
|
+
* (`users://{id}`) — template params are extracted and passed to `read`.
|
|
59
|
+
*/
|
|
60
|
+
export type McpResource = {
|
|
61
|
+
_isMcpResource_?: boolean;
|
|
62
|
+
_tenant_?: string;
|
|
63
|
+
/** Resource name — unique per tenant */
|
|
64
|
+
name: string;
|
|
65
|
+
/** Short description shown to the LLM */
|
|
66
|
+
description?: string;
|
|
67
|
+
enabled?: boolean;
|
|
68
|
+
/** Static URI (e.g. `orders://summary`) or template (e.g. `users://{id}`) */
|
|
69
|
+
uri: string;
|
|
70
|
+
/** Content type of the resource (default: application/json) */
|
|
71
|
+
mimeType?: string;
|
|
72
|
+
/** Handler called when the LLM reads the resource */
|
|
73
|
+
read: (ctx: {
|
|
74
|
+
c: Context;
|
|
75
|
+
rest: InstanceType<typeof useRest>;
|
|
76
|
+
/** Params extracted from the URI template (e.g. `{ id: '123' }`) */
|
|
77
|
+
params: Record<string, string>;
|
|
78
|
+
/** The exact URI the client requested */
|
|
79
|
+
uri: string;
|
|
80
|
+
}) => McpResourceResult | Promise<McpResourceResult>;
|
|
81
|
+
};
|
package/types/scripts.d.ts
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
import { useRest } from "../database/rest"
|
|
2
|
+
import type { Tenant } from "./tenant"
|
|
2
3
|
|
|
3
4
|
|
|
4
5
|
export type Script = {
|
|
5
6
|
_isScript_?: boolean;
|
|
6
7
|
enabled: boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Run in a separate Bun process so heavy work (large JSON, long imports…)
|
|
10
|
+
* never blocks the HTTP server. The subprocess reconnects the tenant DB and
|
|
11
|
+
* rebuilds collection schemas automatically.
|
|
12
|
+
*/
|
|
13
|
+
heavy?: boolean;
|
|
14
|
+
/** Max runtime in ms for heavy scripts (default: no timeout) */
|
|
15
|
+
timeout?: number;
|
|
7
16
|
exec: (ctx: {
|
|
8
17
|
rest: InstanceType<typeof useRest>
|
|
18
|
+
/** Report progress — only wired for heavy scripts (sent via IPC to the parent) */
|
|
19
|
+
progress?: (percent: number, info?: any) => void;
|
|
20
|
+
/** Tenant this script runs for */
|
|
21
|
+
tenant?: Tenant;
|
|
9
22
|
}) => void
|
|
10
|
-
}
|
|
23
|
+
}
|
package/utils/cache.ts
CHANGED
|
@@ -50,6 +50,25 @@ class Cache {
|
|
|
50
50
|
}
|
|
51
51
|
})
|
|
52
52
|
}
|
|
53
|
+
|
|
54
|
+
// ─── BentoCache provider API (delegated to the underlying instance) ───
|
|
55
|
+
|
|
56
|
+
set(options: any): Promise<boolean> { return this.#cache.set(options); }
|
|
57
|
+
setForever(options: any): Promise<boolean> { return this.#cache.setForever(options); }
|
|
58
|
+
get(options: any): Promise<unknown> { return this.#cache.get(options); }
|
|
59
|
+
getOrSet(options: any): Promise<unknown> { return this.#cache.getOrSet(options); }
|
|
60
|
+
getOrSetForever(options: any): Promise<unknown> { return this.#cache.getOrSetForever(options); }
|
|
61
|
+
has(options: any): Promise<boolean> { return this.#cache.has(options); }
|
|
62
|
+
missing(options: any): Promise<boolean> { return this.#cache.missing(options); }
|
|
63
|
+
pull(key: string): Promise<unknown> { return this.#cache.pull(key); }
|
|
64
|
+
delete(options: any): Promise<boolean> { return this.#cache.delete(options); }
|
|
65
|
+
deleteMany(options: any): Promise<boolean> { return this.#cache.deleteMany(options); }
|
|
66
|
+
deleteByTag(options: any): Promise<boolean> { return this.#cache.deleteByTag(options); }
|
|
67
|
+
expire(options: any): Promise<boolean> { return this.#cache.expire(options); }
|
|
68
|
+
clear(options?: any): Promise<void> { return this.#cache.clear(options); }
|
|
69
|
+
prune(): Promise<void> { return this.#cache.prune(); }
|
|
70
|
+
namespace(namespace: string): any { return this.#cache.namespace(namespace); }
|
|
71
|
+
disconnect(): Promise<void> { return this.#cache.disconnect(); }
|
|
53
72
|
}
|
|
54
73
|
|
|
55
74
|
|
|
@@ -93,4 +112,4 @@ export {
|
|
|
93
112
|
useFilesystemCache,
|
|
94
113
|
useMemoryCache,
|
|
95
114
|
useRedisCache
|
|
96
|
-
}
|
|
115
|
+
}
|
package/utils/crypto.ts
CHANGED
|
@@ -2,6 +2,20 @@ const IV_LENGTH = 12;
|
|
|
2
2
|
const TAG_LENGTH = 16;
|
|
3
3
|
const RSA_KEY_SIZE = 256;
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Build AES-GCM parameters with optional Additional Authenticated Data.
|
|
7
|
+
* AAD binds the ciphertext to its context (field/record/tenant) so a valid
|
|
8
|
+
* ciphertext cannot be swapped into another context (substitution attack).
|
|
9
|
+
*/
|
|
10
|
+
function gcmParams(iv: Uint8Array, aad?: string) {
|
|
11
|
+
return {
|
|
12
|
+
name: "AES-GCM" as const,
|
|
13
|
+
iv,
|
|
14
|
+
tagLength: TAG_LENGTH * 8,
|
|
15
|
+
...(aad ? { additionalData: new TextEncoder().encode(aad) } : {}),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
5
19
|
interface JsonWebKey {
|
|
6
20
|
kty?: string;
|
|
7
21
|
alg?: string;
|
|
@@ -43,12 +57,12 @@ class CryptSym {
|
|
|
43
57
|
return this.#key;
|
|
44
58
|
}
|
|
45
59
|
|
|
46
|
-
async encrypt(data: string | object): Promise<string> {
|
|
60
|
+
async encrypt(data: string | object, aad?: string): Promise<string> {
|
|
47
61
|
const text = typeof data === "object" ? JSON.stringify(data) : data;
|
|
48
62
|
const key = await this.#getKey();
|
|
49
63
|
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
|
50
64
|
const encrypted = await crypto.subtle.encrypt(
|
|
51
|
-
|
|
65
|
+
gcmParams(iv, aad),
|
|
52
66
|
key,
|
|
53
67
|
new TextEncoder().encode(text)
|
|
54
68
|
);
|
|
@@ -58,14 +72,17 @@ class CryptSym {
|
|
|
58
72
|
return Buffer.from(buffer).toString("base64");
|
|
59
73
|
}
|
|
60
74
|
|
|
61
|
-
async decrypt<T = string>(cipher: string, parseJson?: boolean): Promise<T> {
|
|
75
|
+
async decrypt<T = string>(cipher: string, parseJson?: boolean, aad?: string): Promise<T> {
|
|
62
76
|
try {
|
|
63
77
|
const buffer = new Uint8Array(Buffer.from(cipher, "base64"));
|
|
78
|
+
if (buffer.length < IV_LENGTH + TAG_LENGTH) {
|
|
79
|
+
throw new Error("ciphertext too short");
|
|
80
|
+
}
|
|
64
81
|
const iv = buffer.slice(0, IV_LENGTH);
|
|
65
82
|
const data = buffer.slice(IV_LENGTH);
|
|
66
83
|
const key = await this.#getKey();
|
|
67
84
|
const decrypted = await crypto.subtle.decrypt(
|
|
68
|
-
|
|
85
|
+
gcmParams(iv, aad),
|
|
69
86
|
key,
|
|
70
87
|
data
|
|
71
88
|
);
|
|
@@ -131,7 +148,7 @@ class CryptAsym {
|
|
|
131
148
|
return this.#keyPair;
|
|
132
149
|
}
|
|
133
150
|
|
|
134
|
-
async encrypt(data: string | object, publicKey?: CryptoKey): Promise<string> {
|
|
151
|
+
async encrypt(data: string | object, publicKey?: CryptoKey, aad?: string): Promise<string> {
|
|
135
152
|
const text = typeof data === "object" ? JSON.stringify(data) : data;
|
|
136
153
|
const rsaKey = publicKey ?? (await this.#getKeyPair()).publicKey;
|
|
137
154
|
|
|
@@ -143,7 +160,7 @@ class CryptAsym {
|
|
|
143
160
|
|
|
144
161
|
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
|
145
162
|
const encrypted = await crypto.subtle.encrypt(
|
|
146
|
-
|
|
163
|
+
gcmParams(iv, aad),
|
|
147
164
|
aesKey,
|
|
148
165
|
new TextEncoder().encode(text)
|
|
149
166
|
);
|
|
@@ -162,7 +179,7 @@ class CryptAsym {
|
|
|
162
179
|
return Buffer.from(result).toString("base64");
|
|
163
180
|
}
|
|
164
181
|
|
|
165
|
-
async decrypt<T = string>(cipher: string, parseJson?: boolean): Promise<T> {
|
|
182
|
+
async decrypt<T = string>(cipher: string, parseJson?: boolean, aad?: string): Promise<T> {
|
|
166
183
|
try {
|
|
167
184
|
const buffer = new Uint8Array(Buffer.from(cipher, "base64"));
|
|
168
185
|
if (buffer.length < RSA_KEY_SIZE + IV_LENGTH + TAG_LENGTH) {
|
|
@@ -189,7 +206,7 @@ class CryptAsym {
|
|
|
189
206
|
);
|
|
190
207
|
|
|
191
208
|
const decrypted = await crypto.subtle.decrypt(
|
|
192
|
-
|
|
209
|
+
gcmParams(iv, aad),
|
|
193
210
|
aesKey,
|
|
194
211
|
ciphertext
|
|
195
212
|
);
|
package/utils/func.ts
CHANGED
|
@@ -19,15 +19,16 @@ const jwt = {
|
|
|
19
19
|
audience?: string | string[];
|
|
20
20
|
subject?: string;
|
|
21
21
|
}) => {
|
|
22
|
-
|
|
22
|
+
const SECRET_ = cfg.server.jwt?.secret || Bun.env.JWT_SECRET
|
|
23
23
|
if (!SECRET_) throw new Error('JWT_SECRET is not set');
|
|
24
|
-
|
|
24
|
+
// Per-call expiresIn wins over the global config (default: 7d)
|
|
25
|
+
const EXPIRES_IN = options?.expiresIn || cfg.server.jwt?.expiresIn || '7d'
|
|
25
26
|
|
|
26
27
|
const secret = new TextEncoder().encode(SECRET_);
|
|
27
28
|
const signer = new Jose.SignJWT(payload)
|
|
28
29
|
.setProtectedHeader({ alg: 'HS256' })
|
|
29
30
|
.setIssuedAt()
|
|
30
|
-
.setExpirationTime(EXPIRES_IN
|
|
31
|
+
.setExpirationTime(EXPIRES_IN)
|
|
31
32
|
|
|
32
33
|
if (options?.issuer) signer.setIssuer(options.issuer)
|
|
33
34
|
if (options?.audience) signer.setAudience(options.audience)
|
|
@@ -301,7 +302,14 @@ function isEpochNumber(value: number): boolean {
|
|
|
301
302
|
}
|
|
302
303
|
|
|
303
304
|
function deepCopy<T>(value: T): T {
|
|
304
|
-
|
|
305
|
+
// structuredClone preserves Date / RegExp / Map / Set / typed arrays
|
|
306
|
+
// (clone() uses toJson, which JSON-serializes and loses those types).
|
|
307
|
+
// Falls back to clone() for values structuredClone cannot handle.
|
|
308
|
+
try {
|
|
309
|
+
return structuredClone(value);
|
|
310
|
+
} catch {
|
|
311
|
+
return clone(value);
|
|
312
|
+
}
|
|
305
313
|
}
|
|
306
314
|
|
|
307
315
|
type FormatToDateResult<T> = T extends string
|