@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 +3 -0
- package/build/app.d.ts.map +1 -0
- package/build/app.js +18 -0
- package/build/app.js.map +1 -0
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +11 -0
- package/build/index.js.map +1 -0
- package/build/local.d.ts +6 -0
- package/build/local.d.ts.map +1 -0
- package/build/local.js +17 -0
- package/build/local.js.map +1 -0
- package/build/middleware/rate-limit.d.ts +2 -0
- package/build/middleware/rate-limit.d.ts.map +1 -0
- package/build/middleware/rate-limit.js +9 -0
- package/build/middleware/rate-limit.js.map +1 -0
- package/build/routes/sessions.d.ts +3 -0
- package/build/routes/sessions.d.ts.map +1 -0
- package/build/routes/sessions.js +58 -0
- package/build/routes/sessions.js.map +1 -0
- package/build/store.d.ts +22 -0
- package/build/store.d.ts.map +1 -0
- package/build/store.js +75 -0
- package/build/store.js.map +1 -0
- package/package.json +34 -0
- package/src/app.ts +22 -0
- package/src/index.ts +14 -0
- package/src/local.ts +19 -0
- package/src/middleware/.gitkeep +0 -0
- package/src/middleware/rate-limit.ts +9 -0
- package/src/routes/.gitkeep +0 -0
- package/src/routes/sessions.ts +84 -0
- package/src/store.ts +99 -0
- package/tests/.gitkeep +0 -0
- package/tests/rate-limit.test.ts +49 -0
- package/tests/session-ttl.test.ts +50 -0
- package/tests/sessions.test.ts +189 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +18 -0
package/build/app.d.ts
ADDED
|
@@ -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
|
package/build/app.js.map
ADDED
|
@@ -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"}
|
package/build/index.d.ts
ADDED
|
@@ -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"}
|
package/build/local.d.ts
ADDED
|
@@ -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 @@
|
|
|
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 @@
|
|
|
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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"}
|
package/build/store.d.ts
ADDED
|
@@ -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
|
|
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
|
+
})
|
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
|
+
}
|