@opentrust/gateway 7.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.
Files changed (66) hide show
  1. package/dist/config.d.ts +7 -0
  2. package/dist/config.d.ts.map +1 -0
  3. package/dist/config.js +81 -0
  4. package/dist/config.js.map +1 -0
  5. package/dist/handlers/anthropic.d.ts +7 -0
  6. package/dist/handlers/anthropic.d.ts.map +1 -0
  7. package/dist/handlers/anthropic.js +49 -0
  8. package/dist/handlers/anthropic.js.map +1 -0
  9. package/dist/handlers/gemini.d.ts +7 -0
  10. package/dist/handlers/gemini.d.ts.map +1 -0
  11. package/dist/handlers/gemini.js +41 -0
  12. package/dist/handlers/gemini.js.map +1 -0
  13. package/dist/handlers/models.d.ts +7 -0
  14. package/dist/handlers/models.d.ts.map +1 -0
  15. package/dist/handlers/models.js +29 -0
  16. package/dist/handlers/models.js.map +1 -0
  17. package/dist/handlers/openai.d.ts +11 -0
  18. package/dist/handlers/openai.d.ts.map +1 -0
  19. package/dist/handlers/openai.js +45 -0
  20. package/dist/handlers/openai.js.map +1 -0
  21. package/dist/index.d.ts +12 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +126 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/restorer.d.ts +8 -0
  26. package/dist/restorer.d.ts.map +1 -0
  27. package/dist/restorer.js +54 -0
  28. package/dist/restorer.js.map +1 -0
  29. package/dist/sanitizer/core.d.ts +16 -0
  30. package/dist/sanitizer/core.d.ts.map +1 -0
  31. package/dist/sanitizer/core.js +77 -0
  32. package/dist/sanitizer/core.js.map +1 -0
  33. package/dist/sanitizer/index.d.ts +3 -0
  34. package/dist/sanitizer/index.d.ts.map +1 -0
  35. package/dist/sanitizer/index.js +3 -0
  36. package/dist/sanitizer/index.js.map +1 -0
  37. package/dist/sanitizer/reversible.d.ts +8 -0
  38. package/dist/sanitizer/reversible.d.ts.map +1 -0
  39. package/dist/sanitizer/reversible.js +62 -0
  40. package/dist/sanitizer/reversible.js.map +1 -0
  41. package/dist/types.d.ts +41 -0
  42. package/dist/types.d.ts.map +1 -0
  43. package/dist/types.js +5 -0
  44. package/dist/types.js.map +1 -0
  45. package/dist/utils/http.d.ts +9 -0
  46. package/dist/utils/http.d.ts.map +1 -0
  47. package/dist/utils/http.js +35 -0
  48. package/dist/utils/http.js.map +1 -0
  49. package/dist/utils/stream.d.ts +14 -0
  50. package/dist/utils/stream.d.ts.map +1 -0
  51. package/dist/utils/stream.js +55 -0
  52. package/dist/utils/stream.js.map +1 -0
  53. package/package.json +53 -0
  54. package/src/config.ts +91 -0
  55. package/src/handlers/anthropic.ts +53 -0
  56. package/src/handlers/gemini.ts +46 -0
  57. package/src/handlers/models.ts +31 -0
  58. package/src/handlers/openai.ts +55 -0
  59. package/src/index.ts +115 -0
  60. package/src/restorer.ts +49 -0
  61. package/src/sanitizer/core.ts +102 -0
  62. package/src/sanitizer/index.ts +2 -0
  63. package/src/sanitizer/reversible.ts +78 -0
  64. package/src/types.ts +33 -0
  65. package/src/utils/http.ts +41 -0
  66. package/src/utils/stream.ts +64 -0
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Model list proxy — GET /v1/models
3
+ */
4
+
5
+ import type { ServerResponse } from "node:http";
6
+ import type { GatewayConfig } from "../types.js";
7
+ import { sendError } from "../utils/http.js";
8
+
9
+ export async function handleModelsRequest(
10
+ res: ServerResponse,
11
+ config: GatewayConfig,
12
+ ): Promise<void> {
13
+ try {
14
+ const backend = config.backends.openrouter ?? config.backends.openai;
15
+ if (!backend) { sendError(res, 500, "No OpenAI-compatible backend configured"); return; }
16
+
17
+ const headers: Record<string, string> = { Authorization: `Bearer ${backend.apiKey}` };
18
+ if (config.backends.openrouter) {
19
+ const or = config.backends.openrouter;
20
+ if (or.referer) headers["HTTP-Referer"] = or.referer;
21
+ if (or.title) headers["X-Title"] = or.title;
22
+ }
23
+
24
+ const response = await fetch(`${backend.baseUrl}/v1/models`, { headers });
25
+ res.writeHead(response.status, { "Content-Type": "application/json" });
26
+ res.end(await response.text());
27
+ } catch (error) {
28
+ console.error("[opentrust-gateway] Models request error:", error);
29
+ sendError(res, 500, error instanceof Error ? error.message : String(error));
30
+ }
31
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * OpenAI Chat Completions handler — POST /v1/chat/completions
3
+ * Also compatible with OpenRouter, Kimi, DeepSeek, etc.
4
+ */
5
+
6
+ import type { IncomingMessage, ServerResponse } from "node:http";
7
+ import type { MappingTable } from "../types.js";
8
+ import { sanitize } from "../sanitizer/index.js";
9
+ import { readBody, sendError, forwardError } from "../utils/http.js";
10
+ import { handleSSEStream, handleJSONResponse } from "../utils/stream.js";
11
+
12
+ export type OpenAICompatibleBackend = { baseUrl: string; apiKey: string };
13
+
14
+ export async function handleOpenAIRequest(
15
+ req: IncomingMessage,
16
+ res: ServerResponse,
17
+ backend: OpenAICompatibleBackend,
18
+ extraHeaders?: Record<string, string>,
19
+ ): Promise<void> {
20
+ try {
21
+ const { model, messages, tools, tool_choice, temperature, max_tokens, stream = false, ...rest } =
22
+ JSON.parse(await readBody(req));
23
+
24
+ const { sanitized: sanitizedMessages, mappingTable } = sanitize(messages);
25
+
26
+ const headers: Record<string, string> = {
27
+ "Content-Type": "application/json",
28
+ Authorization: `Bearer ${backend.apiKey}`,
29
+ ...extraHeaders,
30
+ };
31
+
32
+ const response = await fetch(`${backend.baseUrl}/v1/chat/completions`, {
33
+ method: "POST",
34
+ headers,
35
+ body: JSON.stringify({
36
+ model,
37
+ messages: sanitizedMessages,
38
+ ...(tools && { tools }),
39
+ ...(tool_choice && { tool_choice }),
40
+ ...(temperature !== undefined && { temperature }),
41
+ ...(max_tokens && { max_tokens }),
42
+ stream,
43
+ ...rest,
44
+ }),
45
+ });
46
+
47
+ if (!response.ok) { await forwardError(res, response); return; }
48
+
49
+ if (stream) await handleSSEStream(response, res, mappingTable);
50
+ else await handleJSONResponse(response, res, mappingTable);
51
+ } catch (error) {
52
+ console.error("[opentrust-gateway] OpenAI handler error:", error);
53
+ sendError(res, 500, error instanceof Error ? error.message : String(error));
54
+ }
55
+ }
package/src/index.ts ADDED
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * OpenTrust AI Security Gateway
4
+ *
5
+ * Local HTTP proxy that intercepts LLM API calls, sanitizes sensitive data
6
+ * before sending to providers, and restores it in responses.
7
+ */
8
+
9
+ import { createServer } from "node:http";
10
+ import type { IncomingMessage, ServerResponse } from "node:http";
11
+ import { loadConfig, validateConfig } from "./config.js";
12
+ import type { GatewayConfig } from "./types.js";
13
+ import { handleAnthropicRequest } from "./handlers/anthropic.js";
14
+ import { handleOpenAIRequest } from "./handlers/openai.js";
15
+ import { handleGeminiRequest } from "./handlers/gemini.js";
16
+ import { handleModelsRequest } from "./handlers/models.js";
17
+ import { sendJSON, sendError } from "./utils/http.js";
18
+
19
+ const LOG = "[opentrust-gateway]";
20
+ let config: GatewayConfig;
21
+
22
+ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
23
+ const { method, url } = req;
24
+ console.log(`${LOG} ${method} ${url}`);
25
+
26
+ // CORS
27
+ res.setHeader("Access-Control-Allow-Origin", "*");
28
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
29
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, x-api-key, anthropic-version");
30
+
31
+ if (method === "OPTIONS") { res.writeHead(204); res.end(); return; }
32
+
33
+ if (url === "/health") { sendJSON(res, 200, { status: "ok", version: "7.0.0" }); return; }
34
+
35
+ if (method === "GET" && url === "/v1/models") { await handleModelsRequest(res, config); return; }
36
+
37
+ if (method !== "POST") { sendError(res, 405, "Method not allowed"); return; }
38
+
39
+ try {
40
+ if (url === "/v1/messages") {
41
+ await handleAnthropicRequest(req, res, config);
42
+ } else if (url === "/v1/chat/completions") {
43
+ await routeOpenAI(req, res);
44
+ } else if (url?.match(/^\/v1\/models\/(.+):generateContent$/)) {
45
+ const modelName = url.match(/^\/v1\/models\/(.+):generateContent$/)?.[1];
46
+ if (modelName) await handleGeminiRequest(req, res, config, modelName);
47
+ else sendError(res, 404, "Model name required");
48
+ } else {
49
+ sendError(res, 404, `Not found: ${url}`);
50
+ }
51
+ } catch (error) {
52
+ console.error(`${LOG} Request handler error:`, error);
53
+ sendError(res, 500, error instanceof Error ? error.message : String(error));
54
+ }
55
+ }
56
+
57
+ async function routeOpenAI(req: IncomingMessage, res: ServerResponse): Promise<void> {
58
+ const explicit = config.routing?.["/v1/chat/completions"];
59
+
60
+ if (explicit === "openrouter" || (!explicit && config.backends.openrouter)) {
61
+ const backend = config.backends.openrouter;
62
+ if (!backend) { sendError(res, 500, "No OpenRouter backend configured"); return; }
63
+ const extra: Record<string, string> = {};
64
+ if (backend.referer) extra["HTTP-Referer"] = backend.referer;
65
+ if (backend.title) extra["X-Title"] = backend.title;
66
+ await handleOpenAIRequest(req, res, backend, extra);
67
+ } else if (explicit === "openai" || (!explicit && config.backends.openai)) {
68
+ const backend = config.backends.openai;
69
+ if (!backend) { sendError(res, 500, "No OpenAI backend configured"); return; }
70
+ await handleOpenAIRequest(req, res, backend);
71
+ } else {
72
+ sendError(res, 500, "No OpenAI-compatible backend configured");
73
+ }
74
+ }
75
+
76
+ export function startGateway(configPath?: string): void {
77
+ try {
78
+ config = loadConfig(configPath);
79
+ validateConfig(config);
80
+
81
+ console.log(`${LOG} Configuration loaded:`);
82
+ console.log(` Port: ${config.port}`);
83
+ console.log(` Backends: ${Object.keys(config.backends).join(", ") || "(none)"}`);
84
+
85
+ const server = createServer(handleRequest);
86
+ const host = process.env.GATEWAY_HOST || "127.0.0.1";
87
+ server.listen(config.port, host, () => {
88
+ console.log(`${LOG} Listening on http://${host}:${config.port}`);
89
+ console.log(` POST /v1/messages — Anthropic`);
90
+ console.log(` POST /v1/chat/completions — OpenAI / OpenRouter`);
91
+ console.log(` POST /v1/models/:m:generate — Gemini`);
92
+ console.log(` GET /v1/models — List models`);
93
+ console.log(` GET /health — Health check`);
94
+ });
95
+
96
+ const shutdown = () => {
97
+ console.log(`\n${LOG} Shutting down...`);
98
+ server.close(() => process.exit(0));
99
+ };
100
+ process.on("SIGINT", shutdown);
101
+ process.on("SIGTERM", shutdown);
102
+ } catch (error) {
103
+ console.error(`${LOG} Failed to start:`, error);
104
+ process.exit(1);
105
+ }
106
+ }
107
+
108
+ // Re-exports
109
+ export { sanitize, sanitizeMessages } from "./sanitizer/index.js";
110
+ export { restore, restoreJSON, restoreSSELine } from "./restorer.js";
111
+ export type { GatewayConfig, MappingTable, SanitizeResult, EntityMatch } from "./types.js";
112
+
113
+ if (import.meta.url === `file://${process.argv[1]}`) {
114
+ startGateway(process.argv[2]);
115
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Content restorer — replaces placeholders back to original values.
3
+ */
4
+
5
+ import type { MappingTable } from "./types.js";
6
+
7
+ function restoreText(text: string, map: MappingTable): string {
8
+ let out = text;
9
+ const keys = Array.from(map.keys()).sort((a, b) => b.length - a.length);
10
+ for (const k of keys) out = out.split(k).join(map.get(k)!);
11
+ return out;
12
+ }
13
+
14
+ function restoreValue(value: any, map: MappingTable): any {
15
+ if (typeof value === "string") return restoreText(value, map);
16
+ if (Array.isArray(value)) return value.map((v) => restoreValue(v, map));
17
+ if (value !== null && typeof value === "object") {
18
+ const out: any = {};
19
+ for (const [k, v] of Object.entries(value)) out[k] = restoreValue(v, map);
20
+ return out;
21
+ }
22
+ return value;
23
+ }
24
+
25
+ export function restore(content: any, map: MappingTable): any {
26
+ if (map.size === 0) return content;
27
+ return restoreValue(content, map);
28
+ }
29
+
30
+ export function restoreJSON(jsonString: string, map: MappingTable): string {
31
+ if (map.size === 0) return jsonString;
32
+ try {
33
+ return JSON.stringify(restore(JSON.parse(jsonString), map));
34
+ } catch {
35
+ return restoreText(jsonString, map);
36
+ }
37
+ }
38
+
39
+ export function restoreSSELine(line: string, map: MappingTable): string {
40
+ if (map.size === 0) return line;
41
+ if (!line.startsWith("data: ")) return line;
42
+ const data = line.slice(6);
43
+ if (data === "[DONE]") return line;
44
+ try {
45
+ return `data: ${JSON.stringify(restore(JSON.parse(data), map))}\n`;
46
+ } catch {
47
+ return `data: ${restoreText(data, map)}\n`;
48
+ }
49
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Sanitizer core — entity definitions, pattern matching, entropy detection.
3
+ * Shared between gateway (reversible) and guards (one-way).
4
+ */
5
+
6
+ import type { EntityMatch } from "../types.js";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Entity Definitions
10
+ // ---------------------------------------------------------------------------
11
+
12
+ type Entity = {
13
+ category: string;
14
+ categoryKey: string;
15
+ pattern: RegExp;
16
+ };
17
+
18
+ const ENTITIES: Entity[] = [
19
+ { category: "URL", categoryKey: "url", pattern: /https?:\/\/[^\s<>"{}|\\^`\[\]]+/g },
20
+ { category: "EMAIL", categoryKey: "email", pattern: /[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/g },
21
+ { category: "CREDIT_CARD", categoryKey: "credit_card", pattern: /\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/g },
22
+ { category: "BANK_CARD", categoryKey: "bank_card", pattern: /\b\d{16,19}\b/g },
23
+ { category: "SSN", categoryKey: "ssn", pattern: /\b\d{3}-\d{2}-\d{4}\b/g },
24
+ { category: "IBAN", categoryKey: "iban", pattern: /\b[A-Z]{2}\d{2}[A-Z0-9]{4}\d{7}[A-Z0-9]{0,16}\b/g },
25
+ { category: "IP_ADDRESS", categoryKey: "ip", pattern: /\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b/g },
26
+ { category: "PHONE", categoryKey: "phone", pattern: /[+]?[0-9]{1,3}?[-\s.]?[(]?[0-9]{3}[)]?[-\s.][0-9]{3,4}[-\s.][0-9]{4,6}\b/g },
27
+ ];
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Secret detection
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export const SECRET_PREFIXES = [
34
+ "sk-", "sk_", "pk_", "ghp_", "AKIA", "xox", "SG.", "hf_",
35
+ "api-", "token-", "secret-",
36
+ ];
37
+
38
+ const BEARER_PATTERN = /Bearer\s+[A-Za-z0-9\-_.~+/]+=*/g;
39
+
40
+ const SECRET_PREFIX_PATTERN = new RegExp(
41
+ `(?:${SECRET_PREFIXES.map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})[A-Za-z0-9\\-_.~+/]{8,}=*`,
42
+ "g",
43
+ );
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Shannon entropy
47
+ // ---------------------------------------------------------------------------
48
+
49
+ export function shannonEntropy(s: string): number {
50
+ if (s.length === 0) return 0;
51
+ const freq = new Map<string, number>();
52
+ for (const ch of s) freq.set(ch, (freq.get(ch) ?? 0) + 1);
53
+ let entropy = 0;
54
+ for (const count of freq.values()) {
55
+ const p = count / s.length;
56
+ entropy -= p * Math.log2(p);
57
+ }
58
+ return entropy;
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Match collection (public — used by both gateway and guards)
63
+ // ---------------------------------------------------------------------------
64
+
65
+ export function collectMatches(content: string): EntityMatch[] {
66
+ const matches: EntityMatch[] = [];
67
+
68
+ for (const entity of ENTITIES) {
69
+ entity.pattern.lastIndex = 0;
70
+ let m: RegExpExecArray | null;
71
+ while ((m = entity.pattern.exec(content)) !== null) {
72
+ matches.push({ originalText: m[0], category: entity.categoryKey, placeholder: "" });
73
+ }
74
+ }
75
+
76
+ SECRET_PREFIX_PATTERN.lastIndex = 0;
77
+ let m: RegExpExecArray | null;
78
+ while ((m = SECRET_PREFIX_PATTERN.exec(content)) !== null) {
79
+ matches.push({ originalText: m[0], category: "secret", placeholder: "" });
80
+ }
81
+
82
+ BEARER_PATTERN.lastIndex = 0;
83
+ while ((m = BEARER_PATTERN.exec(content)) !== null) {
84
+ matches.push({ originalText: m[0], category: "secret", placeholder: "" });
85
+ }
86
+
87
+ // High-entropy tokens
88
+ const tokenPattern = /\b[A-Za-z0-9\-_.~+/]{20,}={0,3}\b/g;
89
+ tokenPattern.lastIndex = 0;
90
+ while ((m = tokenPattern.exec(content)) !== null) {
91
+ const token = m[0];
92
+ if (matches.some((e) => e.originalText === token)) continue;
93
+ if (/^[a-z]+$/.test(token)) continue;
94
+ if (shannonEntropy(token) >= 4.0) {
95
+ matches.push({ originalText: token, category: "secret", placeholder: "" });
96
+ }
97
+ }
98
+
99
+ return matches;
100
+ }
101
+
102
+ export { ENTITIES };
@@ -0,0 +1,2 @@
1
+ export { collectMatches, shannonEntropy, SECRET_PREFIXES } from "./core.js";
2
+ export { sanitize, sanitizeMessages } from "./reversible.js";
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Reversible sanitizer — replaces sensitive data with numbered placeholders
3
+ * and returns a mapping table for later restoration.
4
+ */
5
+
6
+ import type { SanitizeResult, MappingTable, EntityMatch } from "../types.js";
7
+ import { collectMatches } from "./core.js";
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Text-level sanitization
11
+ // ---------------------------------------------------------------------------
12
+
13
+ function sanitizeText(
14
+ text: string,
15
+ mappingTable: MappingTable,
16
+ counters: Map<string, number>,
17
+ ): string {
18
+ const matches = collectMatches(text);
19
+ if (matches.length === 0) return text;
20
+
21
+ // Deduplicate by original text
22
+ const unique = new Map<string, EntityMatch>();
23
+ for (const m of matches) {
24
+ if (!unique.has(m.originalText)) unique.set(m.originalText, m);
25
+ }
26
+
27
+ // Sort longest-first to avoid partial replacement
28
+ const sorted = [...unique.values()].sort(
29
+ (a, b) => b.originalText.length - a.originalText.length,
30
+ );
31
+
32
+ let sanitized = text;
33
+ for (const match of sorted) {
34
+ const n = (counters.get(match.category) ?? 0) + 1;
35
+ counters.set(match.category, n);
36
+ const placeholder = `__${match.category}_${n}__`;
37
+ const parts = sanitized.split(match.originalText);
38
+ if (parts.length > 1) {
39
+ sanitized = parts.join(placeholder);
40
+ mappingTable.set(placeholder, match.originalText);
41
+ }
42
+ }
43
+ return sanitized;
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Recursive value sanitization
48
+ // ---------------------------------------------------------------------------
49
+
50
+ function sanitizeValue(
51
+ value: any,
52
+ mappingTable: MappingTable,
53
+ counters: Map<string, number>,
54
+ ): any {
55
+ if (typeof value === "string") return sanitizeText(value, mappingTable, counters);
56
+ if (Array.isArray(value)) return value.map((v) => sanitizeValue(v, mappingTable, counters));
57
+ if (value !== null && typeof value === "object") {
58
+ const out: any = {};
59
+ for (const [k, v] of Object.entries(value)) out[k] = sanitizeValue(v, mappingTable, counters);
60
+ return out;
61
+ }
62
+ return value;
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Public API
67
+ // ---------------------------------------------------------------------------
68
+
69
+ export function sanitize(content: any): SanitizeResult {
70
+ const mappingTable: MappingTable = new Map();
71
+ const counters = new Map<string, number>();
72
+ const sanitized = sanitizeValue(content, mappingTable, counters);
73
+ return { sanitized, mappingTable, redactionCount: mappingTable.size };
74
+ }
75
+
76
+ export function sanitizeMessages(messages: any[]): SanitizeResult {
77
+ return sanitize(messages);
78
+ }
package/src/types.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * AI Security Gateway types
3
+ */
4
+
5
+ export type MappingTable = Map<string, string>;
6
+
7
+ export type SanitizeResult = {
8
+ sanitized: any;
9
+ mappingTable: MappingTable;
10
+ redactionCount: number;
11
+ };
12
+
13
+ export type GatewayConfig = {
14
+ port: number;
15
+ backends: {
16
+ anthropic?: { baseUrl: string; apiKey: string };
17
+ openai?: { baseUrl: string; apiKey: string };
18
+ gemini?: { baseUrl: string; apiKey: string };
19
+ openrouter?: {
20
+ baseUrl: string;
21
+ apiKey: string;
22
+ referer?: string;
23
+ title?: string;
24
+ };
25
+ };
26
+ routing?: { [path: string]: keyof GatewayConfig["backends"] };
27
+ };
28
+
29
+ export type EntityMatch = {
30
+ originalText: string;
31
+ category: string;
32
+ placeholder: string;
33
+ };
@@ -0,0 +1,41 @@
1
+ /**
2
+ * HTTP utilities shared across handlers.
3
+ */
4
+
5
+ import type { IncomingMessage, ServerResponse } from "node:http";
6
+
7
+ const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB
8
+
9
+ export function readBody(req: IncomingMessage, maxBytes = MAX_BODY_BYTES): Promise<string> {
10
+ return new Promise((resolve, reject) => {
11
+ let body = "";
12
+ let bytes = 0;
13
+ req.on("data", (chunk: Buffer) => {
14
+ bytes += chunk.length;
15
+ if (bytes > maxBytes) {
16
+ req.destroy();
17
+ reject(new Error(`Request body exceeds ${maxBytes} bytes`));
18
+ return;
19
+ }
20
+ body += chunk.toString();
21
+ });
22
+ req.on("end", () => resolve(body));
23
+ req.on("error", reject);
24
+ });
25
+ }
26
+
27
+ export function sendJSON(res: ServerResponse, status: number, data: unknown): void {
28
+ res.writeHead(status, { "Content-Type": "application/json" });
29
+ res.end(JSON.stringify(data));
30
+ }
31
+
32
+ export function sendError(res: ServerResponse, status: number, message: string): void {
33
+ sendJSON(res, status, { error: message });
34
+ }
35
+
36
+ export function forwardError(res: ServerResponse, upstream: Response): Promise<void> {
37
+ return upstream.text().then((body) => {
38
+ res.writeHead(upstream.status, { "Content-Type": "application/json" });
39
+ res.end(body);
40
+ });
41
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * SSE streaming and JSON response utilities shared across handlers.
3
+ */
4
+
5
+ import type { ServerResponse } from "node:http";
6
+ import type { MappingTable } from "../types.js";
7
+ import { restore, restoreSSELine } from "../restorer.js";
8
+
9
+ /**
10
+ * Pipe an upstream SSE stream to the client, restoring placeholders per line.
11
+ */
12
+ export async function handleSSEStream(
13
+ upstream: Response,
14
+ res: ServerResponse,
15
+ mappingTable: MappingTable,
16
+ ): Promise<void> {
17
+ res.writeHead(200, {
18
+ "Content-Type": "text/event-stream",
19
+ "Cache-Control": "no-cache",
20
+ Connection: "keep-alive",
21
+ });
22
+
23
+ const reader = upstream.body?.getReader();
24
+ if (!reader) { res.end(); return; }
25
+
26
+ const decoder = new TextDecoder();
27
+ let buffer = "";
28
+
29
+ try {
30
+ while (true) {
31
+ const { done, value } = await reader.read();
32
+ if (done) break;
33
+
34
+ buffer += decoder.decode(value, { stream: true });
35
+ const lines = buffer.split("\n");
36
+ buffer = lines.pop() || "";
37
+
38
+ for (const line of lines) {
39
+ if (!line.trim()) { res.write("\n"); continue; }
40
+ res.write(restoreSSELine(line, mappingTable) + "\n");
41
+ }
42
+ }
43
+ if (buffer.trim()) {
44
+ res.write(restoreSSELine(buffer, mappingTable) + "\n");
45
+ }
46
+ } catch (err) {
47
+ console.error("[opentrust-gateway] Stream error:", err);
48
+ }
49
+ res.end();
50
+ }
51
+
52
+ /**
53
+ * Read a full JSON response from upstream, restore placeholders, and send it back.
54
+ */
55
+ export async function handleJSONResponse(
56
+ upstream: Response,
57
+ res: ServerResponse,
58
+ mappingTable: MappingTable,
59
+ ): Promise<void> {
60
+ const data = JSON.parse(await upstream.text());
61
+ const restored = restore(data, mappingTable);
62
+ res.writeHead(200, { "Content-Type": "application/json" });
63
+ res.end(JSON.stringify(restored));
64
+ }