@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.
Files changed (42) hide show
  1. package/README.md +90 -5
  2. package/database/mongodbadapter.ts +3 -0
  3. package/index.ts +1 -1
  4. package/lib/define.ts +60 -3
  5. package/lib/files.ts +15 -27
  6. package/lib/mcp.ts +59 -0
  7. package/lib/scriptWorker.ts +76 -0
  8. package/lib/scripts.ts +77 -12
  9. package/package.json +5 -2
  10. package/probe-cache.ts +16 -0
  11. package/server/api.ts +4 -1
  12. package/server/boot.ts +75 -13
  13. package/server/cluster.ts +177 -0
  14. package/server/config.ts +0 -1
  15. package/server/env.ts +6 -0
  16. package/server/hono.ts +185 -14
  17. package/server/mcp.ts +232 -0
  18. package/server/metrics.ts +110 -0
  19. package/server/security.ts +13 -9
  20. package/tests/cache.test.ts +124 -0
  21. package/tests/crypto.test.ts +101 -0
  22. package/tests/fixtures/heavy/collections/items.model.ts +8 -0
  23. package/tests/fixtures/heavy/scripts/heavy-import.run.ts +14 -0
  24. package/tests/fixtures/mcp-tenant/mcp/resources/orders.summary.resource.ts +17 -0
  25. package/tests/fixtures/mcp-tenant/mcp/resources/users.id.resource.ts +18 -0
  26. package/tests/fixtures/mcp-tenant/mcp/tools/badge.tool.ts +16 -0
  27. package/tests/fixtures/mcp-tenant/mcp/tools/echo.tool.ts +19 -0
  28. package/tests/fixtures/metrics-worker.ts +21 -0
  29. package/tests/hono.test.ts +211 -0
  30. package/tests/mcp.test.ts +186 -0
  31. package/tests/metrics-cluster.test.ts +103 -0
  32. package/tests/scripts-heavy.test.ts +49 -0
  33. package/tests/security.test.ts +102 -0
  34. package/tests/utils.test.ts +113 -0
  35. package/types/collection.d.ts +20 -5
  36. package/types/config.d.ts +20 -3
  37. package/types/hook.d.ts +16 -13
  38. package/types/mcp.d.ts +81 -0
  39. package/types/scripts.d.ts +14 -1
  40. package/utils/cache.ts +20 -1
  41. package/utils/crypto.ts +25 -8
  42. package/utils/func.ts +12 -4
package/server/boot.ts CHANGED
@@ -16,8 +16,12 @@ import { io, engineIo, websocket } from './io'
16
16
  import { loadServices } from '../lib/services'
17
17
  import { loadSockets } from '../lib/sockets'
18
18
  import { syncWorkflows } from '../lib/workflow'
19
+ import { syncMcpTools } from '../lib/mcp'
19
20
  import { loadTenantsMiddlewares } from '../lib/middleware'
20
21
  import crypto from 'node:crypto';
22
+ import os from 'node:os';
23
+ import { registerMetrics, getMetrics, trackRequest } from './metrics';
24
+ import { startMaster, aggregateMetrics } from './cluster';
21
25
 
22
26
  /** Check if a TCP port is already bound */
