@n24q02m/mcp-relay-server 0.1.0

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/build/app.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import express from 'express';
2
+ export declare function createApp(): express.Express;
3
+ //# sourceMappingURL=app.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"AACA,OAAO,OAAO,MAAM,SAAS,CAAA;AAI7B,wBAAgB,SAAS,IAAI,OAAO,CAAC,OAAO,CAgB3C"}
package/build/app.js ADDED
@@ -0,0 +1,18 @@
1
+ import cors from 'cors';
2
+ import express from 'express';
3
+ import { rateLimiter } from './middleware/rate-limit.js';
4
+ import { sessionsRouter } from './routes/sessions.js';
5
+ export function createApp() {
6
+ const app = express();
7
+ const corsOrigin = process.env.CORS_ORIGIN ?? '*';
8
+ app.use(cors({ origin: corsOrigin }));
9
+ app.use(express.json({ limit: '1mb' }));
10
+ app.use(rateLimiter);
11
+ app.use('/api/sessions', sessionsRouter);
12
+ const pagesDir = process.env.PAGES_DIR;
13
+ if (pagesDir) {
14
+ app.use(express.static(pagesDir));
15
+ }
16
+ return app;
17
+ }
18
+ //# sourceMappingURL=app.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"app.js","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,MAAM,CAAA;AACvB,OAAO,OAAO,MAAM,SAAS,CAAA;AAC7B,OAAO,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAA;AACxD,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAA;AAErD,MAAM,UAAU,SAAS;IACvB,MAAM,GAAG,GAAG,OAAO,EAAE,CAAA;IAErB,MAAM,UAAU,GAAG,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,GAAG,CAAA;IACjD,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC,CAAC,CAAA;IACrC,GAAG,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC,CAAA;IACvC,GAAG,CAAC,GAAG,CAAC,WAAW,CAAC,CAAA;IAEpB,GAAG,CAAC,GAAG,CAAC,eAAe,EAAE,cAAc,CAAC,CAAA;IAExC,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,SAAS,CAAA;IACtC,IAAI,QAAQ,EAAE,CAAC;QACb,GAAG,CAAC,GAAG,CAAC,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAA;IACnC,CAAC;IAED,OAAO,GAAG,CAAA;AACZ,CAAC"}
@@ -0,0 +1,3 @@
1
+ export { createApp } from './app.js';
2
+ export { startLocalRelay } from './local.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAA;AACpC,OAAO,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA"}
package/build/index.js ADDED
@@ -0,0 +1,11 @@
1
+ import { createApp } from './app.js';
2
+ import { startCleanup } from './store.js';
3
+ export { createApp } from './app.js';
4
+ export { startLocalRelay } from './local.js';
5
+ const port = Number.parseInt(process.env.PORT ?? '3000', 10);
6
+ const app = createApp();
7
+ startCleanup();
8
+ app.listen(port, () => {
9
+ console.log(`Relay server listening on port ${port}`);
10
+ });
11
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAA;AACpC,OAAO,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AAEzC,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAA;AACpC,OAAO,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAE5C,MAAM,IAAI,GAAG,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,MAAM,EAAE,EAAE,CAAC,CAAA;AAC5D,MAAM,GAAG,GAAG,SAAS,EAAE,CAAA;AAEvB,YAAY,EAAE,CAAA;AAEd,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,GAAG,EAAE;IACpB,OAAO,CAAC,GAAG,CAAC,kCAAkC,IAAI,EAAE,CAAC,CAAA;AACvD,CAAC,CAAC,CAAA"}
@@ -0,0 +1,6 @@
1
+ export declare function startLocalRelay(pagesDir: string): Promise<{
2
+ port: number;
3
+ url: string;
4
+ close: () => void;
5
+ }>;
6
+ //# sourceMappingURL=local.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"local.d.ts","sourceRoot":"","sources":["../src/local.ts"],"names":[],"mappings":"AAIA,wBAAsB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,IAAI,CAAA;CAAE,CAAC,CAcjH"}
package/build/local.js ADDED
@@ -0,0 +1,17 @@
1
+ import express from 'express';
2
+ import { createApp } from './app.js';
3
+ export async function startLocalRelay(pagesDir) {
4
+ const app = createApp();
5
+ app.use(express.static(pagesDir));
6
+ return new Promise((resolve) => {
7
+ const server = app.listen(0, () => {
8
+ const addr = server.address();
9
+ resolve({
10
+ port: addr.port,
11
+ url: `http://localhost:${addr.port}`,
12
+ close: () => server.close()
13
+ });
14
+ });
15
+ });
16
+ }
17
+ //# sourceMappingURL=local.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"local.js","sourceRoot":"","sources":["../src/local.ts"],"names":[],"mappings":"AACA,OAAO,OAAO,MAAM,SAAS,CAAA;AAC7B,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAA;AAEpC,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,QAAgB;IACpD,MAAM,GAAG,GAAG,SAAS,EAAE,CAAA;IACvB,GAAG,CAAC,GAAG,CAAC,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAA;IAEjC,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE;QAC7B,MAAM,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CAAC,EAAE,GAAG,EAAE;YAChC,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,EAAiB,CAAA;YAC5C,OAAO,CAAC;gBACN,IAAI,EAAE,IAAI,CAAC,IAAI;gBACf,GAAG,EAAE,oBAAoB,IAAI,CAAC,IAAI,EAAE;gBACpC,KAAK,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,KAAK,EAAE;aAC5B,CAAC,CAAA;QACJ,CAAC,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;AACJ,CAAC"}
@@ -0,0 +1,2 @@
1
+ export declare const rateLimiter: import("express-rate-limit").RateLimitRequestHandler;
2
+ //# sourceMappingURL=rate-limit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rate-limit.d.ts","sourceRoot":"","sources":["../../src/middleware/rate-limit.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,WAAW,sDAMtB,CAAA"}
@@ -0,0 +1,9 @@
1
+ import rateLimit from 'express-rate-limit';
2
+ export const rateLimiter = rateLimit({
3
+ windowMs: 60 * 1000, // 1 minute
4
+ limit: 30,
5
+ standardHeaders: 'draft-7',
6
+ legacyHeaders: false,
7
+ message: { error: 'Too many requests, please try again later' }
8
+ });
9
+ //# sourceMappingURL=rate-limit.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rate-limit.js","sourceRoot":"","sources":["../../src/middleware/rate-limit.ts"],"names":[],"mappings":"AAAA,OAAO,SAAS,MAAM,oBAAoB,CAAA;AAE1C,MAAM,CAAC,MAAM,WAAW,GAAG,SAAS,CAAC;IACnC,QAAQ,EAAE,EAAE,GAAG,IAAI,EAAE,WAAW;IAChC,KAAK,EAAE,EAAE;IACT,eAAe,EAAE,SAAS;IAC1B,aAAa,EAAE,KAAK;IACpB,OAAO,EAAE,EAAE,KAAK,EAAE,2CAA2C,EAAE;CAChE,CAAC,CAAA"}
@@ -0,0 +1,3 @@
1
+ import { Router } from 'express';
2
+ export declare const sessionsRouter: ReturnType<typeof Router>;
3
+ //# sourceMappingURL=sessions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sessions.d.ts","sourceRoot":"","sources":["../../src/routes/sessions.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAA;AAQhC,eAAO,MAAM,cAAc,EAAE,UAAU,CAAC,OAAO,MAAM,CAAY,CAAA"}
@@ -0,0 +1,58 @@
1
+ import { Router } from 'express';
2
+ import { createSession, deleteSession, getSession, setSessionResult } from '../store.js';
3
+ function paramId(req) {
4
+ const id = req.params.id;
5
+ return Array.isArray(id) ? id[0] : id;
6
+ }
7
+ export const sessionsRouter = Router();
8
+ sessionsRouter.post('/', (req, res) => {
9
+ const { sessionId, serverName, schema } = req.body;
10
+ if (!sessionId || !serverName) {
11
+ res.status(400).json({ error: 'sessionId and serverName are required' });
12
+ return;
13
+ }
14
+ const sourceIp = req.ip ?? req.socket.remoteAddress ?? 'unknown';
15
+ const session = createSession(sessionId, serverName, schema, sourceIp);
16
+ if (!session) {
17
+ res.status(429).json({ error: 'Too many active sessions for this IP' });
18
+ return;
19
+ }
20
+ res.status(201).json({ sessionId: session.id });
21
+ });
22
+ sessionsRouter.get('/:id', (req, res) => {
23
+ const session = getSession(paramId(req));
24
+ if (!session) {
25
+ res.status(404).json({ error: 'Session not found or expired' });
26
+ return;
27
+ }
28
+ if (session.result === null) {
29
+ res
30
+ .status(202)
31
+ .json({ status: 'pending', sessionId: session.id, serverName: session.serverName, schema: session.schema });
32
+ return;
33
+ }
34
+ res.status(200).json({ status: 'ready', result: session.result });
35
+ });
36
+ sessionsRouter.post('/:id/result', (req, res) => {
37
+ const { browserPub, ciphertext, iv, tag } = req.body;
38
+ if (!browserPub || !ciphertext || !iv || !tag) {
39
+ res.status(400).json({ error: 'browserPub, ciphertext, iv, and tag are required' });
40
+ return;
41
+ }
42
+ const session = getSession(paramId(req));
43
+ if (!session) {
44
+ res.status(404).json({ error: 'Session not found or expired' });
45
+ return;
46
+ }
47
+ const success = setSessionResult(paramId(req), { browserPub, ciphertext, iv, tag });
48
+ if (!success) {
49
+ res.status(409).json({ error: 'Result already submitted' });
50
+ return;
51
+ }
52
+ res.status(200).json({ ok: true });
53
+ });
54
+ sessionsRouter.delete('/:id', (req, res) => {
55
+ deleteSession(paramId(req));
56
+ res.status(204).send();
57
+ });
58
+ //# sourceMappingURL=sessions.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sessions.js","sourceRoot":"","sources":["../../src/routes/sessions.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,22 @@
1
+ export interface SessionResult {
2
+ browserPub: string;
3
+ ciphertext: string;
4
+ iv: string;
5
+ tag: string;
6
+ }
7
+ export interface Session {
8
+ id: string;
9
+ serverName: string;
10
+ schema: unknown;
11
+ result: SessionResult | null;
12
+ createdAt: number;
13
+ sourceIp: string;
14
+ }
15
+ export declare function getSession(id: string): Session | undefined;
16
+ export declare function createSession(id: string, serverName: string, schema: unknown, sourceIp: string): Session | null;
17
+ export declare function setSessionResult(id: string, result: SessionResult): boolean;
18
+ export declare function deleteSession(id: string): boolean;
19
+ export declare function startCleanup(): void;
20
+ export declare function stopCleanup(): void;
21
+ export declare function clearAllSessions(): void;
22
+ //# sourceMappingURL=store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,UAAU,EAAE,MAAM,CAAA;IAClB,UAAU,EAAE,MAAM,CAAA;IAClB,EAAE,EAAE,MAAM,CAAA;IACV,GAAG,EAAE,MAAM,CAAA;CACZ;AAED,MAAM,WAAW,OAAO;IACtB,EAAE,EAAE,MAAM,CAAA;IACV,UAAU,EAAE,MAAM,CAAA;IAClB,MAAM,EAAE,OAAO,CAAA;IACf,MAAM,EAAE,aAAa,GAAG,IAAI,CAAA;IAC5B,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,CAAA;CACjB;AASD,wBAAgB,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS,CAO1D;AAED,wBAAgB,aAAa,CAAC,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,GAAG,IAAI,CAgB/G;AAED,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,GAAG,OAAO,CAM3E;AAED,wBAAgB,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAEjD;AAsBD,wBAAgB,YAAY,IAAI,IAAI,CAKnC;AAED,wBAAgB,WAAW,IAAI,IAAI,CAKlC;AAED,wBAAgB,gBAAgB,IAAI,IAAI,CAEvC"}
package/build/store.js ADDED
@@ -0,0 +1,75 @@
1
+ const SESSION_TTL_MS = 10 * 60 * 1000; // 10 minutes
2
+ const CLEANUP_INTERVAL_MS = 60 * 1000; // 60 seconds
3
+ const MAX_SESSIONS_PER_IP = 5;
4
+ const sessions = new Map();
5
+ let cleanupTimer = null;
6
+ export function getSession(id) {
7
+ const session = sessions.get(id);
8
+ if (session && Date.now() - session.createdAt > SESSION_TTL_MS) {
9
+ sessions.delete(id);
10
+ return undefined;
11
+ }
12
+ return session;
13
+ }
14
+ export function createSession(id, serverName, schema, sourceIp) {
15
+ const ipCount = countSessionsByIp(sourceIp);
16
+ if (ipCount >= MAX_SESSIONS_PER_IP) {
17
+ return null;
18
+ }
19
+ const session = {
20
+ id,
21
+ serverName,
22
+ schema,
23
+ result: null,
24
+ createdAt: Date.now(),
25
+ sourceIp
26
+ };
27
+ sessions.set(id, session);
28
+ return session;
29
+ }
30
+ export function setSessionResult(id, result) {
31
+ const session = getSession(id);
32
+ if (!session)
33
+ return false;
34
+ if (session.result !== null)
35
+ return false;
36
+ session.result = result;
37
+ return true;
38
+ }
39
+ export function deleteSession(id) {
40
+ return sessions.delete(id);
41
+ }
42
+ function countSessionsByIp(ip) {
43
+ const now = Date.now();
44
+ let count = 0;
45
+ for (const session of sessions.values()) {
46
+ if (session.sourceIp === ip && now - session.createdAt <= SESSION_TTL_MS) {
47
+ count++;
48
+ }
49
+ }
50
+ return count;
51
+ }
52
+ function cleanup() {
53
+ const now = Date.now();
54
+ for (const [id, session] of sessions) {
55
+ if (now - session.createdAt > SESSION_TTL_MS) {
56
+ sessions.delete(id);
57
+ }
58
+ }
59
+ }
60
+ export function startCleanup() {
61
+ if (!cleanupTimer) {
62
+ cleanupTimer = setInterval(cleanup, CLEANUP_INTERVAL_MS);
63
+ cleanupTimer.unref?.();
64
+ }
65
+ }
66
+ export function stopCleanup() {
67
+ if (cleanupTimer) {
68
+ clearInterval(cleanupTimer);
69
+ cleanupTimer = null;
70
+ }
71
+ }
72
+ export function clearAllSessions() {
73
+ sessions.clear();
74
+ }
75
+ //# sourceMappingURL=store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.js","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@n24q02m/mcp-relay-server",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "build/index.js",
6
+ "scripts": {
7
+ "build": "tsc --build tsconfig.build.json",
8
+ "dev": "tsx watch src/index.ts",
9
+ "start": "node build/index.js",
10
+ "test": "vitest --passWithNoTests",
11
+ "test:coverage": "vitest --coverage",
12
+ "check": "biome check . && bun run type-check",
13
+ "type-check": "tsc --noEmit"
14
+ },
15
+ "dependencies": {
16
+ "express": "^5.2.1",
17
+ "express-rate-limit": "^8.3.1",
18
+ "cors": "^2.8.5"
19
+ },
20
+ "devDependencies": {
21
+ "@biomejs/biome": "^2.4.8",
22
+ "@types/cors": "^2.8.17",
23
+ "@types/express": "^5.0.6",
24
+ "@types/node": "^24.12.0",
25
+ "@vitest/coverage-v8": "^4.1.0",
26
+ "tsx": "^4.21.0",
27
+ "typescript": "^5.9.3",
28
+ "vitest": "^4.1.0"
29
+ },
30
+ "engines": {
31
+ "node": ">=24.14.0"
32
+ },
33
+ "packageManager": "bun@1.2.21"
34
+ }
package/src/app.ts ADDED
@@ -0,0 +1,22 @@
1
+ import cors from 'cors'
2
+ import express from 'express'
3
+ import { rateLimiter } from './middleware/rate-limit.js'
4
+ import { sessionsRouter } from './routes/sessions.js'
5
+
6
+ export function createApp(): express.Express {
7
+ const app = express()
8
+
9
+ const corsOrigin = process.env.CORS_ORIGIN ?? '*'
10
+ app.use(cors({ origin: corsOrigin }))
11
+ app.use(express.json({ limit: '1mb' }))
12
+ app.use(rateLimiter)
13
+
14
+ app.use('/api/sessions', sessionsRouter)
15
+
16
+ const pagesDir = process.env.PAGES_DIR
17
+ if (pagesDir) {
18
+ app.use(express.static(pagesDir))
19
+ }
20
+
21
+ return app
22
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { createApp } from './app.js'
2
+ import { startCleanup } from './store.js'
3
+
4
+ export { createApp } from './app.js'
5
+ export { startLocalRelay } from './local.js'
6
+
7
+ const port = Number.parseInt(process.env.PORT ?? '3000', 10)
8
+ const app = createApp()
9
+
10
+ startCleanup()
11
+
12
+ app.listen(port, () => {
13
+ console.log(`Relay server listening on port ${port}`)
14
+ })
package/src/local.ts ADDED
@@ -0,0 +1,19 @@
1
+ import type { AddressInfo } from 'node:net'
2
+ import express from 'express'
3
+ import { createApp } from './app.js'
4
+
5
+ export async function startLocalRelay(pagesDir: string): Promise<{ port: number; url: string; close: () => void }> {
6
+ const app = createApp()
7
+ app.use(express.static(pagesDir))
8
+
9
+ return new Promise((resolve) => {
10
+ const server = app.listen(0, () => {
11
+ const addr = server.address() as AddressInfo
12
+ resolve({
13
+ port: addr.port,
14
+ url: `http://localhost:${addr.port}`,
15
+ close: () => server.close()
16
+ })
17
+ })
18
+ })
19
+ }
File without changes
@@ -0,0 +1,9 @@
1
+ import rateLimit from 'express-rate-limit'
2
+
3
+ export const rateLimiter = rateLimit({
4
+ windowMs: 60 * 1000, // 1 minute
5
+ limit: 30,
6
+ standardHeaders: 'draft-7',
7
+ legacyHeaders: false,
8
+ message: { error: 'Too many requests, please try again later' }
9
+ })
File without changes
@@ -0,0 +1,84 @@
1
+ import type { Request, Response } from 'express'
2
+ import { Router } from 'express'
3
+ import { createSession, deleteSession, getSession, setSessionResult } from '../store.js'
4
+
5
+ function paramId(req: Request): string {
6
+ const id = req.params.id
7
+ return Array.isArray(id) ? id[0] : id
8
+ }
9
+
10
+ export const sessionsRouter: ReturnType<typeof Router> = Router()
11
+
12
+ sessionsRouter.post('/', (req: Request, res: Response) => {
13
+ const { sessionId, serverName, schema } = req.body as {
14
+ sessionId?: string
15
+ serverName?: string
16
+ schema?: unknown
17
+ }
18
+
19
+ if (!sessionId || !serverName) {
20
+ res.status(400).json({ error: 'sessionId and serverName are required' })
21
+ return
22
+ }
23
+
24
+ const sourceIp = req.ip ?? req.socket.remoteAddress ?? 'unknown'
25
+ const session = createSession(sessionId, serverName, schema, sourceIp)
26
+
27
+ if (!session) {
28
+ res.status(429).json({ error: 'Too many active sessions for this IP' })
29
+ return
30
+ }
31
+
32
+ res.status(201).json({ sessionId: session.id })
33
+ })
34
+
35
+ sessionsRouter.get('/:id', (req: Request, res: Response) => {
36
+ const session = getSession(paramId(req))
37
+
38
+ if (!session) {
39
+ res.status(404).json({ error: 'Session not found or expired' })
40
+ return
41
+ }
42
+
43
+ if (session.result === null) {
44
+ res
45
+ .status(202)
46
+ .json({ status: 'pending', sessionId: session.id, serverName: session.serverName, schema: session.schema })
47
+ return
48
+ }
49
+
50
+ res.status(200).json({ status: 'ready', result: session.result })
51
+ })
52
+
53
+ sessionsRouter.post('/:id/result', (req: Request, res: Response) => {
54
+ const { browserPub, ciphertext, iv, tag } = req.body as {
55
+ browserPub?: string
56
+ ciphertext?: string
57
+ iv?: string
58
+ tag?: string
59
+ }
60
+
61
+ if (!browserPub || !ciphertext || !iv || !tag) {
62
+ res.status(400).json({ error: 'browserPub, ciphertext, iv, and tag are required' })
63
+ return
64
+ }
65
+
66
+ const session = getSession(paramId(req))
67
+ if (!session) {
68
+ res.status(404).json({ error: 'Session not found or expired' })
69
+ return
70
+ }
71
+
72
+ const success = setSessionResult(paramId(req), { browserPub, ciphertext, iv, tag })
73
+ if (!success) {
74
+ res.status(409).json({ error: 'Result already submitted' })
75
+ return
76
+ }
77
+
78
+ res.status(200).json({ ok: true })
79
+ })
80
+
81
+ sessionsRouter.delete('/:id', (req: Request, res: Response) => {
82
+ deleteSession(paramId(req))
83
+ res.status(204).send()
84
+ })
package/src/store.ts ADDED
@@ -0,0 +1,99 @@
1
+ export interface SessionResult {
2
+ browserPub: string
3
+ ciphertext: string
4
+ iv: string
5
+ tag: string
6
+ }
7
+
8
+ export interface Session {
9
+ id: string
10
+ serverName: string
11
+ schema: unknown
12
+ result: SessionResult | null
13
+ createdAt: number
14
+ sourceIp: string
15
+ }
16
+
17
+ const SESSION_TTL_MS = 10 * 60 * 1000 // 10 minutes
18
+ const CLEANUP_INTERVAL_MS = 60 * 1000 // 60 seconds
19
+ const MAX_SESSIONS_PER_IP = 5
20
+
21
+ const sessions = new Map<string, Session>()
22
+ let cleanupTimer: ReturnType<typeof setInterval> | null = null
23
+
24
+ export function getSession(id: string): Session | undefined {
25
+ const session = sessions.get(id)
26
+ if (session && Date.now() - session.createdAt > SESSION_TTL_MS) {
27
+ sessions.delete(id)
28
+ return undefined
29
+ }
30
+ return session
31
+ }
32
+
33
+ export function createSession(id: string, serverName: string, schema: unknown, sourceIp: string): Session | null {
34
+ const ipCount = countSessionsByIp(sourceIp)
35
+ if (ipCount >= MAX_SESSIONS_PER_IP) {
36
+ return null
37
+ }
38
+
39
+ const session: Session = {
40
+ id,
41
+ serverName,
42
+ schema,
43
+ result: null,
44
+ createdAt: Date.now(),
45
+ sourceIp
46
+ }
47
+ sessions.set(id, session)
48
+ return session
49
+ }
50
+
51
+ export function setSessionResult(id: string, result: SessionResult): boolean {
52
+ const session = getSession(id)
53
+ if (!session) return false
54
+ if (session.result !== null) return false
55
+ session.result = result
56
+ return true
57
+ }
58
+
59
+ export function deleteSession(id: string): boolean {
60
+ return sessions.delete(id)
61
+ }
62
+
63
+ function countSessionsByIp(ip: string): number {
64
+ const now = Date.now()
65
+ let count = 0
66
+ for (const session of sessions.values()) {
67
+ if (session.sourceIp === ip && now - session.createdAt <= SESSION_TTL_MS) {
68
+ count++
69
+ }
70
+ }
71
+ return count
72
+ }
73
+
74
+ function cleanup(): void {
75
+ const now = Date.now()
76
+ for (const [id, session] of sessions) {
77
+ if (now - session.createdAt > SESSION_TTL_MS) {
78
+ sessions.delete(id)
79
+ }
80
+ }
81
+ }
82
+
83
+ export function startCleanup(): void {
84
+ if (!cleanupTimer) {
85
+ cleanupTimer = setInterval(cleanup, CLEANUP_INTERVAL_MS)
86
+ cleanupTimer.unref?.()
87
+ }
88
+ }
89
+
90
+ export function stopCleanup(): void {
91
+ if (cleanupTimer) {
92
+ clearInterval(cleanupTimer)
93
+ cleanupTimer = null
94
+ }
95
+ }
96
+
97
+ export function clearAllSessions(): void {
98
+ sessions.clear()
99
+ }
package/tests/.gitkeep ADDED
File without changes
@@ -0,0 +1,49 @@
1
+ import { createServer, type Server } from 'node:http'
2
+ import type { AddressInfo } from 'node:net'
3
+ import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
4
+ import { createApp } from '../src/app.js'
5
+ import { clearAllSessions } from '../src/store.js'
6
+
7
+ let server: Server
8
+ let baseUrl: string
9
+
10
+ beforeAll(async () => {
11
+ const app = createApp()
12
+ server = createServer(app)
13
+ await new Promise<void>((resolve) => {
14
+ server.listen(0, () => {
15
+ const addr = server.address() as AddressInfo
16
+ baseUrl = `http://127.0.0.1:${addr.port}`
17
+ resolve()
18
+ })
19
+ })
20
+ })
21
+
22
+ afterEach(() => {
23
+ clearAllSessions()
24
+ })
25
+
26
+ afterAll(async () => {
27
+ await new Promise<void>((resolve) => {
28
+ server.close(() => resolve())
29
+ })
30
+ })
31
+
32
+ describe('Rate limiting', () => {
33
+ it('rate limit kicks in after 30 requests per minute', async () => {
34
+ // Send 30 requests (all should succeed with 404 since no session exists)
35
+ const results: number[] = []
36
+ for (let i = 0; i < 31; i++) {
37
+ const res = await fetch(`${baseUrl}/api/sessions/rate-test-${i}`)
38
+ results.push(res.status)
39
+ }
40
+
41
+ // First 30 should be 404 (valid response, session not found)
42
+ for (let i = 0; i < 30; i++) {
43
+ expect(results[i]).toBe(404)
44
+ }
45
+
46
+ // 31st should be rate limited
47
+ expect(results[30]).toBe(429)
48
+ })
49
+ })
@@ -0,0 +1,50 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { clearAllSessions, createSession, getSession, startCleanup, stopCleanup } from '../src/store.js'
3
+
4
+ describe('Session TTL', () => {
5
+ beforeEach(() => {
6
+ vi.useFakeTimers()
7
+ clearAllSessions()
8
+ })
9
+
10
+ afterEach(() => {
11
+ stopCleanup()
12
+ vi.useRealTimers()
13
+ })
14
+
15
+ it('sessions expire after 10 minutes', () => {
16
+ createSession('ttl-1', 'telegram', {}, '127.0.0.1')
17
+ expect(getSession('ttl-1')).toBeDefined()
18
+
19
+ // Advance 9 minutes - should still exist
20
+ vi.advanceTimersByTime(9 * 60 * 1000)
21
+ expect(getSession('ttl-1')).toBeDefined()
22
+
23
+ // Advance past 10 minutes
24
+ vi.advanceTimersByTime(2 * 60 * 1000)
25
+ expect(getSession('ttl-1')).toBeUndefined()
26
+ })
27
+
28
+ it('cleanup runs every 60 seconds and removes expired sessions', () => {
29
+ startCleanup()
30
+
31
+ createSession('cleanup-1', 'telegram', {}, '127.0.0.1')
32
+
33
+ // Advance 11 minutes (past TTL + multiple cleanup cycles)
34
+ vi.advanceTimersByTime(11 * 60 * 1000)
35
+
36
+ // After cleanup, session should be gone
37
+ expect(getSession('cleanup-1')).toBeUndefined()
38
+ })
39
+
40
+ it('cleanup does not remove sessions within TTL', () => {
41
+ startCleanup()
42
+
43
+ createSession('alive-1', 'telegram', {}, '127.0.0.1')
44
+
45
+ // Advance 2 cleanup cycles (120s) - well within 10 min TTL
46
+ vi.advanceTimersByTime(2 * 60 * 1000)
47
+
48
+ expect(getSession('alive-1')).toBeDefined()
49
+ })
50
+ })
@@ -0,0 +1,189 @@
1
+ import { createServer, type Server } from 'node:http'
2
+ import type { AddressInfo } from 'node:net'
3
+ import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
4
+ import { createApp } from '../src/app.js'
5
+ import { clearAllSessions } from '../src/store.js'
6
+
7
+ let server: Server
8
+ let baseUrl: string
9
+
10
+ beforeAll(async () => {
11
+ const app = createApp()
12
+ server = createServer(app)
13
+ await new Promise<void>((resolve) => {
14
+ server.listen(0, () => {
15
+ const addr = server.address() as AddressInfo
16
+ baseUrl = `http://127.0.0.1:${addr.port}`
17
+ resolve()
18
+ })
19
+ })
20
+ })
21
+
22
+ afterEach(() => {
23
+ clearAllSessions()
24
+ })
25
+
26
+ afterAll(async () => {
27
+ await new Promise<void>((resolve) => {
28
+ server.close(() => resolve())
29
+ })
30
+ })
31
+
32
+ describe('POST /api/sessions', () => {
33
+ it('creates a session and returns 201', async () => {
34
+ const res = await fetch(`${baseUrl}/api/sessions`, {
35
+ method: 'POST',
36
+ headers: { 'Content-Type': 'application/json' },
37
+ body: JSON.stringify({ sessionId: 'test-1', serverName: 'telegram', schema: { token: 'string' } })
38
+ })
39
+ expect(res.status).toBe(201)
40
+ const body = await res.json()
41
+ expect(body.sessionId).toBe('test-1')
42
+ })
43
+
44
+ it('rejects after 5 sessions from the same IP (429)', async () => {
45
+ for (let i = 0; i < 5; i++) {
46
+ const res = await fetch(`${baseUrl}/api/sessions`, {
47
+ method: 'POST',
48
+ headers: { 'Content-Type': 'application/json' },
49
+ body: JSON.stringify({ sessionId: `s-${i}`, serverName: 'telegram', schema: {} })
50
+ })
51
+ expect(res.status).toBe(201)
52
+ }
53
+
54
+ const res = await fetch(`${baseUrl}/api/sessions`, {
55
+ method: 'POST',
56
+ headers: { 'Content-Type': 'application/json' },
57
+ body: JSON.stringify({ sessionId: 's-6', serverName: 'telegram', schema: {} })
58
+ })
59
+ expect(res.status).toBe(429)
60
+ })
61
+
62
+ it('returns 400 when sessionId or serverName is missing', async () => {
63
+ const res = await fetch(`${baseUrl}/api/sessions`, {
64
+ method: 'POST',
65
+ headers: { 'Content-Type': 'application/json' },
66
+ body: JSON.stringify({ sessionId: 'test-bad' })
67
+ })
68
+ expect(res.status).toBe(400)
69
+ })
70
+ })
71
+
72
+ describe('GET /api/sessions/:id', () => {
73
+ it('returns 202 when session is pending', async () => {
74
+ await fetch(`${baseUrl}/api/sessions`, {
75
+ method: 'POST',
76
+ headers: { 'Content-Type': 'application/json' },
77
+ body: JSON.stringify({ sessionId: 'pending-1', serverName: 'telegram', schema: {} })
78
+ })
79
+
80
+ const res = await fetch(`${baseUrl}/api/sessions/pending-1`)
81
+ expect(res.status).toBe(202)
82
+ const body = await res.json()
83
+ expect(body.status).toBe('pending')
84
+ expect(body.serverName).toBe('telegram')
85
+ })
86
+
87
+ it('returns 200 with result when ready', async () => {
88
+ await fetch(`${baseUrl}/api/sessions`, {
89
+ method: 'POST',
90
+ headers: { 'Content-Type': 'application/json' },
91
+ body: JSON.stringify({ sessionId: 'ready-1', serverName: 'telegram', schema: {} })
92
+ })
93
+
94
+ await fetch(`${baseUrl}/api/sessions/ready-1/result`, {
95
+ method: 'POST',
96
+ headers: { 'Content-Type': 'application/json' },
97
+ body: JSON.stringify({ browserPub: 'pub', ciphertext: 'ct', iv: 'iv', tag: 'tag' })
98
+ })
99
+
100
+ const res = await fetch(`${baseUrl}/api/sessions/ready-1`)
101
+ expect(res.status).toBe(200)
102
+ const body = await res.json()
103
+ expect(body.status).toBe('ready')
104
+ expect(body.result.ciphertext).toBe('ct')
105
+ })
106
+
107
+ it('returns 404 for unknown session', async () => {
108
+ const res = await fetch(`${baseUrl}/api/sessions/nonexistent`)
109
+ expect(res.status).toBe(404)
110
+ })
111
+ })
112
+
113
+ describe('POST /api/sessions/:id/result', () => {
114
+ it('stores result (200)', async () => {
115
+ await fetch(`${baseUrl}/api/sessions`, {
116
+ method: 'POST',
117
+ headers: { 'Content-Type': 'application/json' },
118
+ body: JSON.stringify({ sessionId: 'res-1', serverName: 'telegram', schema: {} })
119
+ })
120
+
121
+ const res = await fetch(`${baseUrl}/api/sessions/res-1/result`, {
122
+ method: 'POST',
123
+ headers: { 'Content-Type': 'application/json' },
124
+ body: JSON.stringify({ browserPub: 'pub', ciphertext: 'ct', iv: 'iv', tag: 'tag' })
125
+ })
126
+ expect(res.status).toBe(200)
127
+ })
128
+
129
+ it('rejects second submission with 409 (one-shot)', async () => {
130
+ await fetch(`${baseUrl}/api/sessions`, {
131
+ method: 'POST',
132
+ headers: { 'Content-Type': 'application/json' },
133
+ body: JSON.stringify({ sessionId: 'oneshot-1', serverName: 'telegram', schema: {} })
134
+ })
135
+
136
+ await fetch(`${baseUrl}/api/sessions/oneshot-1/result`, {
137
+ method: 'POST',
138
+ headers: { 'Content-Type': 'application/json' },
139
+ body: JSON.stringify({ browserPub: 'pub', ciphertext: 'ct', iv: 'iv', tag: 'tag' })
140
+ })
141
+
142
+ const res = await fetch(`${baseUrl}/api/sessions/oneshot-1/result`, {
143
+ method: 'POST',
144
+ headers: { 'Content-Type': 'application/json' },
145
+ body: JSON.stringify({ browserPub: 'pub2', ciphertext: 'ct2', iv: 'iv2', tag: 'tag2' })
146
+ })
147
+ expect(res.status).toBe(409)
148
+ })
149
+
150
+ it('returns 404 for unknown session', async () => {
151
+ const res = await fetch(`${baseUrl}/api/sessions/nonexistent/result`, {
152
+ method: 'POST',
153
+ headers: { 'Content-Type': 'application/json' },
154
+ body: JSON.stringify({ browserPub: 'pub', ciphertext: 'ct', iv: 'iv', tag: 'tag' })
155
+ })
156
+ expect(res.status).toBe(404)
157
+ })
158
+
159
+ it('returns 400 when required fields are missing', async () => {
160
+ await fetch(`${baseUrl}/api/sessions`, {
161
+ method: 'POST',
162
+ headers: { 'Content-Type': 'application/json' },
163
+ body: JSON.stringify({ sessionId: 'bad-res-1', serverName: 'telegram', schema: {} })
164
+ })
165
+
166
+ const res = await fetch(`${baseUrl}/api/sessions/bad-res-1/result`, {
167
+ method: 'POST',
168
+ headers: { 'Content-Type': 'application/json' },
169
+ body: JSON.stringify({ browserPub: 'pub' })
170
+ })
171
+ expect(res.status).toBe(400)
172
+ })
173
+ })
174
+
175
+ describe('DELETE /api/sessions/:id', () => {
176
+ it('removes session (204)', async () => {
177
+ await fetch(`${baseUrl}/api/sessions`, {
178
+ method: 'POST',
179
+ headers: { 'Content-Type': 'application/json' },
180
+ body: JSON.stringify({ sessionId: 'del-1', serverName: 'telegram', schema: {} })
181
+ })
182
+
183
+ const res = await fetch(`${baseUrl}/api/sessions/del-1`, { method: 'DELETE' })
184
+ expect(res.status).toBe(204)
185
+
186
+ const getRes = await fetch(`${baseUrl}/api/sessions/del-1`)
187
+ expect(getRes.status).toBe(404)
188
+ })
189
+ })
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src"
5
+ },
6
+ "include": ["src"],
7
+ "exclude": ["tests", "**/*.test.ts"]
8
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2021",
4
+ "lib": ["es2022", "dom"],
5
+ "types": ["node"],
6
+ "module": "es2022",
7
+ "moduleResolution": "Bundler",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "allowSyntheticDefaultImports": true,
11
+ "outDir": "build",
12
+ "declaration": true,
13
+ "declarationMap": true,
14
+ "sourceMap": true,
15
+ "skipLibCheck": true
16
+ },
17
+ "include": ["src", "tests"]
18
+ }