@anteros/core 0.0.20 → 0.0.22

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 (43) hide show
  1. package/README.md +90 -5
  2. package/database/collection.ts +1 -1
  3. package/database/mongodbadapter.ts +3 -0
  4. package/index.ts +1 -1
  5. package/lib/define.ts +60 -3
  6. package/lib/files.ts +15 -27
  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 +5 -2
  11. package/probe-cache.ts +16 -0
  12. package/server/api.ts +4 -1
  13. package/server/boot.ts +75 -13
  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 +185 -14
  18. package/server/mcp.ts +232 -0
  19. package/server/metrics.ts +110 -0
  20. package/server/security.ts +13 -9
  21. package/tests/cache.test.ts +124 -0
  22. package/tests/crypto.test.ts +101 -0
  23. package/tests/fixtures/heavy/collections/items.model.ts +8 -0
  24. package/tests/fixtures/heavy/scripts/heavy-import.run.ts +14 -0
  25. package/tests/fixtures/mcp-tenant/mcp/resources/orders.summary.resource.ts +17 -0
  26. package/tests/fixtures/mcp-tenant/mcp/resources/users.id.resource.ts +18 -0
  27. package/tests/fixtures/mcp-tenant/mcp/tools/badge.tool.ts +16 -0
  28. package/tests/fixtures/mcp-tenant/mcp/tools/echo.tool.ts +19 -0
  29. package/tests/fixtures/metrics-worker.ts +21 -0
  30. package/tests/hono.test.ts +211 -0
  31. package/tests/mcp.test.ts +186 -0
  32. package/tests/metrics-cluster.test.ts +103 -0
  33. package/tests/scripts-heavy.test.ts +49 -0
  34. package/tests/security.test.ts +102 -0
  35. package/tests/utils.test.ts +113 -0
  36. package/types/collection.d.ts +20 -5
  37. package/types/config.d.ts +20 -3
  38. package/types/hook.d.ts +16 -13
  39. package/types/mcp.d.ts +81 -0
  40. package/types/scripts.d.ts +14 -1
  41. package/utils/cache.ts +20 -1
  42. package/utils/crypto.ts +25 -8
  43. 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
+ });
@@ -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
  }
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
- type metaHook = {
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?: (ctx: {
19
- rest: MongoRest;
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
+ };
@@ -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
- { name: "AES-GCM", iv, tagLength: TAG_LENGTH * 8 },
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
- { name: "AES-GCM", iv, tagLength: TAG_LENGTH * 8 },
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
- { name: "AES-GCM", iv, tagLength: TAG_LENGTH * 8 },
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
- { name: "AES-GCM", iv, tagLength: TAG_LENGTH * 8 },
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
- let SECRET_ = cfg.server.jwt?.secret || Bun.env.JWT_SECRET
22
+ const SECRET_ = cfg.server.jwt?.secret || Bun.env.JWT_SECRET
23
23
  if (!SECRET_) throw new Error('JWT_SECRET is not set');
24
- let EXPIRES_IN = cfg.server.jwt?.expiresIn || '1h'
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 || '1h')
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
- return clone(value);
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