23
27
  async function isPortInUse(port: number): Promise<boolean> {
@@ -47,6 +51,7 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
47
51
  await loadServices(); // load services
48
52
  await loadSockets(); // load websocket handlers
49
53
  await syncWorkflows();
54
+ await syncMcpTools(); // load MCP tools per tenant (mcp/**/*.tool.ts)
50
55
  await loadTenantsMiddlewares();
51
56
  loadRoutes(); // load routes
52
57
  //******************************* */
@@ -76,16 +81,51 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
76
81
 
77
82
  const PORT = (cfg.server.port || 4000);
78
83
  const NAME = cfg.server.name || process.env.APP_NAME || 'SERVER';
79
- const useClusterMode = cfg.clusterMode || false;
80
- const env = process.env.NODE_ENV || Bun.env.NODE_ENV || 'dev';
81
- // reusePort: cluster mode ON by default, dev mode OFF to detect zombies
82
- const reusePort = cfg.server.reusePort ?? (useClusterMode && env !== 'dev');
84
+ // Widened to `string`: TS narrows `process.env.NODE_ENV` to a literal union
85
+ // ('development' | 'production' | 'test') and drops the 'dev' fallback branch.
86
+ const env: string = process.env.NODE_ENV || Bun.env.NODE_ENV || 'dev';
87
+ // Bun's SO_REUSEPORT: multiple processes can bind the same port (spawn them yourself)
88
+ const reusePort = cfg.server.reusePort ?? false;
89
+ const isWorker = process.env.BUN_WORKER !== undefined;
90
+
91
+ // ── MASTER (cluster): spawn workers, aggregate metrics, supervise ──
92
+ if (reusePort && !isWorker) {
93
+ const workersCount = cfg.server.workers ?? os.availableParallelism();
94
+ console.log(`[cluster] Master ${process.pid} spawning ${workersCount} workers…`.gray.bold);
95
+
96
+ const master = startMaster({
97
+ workers: workersCount,
98
+ argv: [process.execPath, ...process.argv.slice(1)],
99
+ env: { ...(process.env as Record<string, string>) },
100
+ });
101
+
102
+ // Supervision port: aggregated /health (master does NOT serve the main port)
103
+ const metricsPort = cfg.server.metricsPort ?? PORT + 1;
104
+ const metricsServer = Bun.serve({
105
+ port: metricsPort,
106
+ fetch: (req) => {
107
+ if (new URL(req.url).pathname === '/health') {
108
+ return Response.json({
109
+ status: 'ok',
110
+ master: { pid: process.pid },
111
+ ...aggregateMetrics(master.metrics()),
112
+ });
113
+ }
114
+ return new Response('Not found', { status: 404 });
115
+ },
116
+ });
117
+
118
+ console.log(`[cluster] Aggregated /health → http://localhost:${metricsPort}/health`.gray);
119
+
120
+ // Keep the master alive
121
+ return { master, metricsServer } as any;
122
+ }
83
123
 
84
- // Detect zombie: check if port is already in use in dev mode
124
+ // Detect zombie: check if port is already in use when NOT sharing the port
85
125
  if (!reusePort && env === 'dev') {
86
126
  const occupied = await isPortInUse(PORT);
87
127
  if (occupied) {
88
- console.error(`\n⚠ Port ${PORT} is already in use. Possible zombie server.\n → Kill it: lsof -ti :${PORT} | xargs kill -9\n → Or set server.reusePort: true in config\n`.red.bold);
128
+ console.error(`\n⚠ Port ${PORT} is already in use. Possible zombie server.\n → Kill it: lsof -ti :${PORT} | xargs kill -9\n`.red.bold);
89
129
  process.exit(1);
90
130
  }
91
131
  }
@@ -95,25 +135,47 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
95
135
 
96
136
  const server = Bun.serve({
97
137
  port: PORT,
98
- reusePort,
138
+ reusePort: reusePort || isWorker,
99
139
  fetch: (req, server) => {
100
140
  const url = new URL(req.url);
101
141
 
142
+ // Track every request for the built-in metrics (zero dependency)
143
+ const respond = (p: Response | Promise<Response>) =>
144
+ Promise.resolve(p).then((res) => {
145
+ trackRequest(req, res);
146
+ return res;
147
+ });
148
+
102
149
  if (url.pathname === "/socket.io/") {
103
- return engineIo.handleRequest(req, server);
104
- } else {
105
- return app.fetch(req, server);
150
+ return respond(engineIo.handleRequest(req, server));
106
151
  }
107
-
152
+ return respond(app.fetch(req, server));
108
153
  },
109
154
  websocket: websocket,
110
155
  maxRequestBodySize: 1024 * 1024 * 100, // 100MB
111
156
  })
112
157
 
158
+ // Register server for built-in metrics (GET /health)
159
+ registerMetrics(server);
160
+
161
+ // ── WORKER (cluster): report metrics to the master via IPC ──
162
+ if (isWorker && typeof process.send === 'function') {
163
+ setInterval(() => {
164
+ try {
165
+ process.send!({
166
+ type: 'metrics',
167
+ pid: process.pid,
168
+ metrics: getMetrics(),
169
+ });
170
+ } catch {}
171
+ }, 5000);
172
+ }
173
+
113
174
  let box = '';
114
- box += `${NAME}`.gray.underline + ` (PID: ${process.pid})\n\n`
175
+ const role = isWorker ? 'worker' : (reusePort ? 'master' : 'standalone');
176
+ box += `${NAME}`.gray.underline + ` (PID: ${process.pid}) — ${role}\n\n`
115
177
  box += `Env: ${env || 'dev'}`.green.bold + '\n'
116
- box += `Cluster mode: ${useClusterMode ? 'On'.green.bold : 'Off'.red.bold}\n`.gray.bold
178
+ box += `reusePort: ${(reusePort || isWorker) ? 'On'.green.bold : 'Off'.red.bold}\n`.gray.bold
117
179
  box += `Url: http://localhost:${PORT}`.gray.bold;
118
180
  box += '\n\n';
119
181
  box += `Last boot: 🔄 ${dayjs().format('YYYY-MM-DD HH:mm:ss')}`.gray;
@@ -0,0 +1,177 @@
1
+ import type { Subprocess } from "bun";
2
+ import type { MetricsSnapshot } from "./metrics";
3
+
4
+ // ─── Master/worker cluster over Bun.reusePort + IPC ──────────────────────────
5
+ //
6
+ // Each worker is a full server bound to the same port (SO_REUSEPORT, round-robin
7
+ // by the kernel). The master only supervises workers and aggregates their
8
+ // metrics — it never serves the main HTTP port.
9
+
10
+ export type MasterMetrics = Record<number, MetricsSnapshot>;
11
+
12
+ export type MasterHandle = {
13
+ /** Currently running worker PIDs */
14
+ workers: { pid: number }[];
15
+ /** Latest metrics snapshot per worker PID (may be empty until first report) */
16
+ metrics(): MasterMetrics;
17
+ /** Kill + respawn a specific worker */
18
+ restart(pid: number): void;
19
+ /** Kill all workers */
20
+ shutdown(): void;
21
+ };
22
+
23
+ export type AggregatedMetrics = {
24
+ workers: number;
25
+ uptime: { min: number; max: number };
26
+ requests: {
27
+ total: number;
28
+ active: number;
29
+ errors: number;
30
+ perSecond: number;
31
+ byMethod: Record<string, number>;
32
+ byStatus: Record<string, number>;
33
+ };
34
+ memory: { rss: number; heapUsed: number };
35
+ cpu: { percent: number; cores: number };
36
+ pids: number[];
37
+ };
38
+
39
+ type WorkerEntry = {
40
+ proc: Subprocess;
41
+ metrics: MetricsSnapshot | null;
42
+ restarts: number;
43
+ };
44
+
45
+ const MAX_RESTARTS = 10;
46
+
47
+ export function startMaster(options: {
48
+ workers: number;
49
+ argv: string[];
50
+ env: Record<string, string>;
51
+ }): MasterHandle {
52
+ const { workers, argv, env } = options;
53
+ const entries = new Map<number, WorkerEntry>();
54
+ let stopping = false;
55
+
56
+ const spawn = (): number => {
57
+ const proc = Bun.spawn(argv, {
58
+ env: { ...env, BUN_WORKER: '1' },
59
+ stdout: 'inherit',
60
+ stderr: 'inherit',
61
+ ipc: (message: any) => {
62
+ if (message && typeof message === 'object' && message.type === 'metrics') {
63
+ const entry = entries.get(message.pid as number);
64
+ if (entry && message.metrics) {
65
+ entry.metrics = message.metrics as MetricsSnapshot;
66
+ }
67
+ }
68
+ },
69
+ });
70
+
71
+ entries.set(proc.pid, { proc, metrics: null, restarts: 0 });
72
+
73
+ proc.exited.then((code) => {
74
+ const entry = entries.get(proc.pid);
75
+ if (!entry) return;
76
+ entries.delete(proc.pid);
77
+
78
+ console.log(`[cluster] Worker ${proc.pid} exited (code ${code})`.yellow);
79
+ if (stopping) return;
80
+
81
+ if (entry.restarts >= MAX_RESTARTS) {
82
+ console.error(`[cluster] Worker ${proc.pid} exceeded ${MAX_RESTARTS} restarts — giving up`.red.bold);
83
+ return;
84
+ }
85
+
86
+ // Auto-respawn
87
+ const newPid = spawn();
88
+ const fresh = entries.get(newPid);
89
+ if (fresh) fresh.restarts = entry.restarts + 1;
90
+ console.log(`[cluster] Respawned worker ${proc.pid} → ${newPid} (attempt ${entry.restarts + 1})`.gray);
91
+ });
92
+
93
+ return proc.pid;
94
+ };
95
+
96
+ for (let i = 0; i < workers; i++) {
97
+ spawn();
98
+ }
99
+
100
+ return {
101
+ get workers() {
102
+ return [...entries.keys()].map((pid) => ({ pid }));
103
+ },
104
+
105
+ metrics() {
106
+ const out: MasterMetrics = {};
107
+ for (const [pid, entry] of entries) {
108
+ if (entry.metrics) out[pid] = entry.metrics;
109
+ }
110
+ return out;
111
+ },
112
+
113
+ restart(pid: number) {
114
+ const entry = entries.get(pid);
115
+ if (!entry) return;
116
+ entry.proc.kill();
117
+ // respawn happens in the proc.exited handler
118
+ },
119
+
120
+ shutdown() {
121
+ stopping = true;
122
+ for (const [, entry] of entries) {
123
+ entry.proc.kill();
124
+ }
125
+ entries.clear();
126
+ },
127
+ };
128
+ }
129
+
130
+ /** Merge per-worker snapshots into a single cluster-wide view. */
131
+ export function aggregateMetrics(metrics: MasterMetrics): AggregatedMetrics {
132
+ const snapshots = Object.values(metrics);
133
+ const empty: AggregatedMetrics = {
134
+ workers: 0,
135
+ uptime: { min: 0, max: 0 },
136
+ requests: { total: 0, active: 0, errors: 0, perSecond: 0, byMethod: {}, byStatus: {} },
137
+ memory: { rss: 0, heapUsed: 0 },
138
+ cpu: { percent: 0, cores: 0 },
139
+ pids: [],
140
+ };
141
+
142
+ if (!snapshots.length) return empty;
143
+
144
+ const workers = snapshots.length;
145
+ const maxUptime = Math.max(...snapshots.map((m) => m.uptime));
146
+ const minUptime = Math.min(...snapshots.map((m) => m.uptime));
147
+ const totalReqs = snapshots.reduce((sum, m) => sum + m.requests.total, 0);
148
+
149
+ const byMethod: Record<string, number> = {};
150
+ const byStatus: Record<string, number> = {};
151
+ for (const m of snapshots) {
152
+ for (const [k, v] of Object.entries(m.requests.byMethod)) byMethod[k] = (byMethod[k] ?? 0) + v;
153
+ for (const [k, v] of Object.entries(m.requests.byStatus)) byStatus[k] = (byStatus[k] ?? 0) + v;
154
+ }
155
+
156
+ return {
157
+ workers,
158
+ uptime: { min: minUptime, max: maxUptime },
159
+ requests: {
160
+ total: totalReqs,
161
+ active: snapshots.reduce((sum, m) => sum + m.pendingRequests, 0),
162
+ errors: snapshots.reduce((sum, m) => sum + m.requests.errors, 0),
163
+ perSecond: maxUptime > 0 ? Math.round(totalReqs / maxUptime) : 0,
164
+ byMethod,
165
+ byStatus,
166
+ },
167
+ memory: {
168
+ rss: snapshots.reduce((sum, m) => sum + m.memory.rss, 0),
169
+ heapUsed: snapshots.reduce((sum, m) => sum + m.memory.heapUsed, 0),
170
+ },
171
+ cpu: {
172
+ percent: Math.round((snapshots.reduce((sum, m) => sum + m.cpu.percent, 0) / workers) * 10) / 10,
173
+ cores: snapshots[0]?.cpu.cores ?? 0,
174
+ },
175
+ pids: snapshots.map((m) => m.pid),
176
+ };
177
+ }
package/server/config.ts CHANGED
@@ -15,7 +15,6 @@ const cfg: Config = { // app Config
15
15
 
16
16
  function formatConfig(config: ServerConfig) { // format the config
17
17
 
18
- cfg.clusterMode = config.clusterMode ?? true;
19
18
  cfg.server = {
20
19
  ...cfg.server,
21
20
  ...config.server,
package/server/env.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * Hono context variables shared by middleware and route handlers.
3
3
  */
4
+ import type { ConnInfo } from "hono/conninfo";
5
+
4
6
  export type HonoVariables = {
5
7
  /** Present when `Authorization: Bearer <jwt>` was sent and verification succeeded. */
6
8
  token?: {
@@ -13,4 +15,8 @@ export type HonoVariables = {
13
15
  /** Whether the token is expired */
14
16
  expired: boolean;
15
17
  };
18
+ /** Best-effort client IP, resolved once per request (real socket, or trusted proxy header when cfg.server.trustProxy). */
19
+ clientIp?: string;
20
+ /** Connection info (real socket address) — set once per request by the IP middleware. */
21
+ connInfo?: ConnInfo;
16
22
  };
package/server/hono.ts CHANGED
@@ -1,22 +1,111 @@
1
- import { Hono } from "hono";
1
+ import { Hono, type Context } from "hono";
2
2
  import { cors } from "hono/cors"
3
3
  import { compress } from 'hono/compress'
4
4
  import { ipRestriction } from "hono/ip-restriction"
5
5
  import { bodyLimit } from "hono/body-limit"
6
6
  import { secureHeaders } from "hono/secure-headers"
7
+ import { HTTPException } from "hono/http-exception"
7
8
  import { getConnInfo } from "hono/bun"
9
+ import type { ConnInfo, GetConnInfo } from "hono/conninfo"
10
+ import { Redis } from "ioredis"
8
11
  import { cfg } from "./config"
9
- import { rateLimit } from "./security"
12
+ import { rateLimit, createRedisStore, createMemoryStore, type RateLimitStore } from "./security"
10
13
  import { initializeRoutes } from "./routes";
11
- import { sessionCtxStorage, asyncContextStorage, requestCtxStorage } from "../lib/asyncContextStorage";
14
+ import { asyncContextStorage, requestCtxStorage } from "../lib/asyncContextStorage";
12
15
  import { initializeApi } from "./api";
16
+ import { initializeMcp } from "./mcp";
13
17
  import { getGlobalMiddlewares } from "../lib/middleware";
14
18
  import { jwt } from "../utils/func";
15
19
  import { AppError } from "../lib/error";
20
+ import { getMetrics } from "./metrics";
16
21
  import type { HonoVariables } from "./env";
17
22
 
18
- const app = new Hono<{ Variables: HonoVariables }>();
23
+ // ─── Redis-backed rate limiting (C3) ─────────────────────────────────────────
24
+ // Lazy singleton: created once per process and reused across createApp() calls.
25
+ // Each increment falls back to the in-memory store if Redis is unavailable,
26
+ // so the limiter keeps working during a Redis outage.
27
+ let redisRateLimitStore: RateLimitStore | null | undefined;
28
+ let redisRateLimitSignature: string | undefined;
29
+
30
+ function getRedisRateLimitStore(): RateLimitStore | null {
31
+ const rl = cfg.server.rateLimit;
32
+ // Re-evaluate only when the rate-limit config or Redis env vars change,
33
+ // so multiple createApp() calls (tests, hot reload) reuse the same client.
34
+ const signature = JSON.stringify([
35
+ rl?.useRedis,
36
+ rl?.redis,
37
+ process.env.REDIS_URL,
38
+ process.env.REDIS_HOST,
39
+ process.env.REDIS_PORT,
40
+ process.env.REDIS_PASSWORD,
41
+ ]);
42
+ if (redisRateLimitSignature === signature && redisRateLimitStore !== undefined) return redisRateLimitStore;
43
+ redisRateLimitSignature = signature;
44
+
45
+ if (!rl) {
46
+ redisRateLimitStore = null;
47
+ return null;
48
+ }
49
+
50
+ // Redis is used AUTOMATICALLY when connection info is present (rateLimit.redis
51
+ // block or REDIS_* env vars) — no need for useRedis: true.
52
+ // - useRedis: false → force the in-memory store
53
+ // - useRedis: true → force Redis even without connection info (localhost)
54
+ // - unset → auto: Redis if configured, else memory
55
+ const conn = rl.redis ?? {};
56
+ const hasRedisConfig =
57
+ Boolean(conn.url || conn.host || conn.port || conn.password) ||
58
+ Boolean(process.env.REDIS_URL || process.env.REDIS_HOST || process.env.REDIS_PORT || process.env.REDIS_PASSWORD);
59
+ const useRedis = rl.useRedis === true || (rl.useRedis !== false && hasRedisConfig);
60
+
61
+ if (!useRedis) {
62
+ redisRateLimitStore = null;
63
+ return null;
64
+ }
65
+
66
+ try {
67
+ const conn = rl.redis ?? {};
68
+ // Stop reconnecting after a few attempts: a dead Redis must not spam logs
69
+ // or keep the process alive — the limiter falls back to memory meanwhile.
70
+ const retryStrategy = (times: number) => (times > 5 ? null : Math.min(times * 500, 3000));
71
+ const client = conn.url
72
+ ? new Redis(conn.url, { retryStrategy, maxRetriesPerRequest: 2 })
73
+ : new Redis({
74
+ host: conn.host ?? process.env.REDIS_HOST ?? 'localhost',
75
+ port: conn.port ?? (Number(process.env.REDIS_PORT) || 6379),
76
+ password: conn.password ?? process.env.REDIS_PASSWORD,
77
+ retryStrategy,
78
+ maxRetriesPerRequest: 2,
79
+ });
80
+ let errorLogged = false;
81
+ client.on('error', (err) => {
82
+ if (errorLogged) return;
83
+ errorLogged = true;
84
+ console.error('[rateLimit] Redis connection error:', err?.message);
85
+ });
86
+
87
+ const redisStore = createRedisStore(client);
88
+ const memoryFallback = createMemoryStore();
89
+ redisRateLimitStore = {
90
+ async increment(key: string, windowMs: number) {
91
+ try {
92
+ return await redisStore.increment(key, windowMs);
93
+ } catch (err: any) {
94
+ console.error('[rateLimit] Redis store failed, falling back to in-memory:', err?.message);
95
+ return memoryFallback.increment(key, windowMs);
96
+ }
97
+ },
98
+ };
99
+ console.log('[rateLimit] store: Redis (with in-memory fallback)');
100
+ } catch (err: any) {
101
+ console.error('[rateLimit] Redis init failed, using in-memory store:', err?.message);
102
+ redisRateLimitStore = null;
103
+ }
104
+ return redisRateLimitStore;
105
+ }
106
+
19
107
  function createApp(): Hono<{ Variables: HonoVariables }> {
108
+ const app = new Hono<{ Variables: HonoVariables }>();
20
109
  const allowHeaders = [
21
110
  "Tenant-Id",
22
111
  "Content-Type",
@@ -43,17 +132,78 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
43
132
  "HEAD",
44
133
  ...(cfg.server.cors?.allowMethods as string[] || []),
45
134
  ]
46
- // cors
47
- const corsOrigin = cfg.server.cors?.origin as string[] | undefined;
48
- const corsCredentials = cfg.server.cors?.credentials as boolean | undefined ?? false;
135
+ // cors — origin accepts string | string[] | function (see ServerConfig type)
136
+ const corsConfig = cfg.server.cors?.origin;
137
+ const corsCredentials = cfg.server.cors?.credentials ?? false;
138
+
139
+ // Detect a wildcard in any supported form without breaking on string/function origins
140
+ const hasWildcardOrigin =
141
+ corsConfig === '*' ||
142
+ (Array.isArray(corsConfig) && corsConfig.includes('*'));
49
143
 
50
- if (corsCredentials && (!corsOrigin || corsOrigin.includes('*'))) {
144
+ if (corsCredentials && (!corsConfig || hasWildcardOrigin)) {
51
145
  throw new Error(
52
146
  'CORS misconfiguration: credentials: true is incompatible with origin: *. '
53
147
  + 'Set explicit origins in cfg.server.cors.origin.'
54
148
  );
55
149
  }
56
150
 
151
+ // Config function form: (ctx: { origin, c }) => string | string[]
152
+ // Hono expects: (origin, c) => string | Promise<string | null | undefined> | null | undefined
153
+ type CorsOriginOption = NonNullable<Parameters<typeof cors>[0]>['origin'];
154
+ const corsOrigin =
155
+ typeof corsConfig === 'function'
156
+ ? ((origin: string, c: Context) => corsConfig({ origin, c })) as unknown as CorsOriginOption
157
+ : corsConfig;
158
+
159
+ // Global error handler — maps AppError thrown by user routes, global
160
+ // middlewares and useRest to their proper status/code/message.
161
+ // (API routes keep their own errorResponse() for backward compat.)
162
+ app.onError((err, c) => {
163
+ // Let Hono's HTTPException respond with its own status (401/413/…)
164
+ if (err instanceof HTTPException) {
165
+ return err.getResponse();
166
+ }
167
+
168
+ const isAppError = err instanceof AppError;
169
+ // Always log unexpected errors (default Hono behavior preserved)
170
+ if (!isAppError) console.error(err);
171
+
172
+ const status = isAppError ? Number(err.status) || 500 : 500;
173
+ return c.json({
174
+ message: isAppError ? err.message : 'Internal server error',
175
+ code: isAppError ? err.code : 'INTERNAL_SERVER_ERROR',
176
+ meta: isAppError ? err.meta : undefined,
177
+ }, status as any);
178
+ });
179
+
180
+ // ── Client IP: resolved once per request and shared by all middlewares ──
181
+ // getConnInfo(c) = server.requestIP() → the real socket address. Proxy headers
182
+ // (CF-Connecting-IP / X-Forwarded-For) are ONLY trusted when cfg.server.trustProxy
183
+ // is explicitly enabled, otherwise they are spoofable.
184
+ // getConnInfo throws when the app runs outside a real Bun.serve (e.g. app.request()
185
+ // in tests) → fall back to an empty address.
186
+ const safeGetConnInfo = (c: Context): ConnInfo => {
187
+ try {
188
+ return getConnInfo(c);
189
+ } catch {
190
+ return { remote: {} };
191
+ }
192
+ };
193
+
194
+ app.use(async (c, next) => {
195
+ const connInfo = safeGetConnInfo(c);
196
+ c.set('connInfo', connInfo);
197
+ c.set('clientIp', cfg.server.trustProxy
198
+ ? c.req.header('CF-Connecting-IP') ?? c.req.header('X-Forwarded-For') ?? connInfo.remote.address ?? 'unknown'
199
+ : connInfo.remote.address ?? 'unknown');
200
+ await next();
201
+ });
202
+
203
+ // Reuse the cached connInfo instead of re-querying the socket per middleware
204
+ const cachedGetConnInfo: GetConnInfo = (c) =>
205
+ (c as Context<{ Variables: HonoVariables }>).get('connInfo') ?? safeGetConnInfo(c);
206
+
57
207
 
58
208
 
59
209
  app.use(cors({
@@ -67,7 +217,7 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
67
217
  app.use(compress());
68
218
 
69
219
  // ip restriction
70
- app.use(ipRestriction(getConnInfo, {
220
+ app.use(ipRestriction(cachedGetConnInfo, {
71
221
  denyList: cfg.server?.ipRestriction?.denyList || [],
72
222
  allowList: cfg.server?.ipRestriction?.allowList || [],
73
223
  }))
@@ -88,19 +238,38 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
88
238
  maxSize: cfg.server.body?.maxSize ?? 1024 * 1024 * 100, // 100MB
89
239
  }));
90
240
 
241
+ // ── Health & metrics (built-in, zero dependency) ──
242
+ app.get('/health', (c) => {
243
+ return c.json({
244
+ ...getMetrics(),
245
+ ip: c.get('clientIp') ?? 'unknown',
246
+ });
247
+ });
248
+
91
249
 
92
250
 
93
251
  // rate limiting (global)
94
252
  if (cfg.server.rateLimit?.enabled !== false) {
253
+ // Redis-backed store when configured (C3), in-memory otherwise
254
+ const store = getRedisRateLimitStore() ?? undefined;
255
+
256
+ // Distinct keys so the stricter login limit never shares counters with the global one
257
+ const keyByClientIp = (prefix: string) => (c: Context) =>
258
+ `rn:rl:${prefix}${(c as any).get('clientIp') ?? 'unknown'}`;
259
+
95
260
  app.use('*', rateLimit({
96
261
  windowMs: cfg.server.rateLimit?.windowMs ?? 60_000,
97
262
  max: cfg.server.rateLimit?.max ?? 100,
263
+ store,
264
+ keyGenerator: keyByClientIp(''),
98
265
  }));
99
266
 
100
267
  // stricter limit for login endpoints
101
268
  app.use('/api/*/login', rateLimit({
102
269
  windowMs: cfg.server.rateLimit?.login?.windowMs ?? 60_000,
103
270
  max: cfg.server.rateLimit?.login?.max ?? 10,
271
+ store,
272
+ keyGenerator: keyByClientIp('login:'),
104
273
  }));
105
274
  }
106
275
 
@@ -108,7 +277,6 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
108
277
  app.use(async (c, next) => {
109
278
  return asyncContextStorage.run(new Map(), async () => {
110
279
  const traceId = crypto.randomUUID();
111
- const connInfo = getConnInfo(c);
112
280
  requestCtxStorage.set('trace', { id: traceId });
113
281
  requestCtxStorage.set('internal', false);
114
282
 
@@ -120,7 +288,7 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
120
288
  delete requestCtx.cookie;
121
289
  requestCtxStorage.set('meta', {
122
290
  request: {
123
- ip: connInfo.remote.address ?? c.req.header('CF-Connecting-IP') ?? c.req.header('True-Client-IP') ?? c.req.header('X-Forwarded-For') ?? 'unknown',
291
+ ip: c.get('clientIp') ?? 'unknown',
124
292
  user_agent: c.req.header('User-Agent') ?? '',
125
293
  headers: requestCtx,
126
294
  method: c.req.method,
@@ -140,7 +308,8 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
140
308
  value: isValid ? bearer : null,
141
309
  decoded: isValid ? (value as Record<string, unknown>) : null,
142
310
  provided: true,
143
- expired: expired || (value != null && typeof (value as any)?.exp === 'number' && (value as any).exp * 1000 < Date.now()),
311
+ // jwt.verify already flags expiry (expired: true, value: null)
312
+ expired,
144
313
  };
145
314
 
146
315
  requestCtxStorage.set('token', tokenData);
@@ -166,6 +335,9 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
166
335
  // initialize api
167
336
  initializeApi(app);
168
337
 
338
+ // initialize MCP (tools served at /mcp/:tenant_id)
339
+ initializeMcp(app);
340
+
169
341
 
170
342
  return app;
171
343
  }
@@ -174,6 +346,5 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
174
346
 
175
347
 
176
348
  export {
177
- createApp,
178
- app
349
+ createApp
179
350
  }