tova 0.4.6 → 0.4.7

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.
@@ -98,6 +98,43 @@ export class ServerCodegen extends BaseCodegen {
98
98
  return checks;
99
99
  }
100
100
 
101
+ // Generate nested validation checks for a type, recursing into nested types
102
+ _genNestedTypeValidation(sharedTypes, objExpr, typeInfo, prefix, indent = ' ') {
103
+ const checks = [];
104
+ for (const f of typeInfo.fields) {
105
+ if (f.name === 'id') continue;
106
+ const fieldPath = prefix ? `${prefix}.${f.name}` : f.name;
107
+ const fieldAccess = `${objExpr}.${f.name}`;
108
+ // Check if this field type references another shared type (nested object)
109
+ if (sharedTypes.has(f.type)) {
110
+ const nestedType = sharedTypes.get(f.type);
111
+ checks.push(`${indent}if (${fieldAccess} !== undefined && ${fieldAccess} !== null) {`);
112
+ checks.push(`${indent} if (typeof ${fieldAccess} !== "object" || Array.isArray(${fieldAccess})) __bodyTypeErrors.push("${fieldPath} must be an object");`);
113
+ checks.push(`${indent} else {`);
114
+ const nestedChecks = this._genNestedTypeValidation(sharedTypes, fieldAccess, nestedType, fieldPath, indent + ' ');
115
+ checks.push(...nestedChecks);
116
+ checks.push(`${indent} }`);
117
+ checks.push(`${indent}}`);
118
+ } else {
119
+ switch (f.type) {
120
+ case 'String':
121
+ checks.push(`${indent}if (${fieldAccess} !== undefined && typeof ${fieldAccess} !== "string") __bodyTypeErrors.push("${fieldPath} must be a string");`);
122
+ break;
123
+ case 'Int':
124
+ checks.push(`${indent}if (${fieldAccess} !== undefined && !Number.isInteger(${fieldAccess})) __bodyTypeErrors.push("${fieldPath} must be an integer");`);
125
+ break;
126
+ case 'Float':
127
+ checks.push(`${indent}if (${fieldAccess} !== undefined && typeof ${fieldAccess} !== "number") __bodyTypeErrors.push("${fieldPath} must be a number");`);
128
+ break;
129
+ case 'Bool':
130
+ checks.push(`${indent}if (${fieldAccess} !== undefined && typeof ${fieldAccess} !== "boolean") __bodyTypeErrors.push("${fieldPath} must be a boolean");`);
131
+ break;
132
+ }
133
+ }
134
+ }
135
+ return checks;
136
+ }
137
+
101
138
  // Emit handler call, optionally wrapped in Promise.race for timeout
102
139
  _emitHandlerCall(lines, callExpr, timeoutMs) {
103
140
  if (timeoutMs) {
@@ -108,7 +145,7 @@ export class ServerCodegen extends BaseCodegen {
108
145
  lines.push(` new Promise((_, rej) => setTimeout(() => rej(new Error("__timeout__")), ${timeoutMs}))`);
109
146
  lines.push(` ]);`);
110
147
  lines.push(` } catch (__err) {`);
111
- lines.push(` if (__err.message === "__timeout__") return Response.json({ error: "Gateway Timeout" }, { status: 504 });`);
148
+ lines.push(` if (__err.message === "__timeout__") return __errorResponse(504, "GATEWAY_TIMEOUT", "Gateway Timeout");`);
112
149
  lines.push(` throw __err;`);
113
150
  lines.push(` }`);
114
151
  } else {
@@ -132,6 +169,7 @@ export class ServerCodegen extends BaseCodegen {
132
169
  const middlewares = [];
133
170
  const otherStatements = [];
134
171
  let healthPath = null;
172
+ let healthChecks = null;
135
173
  let corsConfig = null;
136
174
  let errorHandler = null;
137
175
  let wsDecl = null;
@@ -180,6 +218,10 @@ export class ServerCodegen extends BaseCodegen {
180
218
  middlewares.push(stmt);
181
219
  } else if (stmt.type === 'HealthCheckDeclaration') {
182
220
  healthPath = stmt.path;
221
+ if (stmt.checks && stmt.checks.length > 0) {
222
+ if (!healthChecks) healthChecks = [];
223
+ healthChecks.push(...stmt.checks);
224
+ }
183
225
  } else if (stmt.type === 'CorsDeclaration') {
184
226
  corsConfig = stmt.config;
185
227
  } else if (stmt.type === 'ErrorHandlerDeclaration') {
@@ -702,7 +744,17 @@ export class ServerCodegen extends BaseCodegen {
702
744
  lines.push(' return obj;');
703
745
  lines.push(' } catch { return null; }');
704
746
  lines.push(' }');
705
- lines.push(' try { return JSON.parse(text); } catch { return null; }');
747
+ lines.push(' try { return __sanitizeBody(JSON.parse(text)); } catch { return null; }');
748
+ lines.push('}');
749
+ lines.push('function __sanitizeBody(obj) {');
750
+ lines.push(' if (obj === null || typeof obj !== "object") return obj;');
751
+ lines.push(' if (Array.isArray(obj)) return obj.map(__sanitizeBody);');
752
+ lines.push(' const clean = {};');
753
+ lines.push(' for (const key of Object.keys(obj)) {');
754
+ lines.push(' if (key === "__proto__" || key === "constructor" || key === "prototype") continue;');
755
+ lines.push(' clean[key] = __sanitizeBody(obj[key]);');
756
+ lines.push(' }');
757
+ lines.push(' return clean;');
706
758
  lines.push('}');
707
759
 
708
760
  // ════════════════════════════════════════════════════════════
@@ -763,11 +815,19 @@ export class ServerCodegen extends BaseCodegen {
763
815
  lines.push('}');
764
816
  lines.push('');
765
817
 
818
+ // ── Structured Error Response ──
819
+ lines.push('function __errorResponse(status, code, message, details, headers) {');
820
+ lines.push(' const body = { error: { code, message } };');
821
+ lines.push(' if (details !== undefined && details !== null) body.error.details = details;');
822
+ lines.push(' return Response.json(body, { status, headers: headers || {} });');
823
+ lines.push('}');
824
+ lines.push('');
825
+
766
826
  // ── Auth Builtins: sign_jwt, hash_password, verify_password ──
767
827
  lines.push('// ── Auth Builtins ──');
768
828
  lines.push('let __jwtSignKey = null;');
769
829
  lines.push('async function sign_jwt(payload, secret, options = {}) {');
770
- lines.push(' const __secret = secret || (typeof __authSecret !== "undefined" ? __authSecret : "secret");');
830
+ lines.push(' const __secret = secret || (typeof __authSecret !== "undefined" ? __authSecret : (process.env.AUTH_SECRET || process.env.JWT_SECRET || ""));');
771
831
  lines.push(' if (!__jwtSignKey || __secret !== (typeof __authSecret !== "undefined" ? __authSecret : "")) {');
772
832
  lines.push(' __jwtSignKey = await crypto.subtle.importKey(');
773
833
  lines.push(' "raw", new TextEncoder().encode(__secret),');
@@ -806,7 +866,10 @@ export class ServerCodegen extends BaseCodegen {
806
866
  lines.push(' const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveBits"]);');
807
867
  lines.push(' const hash = await crypto.subtle.deriveBits({ name: "PBKDF2", salt, iterations, hash: "SHA-256" }, key, 256);');
808
868
  lines.push(' const hashHex = [...new Uint8Array(hash)].map(b => b.toString(16).padStart(2, "0")).join("");');
809
- lines.push(' return hashHex === expectedHash;');
869
+ lines.push(' if (hashHex.length !== expectedHash.length) return false;');
870
+ lines.push(' const __a = Buffer.from(hashHex);');
871
+ lines.push(' const __b = Buffer.from(expectedHash);');
872
+ lines.push(' return require("crypto").timingSafeEqual(__a, __b);');
810
873
  lines.push('}');
811
874
  lines.push('');
812
875
 
@@ -838,9 +901,15 @@ export class ServerCodegen extends BaseCodegen {
838
901
  const methods = corsConfig.methods ? this.genExpression(corsConfig.methods) : '["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]';
839
902
  const headers = corsConfig.headers ? this.genExpression(corsConfig.headers) : '["Content-Type", "Authorization"]';
840
903
  const credentials = corsConfig.credentials ? this.genExpression(corsConfig.credentials) : 'false';
904
+ const maxAge = corsConfig.max_age ? this.genExpression(corsConfig.max_age) : '"86400"';
905
+ // Compile-time warning: credentials + wildcard origin is a CORS spec violation
906
+ if (credentials === 'true' && (origins === '["*"]' || (corsConfig.origins && corsConfig.origins.type === 'ArrayExpression' && corsConfig.origins.elements.some(e => e.value === '*')))) {
907
+ lines.push('console.warn("[tova] CORS warning: credentials: true with wildcard origin \\"*\\" violates the CORS spec. Browsers will reject these responses. Use explicit origins instead.");');
908
+ }
841
909
  lines.push('// ── CORS ──');
842
910
  lines.push(`const __corsOrigins = ${origins};`);
843
911
  lines.push(`const __corsCredentials = ${credentials};`);
912
+ lines.push(`const __corsMaxAge = String(${maxAge});`);
844
913
  lines.push('function __getCorsHeaders(req) {');
845
914
  lines.push(' const origin = req.headers.get("Origin") || "*";');
846
915
  lines.push(' const allowed = __corsOrigins.includes("*") || __corsOrigins.includes(origin);');
@@ -848,21 +917,64 @@ export class ServerCodegen extends BaseCodegen {
848
917
  lines.push(` "Access-Control-Allow-Origin": allowed ? (__corsCredentials ? origin : (origin === "*" ? "*" : origin)) : "",`);
849
918
  lines.push(` "Access-Control-Allow-Methods": ${methods}.join(", "),`);
850
919
  lines.push(` "Access-Control-Allow-Headers": ${headers}.join(", "),`);
920
+ lines.push(' "Access-Control-Max-Age": __corsMaxAge,');
851
921
  lines.push(' };');
852
922
  lines.push(' if (__corsCredentials) h["Access-Control-Allow-Credentials"] = "true";');
853
923
  lines.push(' return h;');
854
924
  lines.push('}');
925
+ } else if (authConfig || sessionConfig) {
926
+ // When auth or sessions are configured but no explicit CORS, restrict to same-origin
927
+ lines.push('// ── CORS (restricted — auth/sessions enabled) ──');
928
+ lines.push('const __corsHeadersConst = Object.freeze({');
929
+ lines.push(' "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",');
930
+ lines.push(' "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Tova-CSRF",');
931
+ lines.push(' "Access-Control-Max-Age": "86400",');
932
+ lines.push('});');
933
+ lines.push('function __getCorsHeaders(req) {');
934
+ lines.push(' const origin = req && req.headers ? req.headers.get("Origin") : null;');
935
+ lines.push(' if (!origin) return __corsHeadersConst;');
936
+ lines.push(' const reqUrl = req.url ? new URL(req.url) : null;');
937
+ lines.push(' if (reqUrl && origin === reqUrl.origin) {');
938
+ lines.push(' return { ...__corsHeadersConst, "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Credentials": "true" };');
939
+ lines.push(' }');
940
+ lines.push(' return __corsHeadersConst;');
941
+ lines.push('}');
855
942
  } else {
856
943
  lines.push('// ── CORS ──');
857
944
  lines.push('const __corsHeadersConst = Object.freeze({');
858
945
  lines.push(' "Access-Control-Allow-Origin": "*",');
859
946
  lines.push(' "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",');
860
947
  lines.push(' "Access-Control-Allow-Headers": "Content-Type, Authorization",');
948
+ lines.push(' "Access-Control-Max-Age": "86400",');
861
949
  lines.push('});');
862
950
  lines.push('function __getCorsHeaders() { return __corsHeadersConst; }');
863
951
  }
864
952
  lines.push('');
865
953
 
954
+ // ════════════════════════════════════════════════════════════
955
+ // 8b. Security Headers — OWASP recommended
956
+ // ════════════════════════════════════════════════════════════
957
+ if (!isFastMode) {
958
+ lines.push('// ── Security Headers ──');
959
+ lines.push('const __securityHeaders = Object.freeze({');
960
+ lines.push(' "X-Content-Type-Options": "nosniff",');
961
+ lines.push(' "X-Frame-Options": "DENY",');
962
+ lines.push(' "X-XSS-Protection": "0",');
963
+ lines.push(' "Referrer-Policy": "strict-origin-when-cross-origin",');
964
+ lines.push(' "Permissions-Policy": "camera=(), microphone=(), geolocation=()",');
965
+ lines.push('});');
966
+ if (tlsConfig) {
967
+ lines.push('const __hstsHeader = { "Strict-Transport-Security": "max-age=31536000; includeSubDomains" };');
968
+ }
969
+ lines.push('function __applySecurityHeaders(headers) {');
970
+ lines.push(' for (const [k, v] of Object.entries(__securityHeaders)) headers.set(k, v);');
971
+ if (tlsConfig) {
972
+ lines.push(' headers.set("Strict-Transport-Security", __hstsHeader["Strict-Transport-Security"]);');
973
+ }
974
+ lines.push('}');
975
+ lines.push('');
976
+ }
977
+
866
978
  // ════════════════════════════════════════════════════════════
867
979
  // 9. Auth (F1) — fixed JWT / API key
868
980
  // ════════════════════════════════════════════════════════════
@@ -882,8 +994,11 @@ export class ServerCodegen extends BaseCodegen {
882
994
  lines.push('}');
883
995
  } else {
884
996
  // JWT auth (default)
885
- const secretExpr = authConfig.secret ? this.genExpression(authConfig.secret) : '"secret"';
997
+ const secretExpr = authConfig.secret ? this.genExpression(authConfig.secret) : 'process.env.AUTH_SECRET || process.env.JWT_SECRET';
886
998
  lines.push(`const __authSecret = ${secretExpr};`);
999
+ if (!authConfig.secret) {
1000
+ lines.push('if (!process.env.AUTH_SECRET && !process.env.JWT_SECRET) console.warn("[tova] WARNING: No auth secret configured. Set AUTH_SECRET or JWT_SECRET env var, or use auth { secret: \\"...\\" }");');
1001
+ }
887
1002
  lines.push('let __authKey = null;');
888
1003
  lines.push('async function __authenticate(req) {');
889
1004
  lines.push(' const authHeader = req.headers.get("Authorization");');
@@ -939,24 +1054,37 @@ export class ServerCodegen extends BaseCodegen {
939
1054
  lines.push(`const __rateLimitWindow = ${windowExpr};`);
940
1055
  }
941
1056
  lines.push('const __rateLimitStore = new Map();');
1057
+ lines.push('const __rateLimitMaxKeys = 100000;');
942
1058
  lines.push('function __checkRateLimit(key, max, windowSec) {');
943
1059
  lines.push(' const now = Date.now();');
944
1060
  lines.push(' const windowMs = windowSec * 1000;');
945
1061
  lines.push(' let entry = __rateLimitStore.get(key);');
946
- lines.push(' if (!entry) { entry = { timestamps: [] }; __rateLimitStore.set(key, entry); }');
947
- lines.push(' entry.timestamps = entry.timestamps.filter(t => now - t < windowMs);');
948
- lines.push(' if (entry.timestamps.length >= max) {');
949
- lines.push(' const retryAfter = Math.ceil((entry.timestamps[0] + windowMs - now) / 1000);');
950
- lines.push(' return { limited: true, retryAfter };');
1062
+ lines.push(' if (!entry) {');
1063
+ lines.push(' if (__rateLimitStore.size >= __rateLimitMaxKeys) {');
1064
+ lines.push(' const oldest = __rateLimitStore.keys().next().value;');
1065
+ lines.push(' __rateLimitStore.delete(oldest);');
1066
+ lines.push(' }');
1067
+ lines.push(' entry = { prevCount: 0, currCount: 0, windowStart: now };');
1068
+ lines.push(' __rateLimitStore.set(key, entry);');
1069
+ lines.push(' }');
1070
+ lines.push(' if (now - entry.windowStart >= windowMs) {');
1071
+ lines.push(' entry.prevCount = entry.currCount;');
1072
+ lines.push(' entry.currCount = 0;');
1073
+ lines.push(' entry.windowStart = now;');
1074
+ lines.push(' }');
1075
+ lines.push(' const elapsed = (now - entry.windowStart) / windowMs;');
1076
+ lines.push(' const estimate = entry.prevCount * (1 - elapsed) + entry.currCount;');
1077
+ lines.push(' if (estimate >= max) {');
1078
+ lines.push(' const retryAfter = Math.ceil(windowSec * (1 - elapsed));');
1079
+ lines.push(' return { limited: true, retryAfter: retryAfter || 1 };');
951
1080
  lines.push(' }');
952
- lines.push(' entry.timestamps.push(now);');
1081
+ lines.push(' entry.currCount++;');
953
1082
  lines.push(' return { limited: false };');
954
1083
  lines.push('}');
955
1084
  lines.push('setInterval(() => {');
956
1085
  lines.push(' const now = Date.now();');
957
1086
  lines.push(' for (const [key, entry] of __rateLimitStore) {');
958
- lines.push(' entry.timestamps = entry.timestamps.filter(t => now - t < 120000);');
959
- lines.push(' if (entry.timestamps.length === 0) __rateLimitStore.delete(key);');
1087
+ lines.push(' if (now - entry.windowStart > 120000) __rateLimitStore.delete(key);');
960
1088
  lines.push(' }');
961
1089
  lines.push('}, 60000);');
962
1090
  lines.push('');
@@ -985,9 +1113,17 @@ export class ServerCodegen extends BaseCodegen {
985
1113
  lines.push('}');
986
1114
  lines.push('async function save_file(file, dir) {');
987
1115
  lines.push(' const fs = await import("node:fs/promises");');
1116
+ lines.push(' const path = await import("node:path");');
988
1117
  lines.push(' await fs.mkdir(dir, { recursive: true });');
989
- lines.push(' const name = file.name || "upload_" + Date.now();');
990
- lines.push(' const dest = dir + "/" + name;');
1118
+ lines.push(' let name = file.name || "upload_" + Date.now();');
1119
+ lines.push(' name = path.basename(name).replace(/[\\0]/g, "");');
1120
+ lines.push(' if (!name || name === "." || name === "..") name = "upload_" + Date.now();');
1121
+ lines.push(' const dest = path.join(dir, name);');
1122
+ lines.push(' const resolved = path.resolve(dest);');
1123
+ lines.push(' const resolvedDir = path.resolve(dir);');
1124
+ lines.push(' if (!resolved.startsWith(resolvedDir + path.sep) && resolved !== resolvedDir) {');
1125
+ lines.push(' throw new Error("Invalid file path: directory traversal detected");');
1126
+ lines.push(' }');
991
1127
  lines.push(' await Bun.write(dest, file);');
992
1128
  lines.push(' return dest;');
993
1129
  lines.push('}');
@@ -999,10 +1135,13 @@ export class ServerCodegen extends BaseCodegen {
999
1135
  // ════════════════════════════════════════════════════════════
1000
1136
  if (sessionConfig) {
1001
1137
  lines.push('// ── Session Management ──');
1002
- const secretExpr = sessionConfig.secret ? this.genExpression(sessionConfig.secret) : '"tova-session-secret"';
1138
+ const secretExpr = sessionConfig.secret ? this.genExpression(sessionConfig.secret) : 'process.env.SESSION_SECRET';
1003
1139
  const maxAgeExpr = sessionConfig.max_age ? this.genExpression(sessionConfig.max_age) : '3600';
1004
1140
  const cookieNameExpr = sessionConfig.cookie_name ? this.genExpression(sessionConfig.cookie_name) : '"__sid"';
1005
1141
  lines.push(`const __sessionSecret = ${secretExpr};`);
1142
+ if (!sessionConfig.secret) {
1143
+ lines.push('if (!process.env.SESSION_SECRET) console.warn("[tova] WARNING: No session secret configured. Set SESSION_SECRET env var, or use session { secret: \\"...\\" }");');
1144
+ }
1006
1145
  lines.push(`const __sessionMaxAge = ${maxAgeExpr};`);
1007
1146
  lines.push(`const __sessionCookieName = ${cookieNameExpr};`);
1008
1147
  lines.push('let __sessionKey = null;');
@@ -1067,8 +1206,13 @@ export class ServerCodegen extends BaseCodegen {
1067
1206
  } else {
1068
1207
  lines.push('// In-memory session store');
1069
1208
  lines.push('const __sessionStore = new Map();');
1209
+ lines.push('const __sessionMaxKeys = 50000;');
1070
1210
  lines.push('function __createSession(id) {');
1071
1211
  lines.push(' if (!__sessionStore.has(id)) {');
1212
+ lines.push(' if (__sessionStore.size >= __sessionMaxKeys) {');
1213
+ lines.push(' const oldest = __sessionStore.keys().next().value;');
1214
+ lines.push(' __sessionStore.delete(oldest);');
1215
+ lines.push(' }');
1072
1216
  lines.push(' __sessionStore.set(id, { data: {}, createdAt: Date.now() });');
1073
1217
  lines.push(' }');
1074
1218
  lines.push(' const entry = __sessionStore.get(id);');
@@ -1091,6 +1235,52 @@ export class ServerCodegen extends BaseCodegen {
1091
1235
  lines.push('');
1092
1236
  }
1093
1237
 
1238
+ // ════════════════════════════════════════════════════════════
1239
+ // 11d. CSRF Protection — server-side token generation + validation
1240
+ // ════════════════════════════════════════════════════════════
1241
+ const needsCsrf = !isFastMode && (sessionConfig || authConfig);
1242
+ if (needsCsrf) {
1243
+ lines.push('// ── CSRF Protection ──');
1244
+ lines.push('let __csrfKey = null;');
1245
+ lines.push('async function __getCSRFKey() {');
1246
+ lines.push(' if (!__csrfKey) {');
1247
+ lines.push(' const secret = typeof __sessionSecret !== "undefined" ? __sessionSecret : (typeof __authSecret !== "undefined" ? __authSecret : (process.env.CSRF_SECRET || process.env.AUTH_SECRET || ""));');
1248
+ lines.push(' __csrfKey = await crypto.subtle.importKey(');
1249
+ lines.push(' "raw", new TextEncoder().encode(secret + ":csrf"),');
1250
+ lines.push(' { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]');
1251
+ lines.push(' );');
1252
+ lines.push(' }');
1253
+ lines.push(' return __csrfKey;');
1254
+ lines.push('}');
1255
+ lines.push('async function __generateCSRFToken() {');
1256
+ lines.push(' const key = await __getCSRFKey();');
1257
+ lines.push(' const timestamp = Date.now().toString(36);');
1258
+ lines.push(' const nonce = crypto.getRandomValues(new Uint8Array(8));');
1259
+ lines.push(' const nonceHex = [...nonce].map(b => b.toString(16).padStart(2, "0")).join("");');
1260
+ lines.push(' const data = timestamp + ":" + nonceHex;');
1261
+ lines.push(' const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));');
1262
+ lines.push(' const sigHex = [...new Uint8Array(sig)].map(b => b.toString(16).padStart(2, "0")).join("");');
1263
+ lines.push(' return data + ":" + sigHex;');
1264
+ lines.push('}');
1265
+ lines.push('async function __validateCSRFToken(token) {');
1266
+ lines.push(' if (!token || typeof token !== "string") return false;');
1267
+ lines.push(' const parts = token.split(":");');
1268
+ lines.push(' if (parts.length !== 3) return false;');
1269
+ lines.push(' const [timestamp, nonce, sig] = parts;');
1270
+ lines.push(' const age = Date.now() - parseInt(timestamp, 36);');
1271
+ lines.push(' if (isNaN(age) || age < 0 || age > 86400000) return false;');
1272
+ lines.push(' const key = await __getCSRFKey();');
1273
+ lines.push(' const data = timestamp + ":" + nonce;');
1274
+ lines.push(' const expectedSig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));');
1275
+ lines.push(' const expectedHex = [...new Uint8Array(expectedSig)].map(b => b.toString(16).padStart(2, "0")).join("");');
1276
+ lines.push(' if (sig.length !== expectedHex.length) return false;');
1277
+ lines.push(' const __a = Buffer.from(sig);');
1278
+ lines.push(' const __b = Buffer.from(expectedHex);');
1279
+ lines.push(' return require("crypto").timingSafeEqual(__a, __b);');
1280
+ lines.push('}');
1281
+ lines.push('');
1282
+ }
1283
+
1094
1284
  // ════════════════════════════════════════════════════════════
1095
1285
  // 12. Event Bus (F5) — if subscriptions exist
1096
1286
  // ════════════════════════════════════════════════════════════
@@ -1131,16 +1321,24 @@ export class ServerCodegen extends BaseCodegen {
1131
1321
  lines.push('// ── Background Jobs ──');
1132
1322
  lines.push('const __jobQueue = [];');
1133
1323
  lines.push('let __jobProcessing = false;');
1324
+ lines.push('const __JOB_BASE_DELAY = 1000;');
1325
+ lines.push('const __JOB_MAX_DELAY = 30000;');
1326
+ lines.push('function __jobBackoffDelay(attempt) {');
1327
+ lines.push(' const exp = Math.min(__JOB_BASE_DELAY * Math.pow(2, attempt), __JOB_MAX_DELAY);');
1328
+ lines.push(' const jitter = exp * (0.5 + Math.random() * 0.5);');
1329
+ lines.push(' return Math.round(jitter);');
1330
+ lines.push('}');
1134
1331
  lines.push('async function __processJobQueue() {');
1135
1332
  lines.push(' if (__jobProcessing) return;');
1136
1333
  lines.push(' __jobProcessing = true;');
1137
1334
  lines.push(' while (__jobQueue.length > 0) {');
1138
1335
  lines.push(' const job = __jobQueue.shift();');
1139
1336
  lines.push(' try { await job.fn(...job.args); } catch (err) {');
1140
- lines.push(' __log("error", `Background job ${job.name} failed`, { error: err.message });');
1141
- lines.push(' if (job.retries > 0) {');
1142
- lines.push(' job.retries--;');
1143
- lines.push(' __jobQueue.push(job);');
1337
+ lines.push(' __log("error", `Background job ${job.name} failed (attempt ${job.attempt + 1}/${job.maxRetries + 1})`, { error: err.message });');
1338
+ lines.push(' if (job.attempt < job.maxRetries) {');
1339
+ lines.push(' const delay = __jobBackoffDelay(job.attempt);');
1340
+ lines.push(' job.attempt++;');
1341
+ lines.push(' setTimeout(() => { __jobQueue.push(job); __processJobQueue(); }, delay);');
1144
1342
  lines.push(' }');
1145
1343
  lines.push(' }');
1146
1344
  lines.push(' }');
@@ -1165,7 +1363,7 @@ export class ServerCodegen extends BaseCodegen {
1165
1363
  lines.push(' };');
1166
1364
  lines.push(' const fn = __jobFns[name];');
1167
1365
  lines.push(' if (!fn) throw new Error(`Unknown background job: ${name}`);');
1168
- lines.push(' __jobQueue.push({ name, fn, args, retries: 2 });');
1366
+ lines.push(' __jobQueue.push({ name, fn, args, attempt: 0, maxRetries: 3 });');
1169
1367
  lines.push(' setTimeout(__processJobQueue, 0);');
1170
1368
  lines.push('}');
1171
1369
  lines.push('');
@@ -1431,6 +1629,48 @@ export class ServerCodegen extends BaseCodegen {
1431
1629
  lines.push(' return row ? row.count : 0;');
1432
1630
  lines.push(' },');
1433
1631
 
1632
+ // paginate(page, perPage) — returns { data, page, perPage, total, totalPages }
1633
+ lines.push(` ${isAsync ? 'async ' : ''}paginate(page = 1, perPage = 20) {`);
1634
+ lines.push(' const p = Math.max(1, Math.floor(page));');
1635
+ lines.push(' const pp = Math.max(1, Math.min(100, Math.floor(perPage)));');
1636
+ lines.push(' const offset = (p - 1) * pp;');
1637
+ if (dbDriver === 'postgres') {
1638
+ lines.push(` const rows = ${aw}db.query("SELECT * FROM ${tableName} ORDER BY id LIMIT $1 OFFSET $2", pp, offset);`);
1639
+ } else {
1640
+ lines.push(` const rows = ${aw}db.query("SELECT * FROM ${tableName} ORDER BY id LIMIT ? OFFSET ?", pp, offset);`);
1641
+ }
1642
+ lines.push(` const countRow = ${aw}db.get("SELECT COUNT(*) as count FROM ${tableName}");`);
1643
+ lines.push(' const total = countRow ? countRow.count : 0;');
1644
+ lines.push(' return { data: rows, page: p, perPage: pp, total, totalPages: Math.ceil(total / pp) };');
1645
+ lines.push(' },');
1646
+
1647
+ // soft_delete(id) — sets deleted_at timestamp instead of removing row
1648
+ lines.push(` ${isAsync ? 'async ' : ''}soft_delete(id) {`);
1649
+ if (dbDriver === 'postgres') {
1650
+ lines.push(` const rows = ${aw}db.query("UPDATE ${tableName} SET deleted_at = NOW() WHERE id = $1 RETURNING *", id);`);
1651
+ lines.push(' return rows[0];');
1652
+ } else {
1653
+ lines.push(` ${aw}db.run("UPDATE ${tableName} SET deleted_at = datetime('now') WHERE id = ?", id);`);
1654
+ lines.push(` return ${aw}db.get("SELECT * FROM ${tableName} WHERE id = ?", id);`);
1655
+ }
1656
+ lines.push(' },');
1657
+
1658
+ // restore(id) — clears deleted_at to restore a soft-deleted row
1659
+ lines.push(` ${isAsync ? 'async ' : ''}restore(id) {`);
1660
+ if (dbDriver === 'postgres') {
1661
+ lines.push(` const rows = ${aw}db.query("UPDATE ${tableName} SET deleted_at = NULL WHERE id = $1 RETURNING *", id);`);
1662
+ lines.push(' return rows[0];');
1663
+ } else {
1664
+ lines.push(` ${aw}db.run("UPDATE ${tableName} SET deleted_at = NULL WHERE id = ?", id);`);
1665
+ lines.push(` return ${aw}db.get("SELECT * FROM ${tableName} WHERE id = ?", id);`);
1666
+ }
1667
+ lines.push(' },');
1668
+
1669
+ // active() — returns only non-soft-deleted rows
1670
+ lines.push(` ${isAsync ? 'async ' : ''}active() {`);
1671
+ lines.push(` return ${aw}db.query("SELECT * FROM ${tableName} WHERE deleted_at IS NULL");`);
1672
+ lines.push(' },');
1673
+
1434
1674
  // belongs_to accessors: PostModel.user(user_id) → single parent record
1435
1675
  for (const parentName of belongsToNames) {
1436
1676
  const parentTable = parentName.toLowerCase() + 's';
@@ -1464,12 +1704,18 @@ export class ServerCodegen extends BaseCodegen {
1464
1704
  // ════════════════════════════════════════════════════════════
1465
1705
  if (sseDecls.length > 0) {
1466
1706
  lines.push('// ── SSE (Server-Sent Events) ──');
1707
+ lines.push('let __sseEventId = 0;');
1467
1708
  lines.push('class __SSEChannel {');
1468
1709
  lines.push(' constructor() { this.clients = new Set(); }');
1469
- lines.push(' subscribe(controller) { this.clients.add(controller); }');
1710
+ lines.push(' subscribe(controller) {');
1711
+ lines.push(' this.clients.add(controller);');
1712
+ lines.push(' // Send retry directive so browser reconnects after 3s');
1713
+ lines.push(' try { controller.enqueue(new TextEncoder().encode("retry: 3000\\n\\n")); } catch {}');
1714
+ lines.push(' }');
1470
1715
  lines.push(' unsubscribe(controller) { this.clients.delete(controller); }');
1471
1716
  lines.push(' send(data, event = null) {');
1472
1717
  lines.push(' let msg = "";');
1718
+ lines.push(' msg += `id: ${++__sseEventId}\\n`;');
1473
1719
  lines.push(' if (event) msg += `event: ${event}\\n`;');
1474
1720
  lines.push(' msg += `data: ${typeof data === "string" ? data : JSON.stringify(data)}\\n\\n`;');
1475
1721
  lines.push(' const encoded = new TextEncoder().encode(msg);');
@@ -1477,6 +1723,13 @@ export class ServerCodegen extends BaseCodegen {
1477
1723
  lines.push(' }');
1478
1724
  lines.push(' get count() { return this.clients.size; }');
1479
1725
  lines.push('}');
1726
+ // Periodic heartbeat to detect dead connections
1727
+ lines.push('setInterval(() => {');
1728
+ lines.push(' const hb = new TextEncoder().encode(": heartbeat\\n\\n");');
1729
+ lines.push(' for (const [, ch] of __sseChannels) {');
1730
+ lines.push(' for (const c of ch.clients) { try { c.enqueue(hb); } catch { ch.clients.delete(c); } }');
1731
+ lines.push(' }');
1732
+ lines.push('}, 30000);');
1480
1733
  lines.push('const __sseChannels = new Map();');
1481
1734
  lines.push('function sse_channel(name) {');
1482
1735
  lines.push(' if (!__sseChannels.has(name)) __sseChannels.set(name, new __SSEChannel());');
@@ -1492,10 +1745,12 @@ export class ServerCodegen extends BaseCodegen {
1492
1745
  this.popScope();
1493
1746
 
1494
1747
  lines.push(`__addRoute("GET", ${JSON.stringify(sse.path)}, async (req) => {`);
1748
+ lines.push(' const __lastEventId = req.headers.get("Last-Event-ID");');
1495
1749
  lines.push(' const stream = new ReadableStream({');
1496
1750
  lines.push(` start(controller) {`);
1751
+ lines.push(' controller.enqueue(new TextEncoder().encode("retry: 3000\\n\\n"));');
1497
1752
  lines.push(' const send = (data, event) => {');
1498
- lines.push(' let msg = "";');
1753
+ lines.push(' let msg = `id: ${++__sseEventId}\\n`;');
1499
1754
  lines.push(' if (event) msg += `event: ${event}\\n`;');
1500
1755
  lines.push(' msg += `data: ${typeof data === "string" ? data : JSON.stringify(data)}\\n\\n`;');
1501
1756
  lines.push(' controller.enqueue(new TextEncoder().encode(msg));');
@@ -1541,7 +1796,7 @@ export class ServerCodegen extends BaseCodegen {
1541
1796
  // ════════════════════════════════════════════════════════════
1542
1797
  // 12h. Race Condition Protection — Async Mutex for shared state
1543
1798
  // ════════════════════════════════════════════════════════════
1544
- lines.push('// ── Async Mutex ──');
1799
+ lines.push('// ── Async Mutex (Named) ──');
1545
1800
  lines.push('class __Mutex {');
1546
1801
  lines.push(' constructor() { this._queue = []; this._locked = false; }');
1547
1802
  lines.push(' async acquire() {');
@@ -1553,13 +1808,49 @@ export class ServerCodegen extends BaseCodegen {
1553
1808
  lines.push(' else { this._locked = false; }');
1554
1809
  lines.push(' }');
1555
1810
  lines.push('}');
1556
- lines.push('const __mutex = new __Mutex();');
1557
- lines.push('async function withLock(fn) {');
1558
- lines.push(' await __mutex.acquire();');
1559
- lines.push(' try { return await fn(); } finally { __mutex.release(); }');
1811
+ lines.push('const __locks = new Map();');
1812
+ lines.push('function __getLock(name) {');
1813
+ lines.push(' if (!__locks.has(name)) __locks.set(name, new __Mutex());');
1814
+ lines.push(' return __locks.get(name);');
1815
+ lines.push('}');
1816
+ lines.push('async function withLock(nameOrFn, fn) {');
1817
+ lines.push(' if (typeof nameOrFn === "function") { fn = nameOrFn; nameOrFn = "default"; }');
1818
+ lines.push(' const lock = __getLock(nameOrFn);');
1819
+ lines.push(' await lock.acquire();');
1820
+ lines.push(' try { return await fn(); } finally { lock.release(); }');
1560
1821
  lines.push('}');
1561
1822
  lines.push('');
1562
1823
 
1824
+ // ════════════════════════════════════════════════════════════
1825
+ // 12i. Idempotency Key Support
1826
+ // ════════════════════════════════════════════════════════════
1827
+ if (!isFastMode) {
1828
+ lines.push('// ── Idempotency Key Cache ──');
1829
+ lines.push('const __idempotencyCache = new Map();');
1830
+ lines.push('const __idempotencyMaxKeys = 10000;');
1831
+ lines.push('const __idempotencyTTL = 86400000;'); // 24h default
1832
+ lines.push('function __checkIdempotencyKey(key) {');
1833
+ lines.push(' const entry = __idempotencyCache.get(key);');
1834
+ lines.push(' if (!entry) return null;');
1835
+ lines.push(' if (Date.now() - entry.timestamp > __idempotencyTTL) { __idempotencyCache.delete(key); return null; }');
1836
+ lines.push(' return entry;');
1837
+ lines.push('}');
1838
+ lines.push('function __storeIdempotencyResult(key, status, body, headers) {');
1839
+ lines.push(' if (__idempotencyCache.size >= __idempotencyMaxKeys) {');
1840
+ lines.push(' const oldest = __idempotencyCache.keys().next().value;');
1841
+ lines.push(' __idempotencyCache.delete(oldest);');
1842
+ lines.push(' }');
1843
+ lines.push(' __idempotencyCache.set(key, { status, body, headers, timestamp: Date.now() });');
1844
+ lines.push('}');
1845
+ lines.push('setInterval(() => {');
1846
+ lines.push(' const now = Date.now();');
1847
+ lines.push(' for (const [key, entry] of __idempotencyCache) {');
1848
+ lines.push(' if (now - entry.timestamp > __idempotencyTTL) __idempotencyCache.delete(key);');
1849
+ lines.push(' }');
1850
+ lines.push('}, 3600000);'); // cleanup every hour
1851
+ lines.push('');
1852
+ }
1853
+
1563
1854
  // ════════════════════════════════════════════════════════════
1564
1855
  // 13. Other statements + Server Functions
1565
1856
  // ════════════════════════════════════════════════════════════
@@ -1627,7 +1918,48 @@ export class ServerCodegen extends BaseCodegen {
1627
1918
  if (healthPath) {
1628
1919
  lines.push('// ── Health Check ──');
1629
1920
  lines.push(`__addRoute("GET", ${JSON.stringify(healthPath)}, async () => {`);
1630
- lines.push(' return Response.json({ status: "ok", uptime: process.uptime() });');
1921
+ if (healthChecks && healthChecks.length > 0) {
1922
+ lines.push(' const __checks = {};');
1923
+ lines.push(' let __overallStatus = "healthy";');
1924
+ // check_memory: report heap usage
1925
+ if (healthChecks.includes('check_memory')) {
1926
+ lines.push(' const __mem = process.memoryUsage();');
1927
+ lines.push(' const __heapPct = __mem.heapUsed / __mem.heapTotal;');
1928
+ lines.push(' __checks.memory = { status: __heapPct > 0.9 ? "degraded" : "healthy", heapUsed: __mem.heapUsed, heapTotal: __mem.heapTotal, rss: __mem.rss };');
1929
+ lines.push(' if (__heapPct > 0.9) __overallStatus = "degraded";');
1930
+ }
1931
+ // check_db: ping database
1932
+ if (healthChecks.includes('check_db') && (dbConfig || usesDb)) {
1933
+ lines.push(' try {');
1934
+ if (dbDriver === 'postgres') {
1935
+ lines.push(' await db.query("SELECT 1");');
1936
+ } else if (dbDriver === 'mysql') {
1937
+ lines.push(' await db.query("SELECT 1");');
1938
+ } else {
1939
+ lines.push(' db.query("SELECT 1");');
1940
+ }
1941
+ lines.push(' __checks.db = { status: "healthy" };');
1942
+ lines.push(' } catch (__dbErr) {');
1943
+ lines.push(' __checks.db = { status: "unhealthy", error: __dbErr.message };');
1944
+ lines.push(' __overallStatus = "unhealthy";');
1945
+ lines.push(' }');
1946
+ }
1947
+ // Always include uptime
1948
+ lines.push(' __checks.uptime = { seconds: Math.floor(process.uptime()) };');
1949
+ lines.push(' return Response.json({ status: __overallStatus, checks: __checks, timestamp: new Date().toISOString() });');
1950
+ } else {
1951
+ lines.push(' return Response.json({ status: "ok", uptime: process.uptime() });');
1952
+ }
1953
+ lines.push('});');
1954
+ lines.push('');
1955
+ }
1956
+
1957
+ // CSRF Token Endpoint
1958
+ if (needsCsrf) {
1959
+ lines.push('// ── CSRF Token Endpoint ──');
1960
+ lines.push('__addRoute("GET", "/csrf-token", async () => {');
1961
+ lines.push(' const token = await __generateCSRFToken();');
1962
+ lines.push(' return Response.json({ token });');
1631
1963
  lines.push('});');
1632
1964
  lines.push('');
1633
1965
  }
@@ -1650,7 +1982,7 @@ export class ServerCodegen extends BaseCodegen {
1650
1982
  for (const check of validationChecks) {
1651
1983
  lines.push(check);
1652
1984
  }
1653
- lines.push(` if (__validationErrors.length > 0) return Response.json({ error: "Validation failed", details: __validationErrors }, { status: 400 });`);
1985
+ lines.push(` if (__validationErrors.length > 0) return __errorResponse(400, "VALIDATION_FAILED", "Validation failed", __validationErrors);`);
1654
1986
  }
1655
1987
  lines.push(` const result = await ${name}(${paramNames.join(', ')});`);
1656
1988
  } else {
@@ -1736,10 +2068,10 @@ export class ServerCodegen extends BaseCodegen {
1736
2068
  // Auth decorator check
1737
2069
  if (hasAuth && authConfig) {
1738
2070
  lines.push(` const __user = await __authenticate(req);`);
1739
- lines.push(` if (!__user) return Response.json({ error: "Unauthorized" }, { status: 401 });`);
2071
+ lines.push(` if (!__user) return __errorResponse(401, "AUTH_REQUIRED", "Unauthorized");`);
1740
2072
  if (roleDecorator && roleDecorator.args.length > 0) {
1741
2073
  const roleExpr = this.genExpression(roleDecorator.args[0]);
1742
- lines.push(` if (__user.role !== ${roleExpr}) return Response.json({ error: "Forbidden" }, { status: 403 });`);
2074
+ lines.push(` if (__user.role !== ${roleExpr}) return __errorResponse(403, "FORBIDDEN", "Forbidden");`);
1743
2075
  }
1744
2076
  }
1745
2077
 
@@ -1749,7 +2081,7 @@ export class ServerCodegen extends BaseCodegen {
1749
2081
  const rlWindow = rateLimitDec.args[1] ? this.genExpression(rateLimitDec.args[1]) : '60';
1750
2082
  lines.push(` const __rlIp = req.headers.get("x-forwarded-for") || "unknown";`);
1751
2083
  lines.push(` const __rlRoute = __checkRateLimit(\`route:${path}:\${__rlIp}\`, ${rlMax}, ${rlWindow});`);
1752
- lines.push(` if (__rlRoute.limited) return Response.json({ error: "Too Many Requests" }, { status: 429, headers: { "Retry-After": String(__rlRoute.retryAfter) } });`);
2084
+ lines.push(` if (__rlRoute.limited) return __errorResponse(429, "RATE_LIMITED", "Too Many Requests", null, { "Retry-After": String(__rlRoute.retryAfter) });`);
1753
2085
  }
1754
2086
 
1755
2087
  // Upload decorator — parse multipart body, validate file field
@@ -1759,7 +2091,7 @@ export class ServerCodegen extends BaseCodegen {
1759
2091
  lines.push(` const __uploadField = ${fieldExpr};`);
1760
2092
  lines.push(` const __uploadFile = __body[__uploadField];`);
1761
2093
  lines.push(` const __uploadCheck = __validateFile(__uploadFile, __uploadField);`);
1762
- lines.push(` if (!__uploadCheck.valid) return Response.json({ error: __uploadCheck.error }, { status: 400 });`);
2094
+ lines.push(` if (!__uploadCheck.valid) return __errorResponse(400, "VALIDATION_FAILED", __uploadCheck.error);`);
1763
2095
  }
1764
2096
 
1765
2097
  // Validate decorator — advanced field validation on body
@@ -1770,7 +2102,7 @@ export class ServerCodegen extends BaseCodegen {
1770
2102
  lines.push(` const __validationErrors = [];`);
1771
2103
  const advChecks = this._genAdvancedValidationCode(validateDec.args[0]);
1772
2104
  for (const check of advChecks) lines.push(check);
1773
- lines.push(` if (__validationErrors.length > 0) return Response.json({ error: "Validation failed", details: __validationErrors }, { status: 400 });`);
2105
+ lines.push(` if (__validationErrors.length > 0) return __errorResponse(400, "VALIDATION_FAILED", "Validation failed", __validationErrors);`);
1774
2106
  }
1775
2107
 
1776
2108
  // T9-1: Route-level body type validation — route POST "/api/users" body: User => handler
@@ -1783,7 +2115,7 @@ export class ServerCodegen extends BaseCodegen {
1783
2115
  }
1784
2116
  const isArray = route.bodyType.type === 'ArrayTypeAnnotation';
1785
2117
  if (isArray) {
1786
- lines.push(` if (!Array.isArray(__body)) return Response.json({ error: "Request body must be an array of ${typeName}" }, { status: 400 });`);
2118
+ lines.push(` if (!Array.isArray(__body)) return __errorResponse(400, "VALIDATION_FAILED", "Request body must be an array of ${typeName}");`);
1787
2119
  lines.push(` const __bodyTypeErrors = [];`);
1788
2120
  lines.push(` for (let __i = 0; __i < __body.length; __i++) {`);
1789
2121
  lines.push(` const __item = __body[__i];`);
@@ -1805,32 +2137,18 @@ export class ServerCodegen extends BaseCodegen {
1805
2137
  }
1806
2138
  }
1807
2139
  lines.push(` }`);
1808
- lines.push(` if (__bodyTypeErrors.length > 0) return Response.json({ error: "Validation failed for ${typeName}[]", details: __bodyTypeErrors }, { status: 400 });`);
2140
+ lines.push(` if (__bodyTypeErrors.length > 0) return __errorResponse(400, "VALIDATION_FAILED", "Validation failed for ${typeName}[]", __bodyTypeErrors);`);
1809
2141
  } else {
1810
2142
  lines.push(` const __bodyTypeErrors = [];`);
1811
- for (const f of typeInfo.fields) {
1812
- if (f.name === 'id') continue;
1813
- switch (f.type) {
1814
- case 'String':
1815
- lines.push(` if (__body.${f.name} !== undefined && typeof __body.${f.name} !== "string") __bodyTypeErrors.push("${f.name} must be a string");`);
1816
- break;
1817
- case 'Int':
1818
- lines.push(` if (__body.${f.name} !== undefined && !Number.isInteger(__body.${f.name})) __bodyTypeErrors.push("${f.name} must be an integer");`);
1819
- break;
1820
- case 'Float':
1821
- lines.push(` if (__body.${f.name} !== undefined && typeof __body.${f.name} !== "number") __bodyTypeErrors.push("${f.name} must be a number");`);
1822
- break;
1823
- case 'Bool':
1824
- lines.push(` if (__body.${f.name} !== undefined && typeof __body.${f.name} !== "boolean") __bodyTypeErrors.push("${f.name} must be a boolean");`);
1825
- break;
1826
- }
1827
- }
2143
+ // Recursive nested validation for body type fields
2144
+ const nestedChecks = this._genNestedTypeValidation(sharedTypes, '__body', typeInfo, '');
2145
+ for (const check of nestedChecks) lines.push(check);
1828
2146
  // Check for required fields (non-id fields without defaults)
1829
2147
  for (const f of typeInfo.fields) {
1830
2148
  if (f.name === 'id') continue;
1831
2149
  lines.push(` if (__body.${f.name} === undefined || __body.${f.name} === null) __bodyTypeErrors.push("${f.name} is required");`);
1832
2150
  }
1833
- lines.push(` if (__bodyTypeErrors.length > 0) return Response.json({ error: "Validation failed for type ${typeName}", details: __bodyTypeErrors }, { status: 400 });`);
2151
+ lines.push(` if (__bodyTypeErrors.length > 0) return __errorResponse(400, "VALIDATION_FAILED", "Validation failed for type ${typeName}", __bodyTypeErrors);`);
1834
2152
  }
1835
2153
  }
1836
2154
  }
@@ -1862,7 +2180,7 @@ export class ServerCodegen extends BaseCodegen {
1862
2180
  break;
1863
2181
  }
1864
2182
  }
1865
- lines.push(` if (__tsErrors_${p.name}.length > 0) return Response.json({ error: "Validation failed", details: __tsErrors_${p.name} }, { status: 400 });`);
2183
+ lines.push(` if (__tsErrors_${p.name}.length > 0) return __errorResponse(400, "VALIDATION_FAILED", "Validation failed", __tsErrors_${p.name});`);
1866
2184
  }
1867
2185
  }
1868
2186
  }
@@ -1906,7 +2224,7 @@ export class ServerCodegen extends BaseCodegen {
1906
2224
  if (validationChecks.length > 0) {
1907
2225
  lines.push(` const __validationErrors = [];`);
1908
2226
  for (const check of validationChecks) lines.push(check);
1909
- lines.push(` if (__validationErrors.length > 0) return Response.json({ error: "Validation failed", details: __validationErrors }, { status: 400 });`);
2227
+ lines.push(` if (__validationErrors.length > 0) return __errorResponse(400, "VALIDATION_FAILED", "Validation failed", __validationErrors);`);
1910
2228
  }
1911
2229
  }
1912
2230
  if (groupMws.length > 0) {
@@ -1938,7 +2256,7 @@ export class ServerCodegen extends BaseCodegen {
1938
2256
  if (validationChecks.length > 0) {
1939
2257
  lines.push(` const __validationErrors = [];`);
1940
2258
  for (const check of validationChecks) lines.push(check);
1941
- lines.push(` if (__validationErrors.length > 0) return Response.json({ error: "Validation failed", details: __validationErrors }, { status: 400 });`);
2259
+ lines.push(` if (__validationErrors.length > 0) return __errorResponse(400, "VALIDATION_FAILED", "Validation failed", __validationErrors);`);
1942
2260
  }
1943
2261
  }
1944
2262
  if (groupMws.length > 0) {
@@ -2033,9 +2351,32 @@ export class ServerCodegen extends BaseCodegen {
2033
2351
  lines.push(' openapi: "3.0.3",');
2034
2352
  lines.push(` info: { title: ${JSON.stringify(blockName || 'Tova API')}, version: "1.0.0" },`);
2035
2353
  lines.push(' paths: {},');
2036
- lines.push(' components: { schemas: {} },');
2354
+ lines.push(' components: { schemas: {}, securitySchemes: {} },');
2355
+ lines.push('};');
2356
+
2357
+ // Standard error response schema
2358
+ lines.push('__openApiSpec.components.schemas.ErrorResponse = {');
2359
+ lines.push(' type: "object",');
2360
+ lines.push(' properties: {');
2361
+ lines.push(' error: { type: "object", properties: {');
2362
+ lines.push(' code: { type: "string", description: "Machine-readable error code" },');
2363
+ lines.push(' message: { type: "string", description: "Human-readable error message" },');
2364
+ lines.push(' details: { type: "array", items: { type: "string" }, description: "Validation error details", nullable: true },');
2365
+ lines.push(' }, required: ["code", "message"] },');
2366
+ lines.push(' },');
2037
2367
  lines.push('};');
2038
2368
 
2369
+ // Add auth security schemes to OpenAPI spec
2370
+ if (authConfig) {
2371
+ const authType = authConfig.type ? authConfig.type.value : 'jwt';
2372
+ if (authType === 'api_key') {
2373
+ const headerExpr = authConfig.header ? this.genExpression(authConfig.header) : '"X-API-Key"';
2374
+ lines.push(`__openApiSpec.components.securitySchemes.ApiKeyAuth = { type: "apiKey", in: "header", name: ${headerExpr} };`);
2375
+ } else {
2376
+ lines.push('__openApiSpec.components.securitySchemes.BearerAuth = { type: "http", scheme: "bearer", bearerFormat: "JWT" };');
2377
+ }
2378
+ }
2379
+
2039
2380
  // Generate schemas from shared types
2040
2381
  for (const [typeName, typeInfo] of sharedTypes) {
2041
2382
  const props = typeInfo.fields.map(f => {
@@ -2145,6 +2486,38 @@ export class ServerCodegen extends BaseCodegen {
2145
2486
  lines.push(' responses: { "200": { description: "Success" } },');
2146
2487
  }
2147
2488
 
2489
+ // Auto-generate error responses based on route context
2490
+ const routeHasAuth = (route.decorators || []).some(d => d.name === 'auth');
2491
+ const routeHasRateLimit = (route.decorators || []).some(d => d.name === 'rate_limit');
2492
+ const routeHasValidation = (route.decorators || []).some(d => d.name === 'validate') || route.bodyType;
2493
+ const routeHasTimeout = (route.decorators || []).some(d => d.name === 'timeout');
2494
+ const errRef = '{ "$ref": "#/components/schemas/ErrorResponse" }';
2495
+
2496
+ // Merge error responses into existing 200 response
2497
+ lines.push(`const __r = __openApiSpec.paths[${JSON.stringify(path)}][${JSON.stringify(method)}].responses;`);
2498
+ if (routeHasValidation || ['post', 'put', 'patch'].includes(method)) {
2499
+ lines.push(`__r["400"] = { description: "Validation Failed", content: { "application/json": { schema: ${errRef} } } };`);
2500
+ }
2501
+ if (routeHasAuth && authConfig) {
2502
+ const authType = authConfig.type ? authConfig.type.value : 'jwt';
2503
+ const schemeName = authType === 'api_key' ? 'ApiKeyAuth' : 'BearerAuth';
2504
+ lines.push(`__openApiSpec.paths[${JSON.stringify(path)}][${JSON.stringify(method)}].security = [{ "${schemeName}": [] }];`);
2505
+ lines.push(`__r["401"] = { description: "Unauthorized", content: { "application/json": { schema: ${errRef} } } };`);
2506
+ if ((route.decorators || []).some(d => d.name === 'role')) {
2507
+ lines.push(`__r["403"] = { description: "Forbidden", content: { "application/json": { schema: ${errRef} } } };`);
2508
+ }
2509
+ }
2510
+ if (pathParams.length > 0) {
2511
+ lines.push(`__r["404"] = { description: "Not Found", content: { "application/json": { schema: ${errRef} } } };`);
2512
+ }
2513
+ if (routeHasRateLimit || rateLimitConfig) {
2514
+ lines.push(`__r["429"] = { description: "Too Many Requests", content: { "application/json": { schema: ${errRef} } } };`);
2515
+ }
2516
+ if (routeHasTimeout) {
2517
+ lines.push(`__r["504"] = { description: "Gateway Timeout", content: { "application/json": { schema: ${errRef} } } };`);
2518
+ }
2519
+ lines.push(`__r["500"] = { description: "Internal Server Error", content: { "application/json": { schema: ${errRef} } } };`);
2520
+
2148
2521
  lines.push('};');
2149
2522
  }
2150
2523
 
@@ -2202,9 +2575,14 @@ export class ServerCodegen extends BaseCodegen {
2202
2575
  if (!isFastMode) {
2203
2576
  lines.push('// ── Structured Logging ──');
2204
2577
  lines.push('let __reqCounter = 0;');
2578
+ lines.push('const __validRequestId = /^[a-zA-Z0-9\\-_.]{1,128}$/;');
2205
2579
  lines.push('function __genRequestId() {');
2206
2580
  lines.push(' return `${Date.now().toString(36)}-${(++__reqCounter).toString(36)}`;');
2207
2581
  lines.push('}');
2582
+ lines.push('function __sanitizeRequestId(raw) {');
2583
+ lines.push(' if (raw && __validRequestId.test(raw)) return raw;');
2584
+ lines.push(' return __genRequestId();');
2585
+ lines.push('}');
2208
2586
  lines.push('const __logLevels = { debug: 0, info: 1, warn: 2, error: 3 };');
2209
2587
  lines.push('const __logMinLevel = __logLevels[process.env.LOG_LEVEL || "info"] || 1;');
2210
2588
  lines.push('let __logFile = null;');
@@ -2228,8 +2606,13 @@ export class ServerCodegen extends BaseCodegen {
2228
2606
  if (staticDecl.fallback) {
2229
2607
  lines.push(`const __staticFallback = ${JSON.stringify(staticDecl.fallback)};`);
2230
2608
  }
2609
+ lines.push('const __path = await import("node:path");');
2610
+ lines.push('const __resolvedStaticDir = __path.resolve(__staticDir);');
2231
2611
  lines.push('async function __serveStatic(pathname, req) {');
2232
- lines.push(' const filePath = __staticDir + pathname.slice(__staticPrefix.length);');
2612
+ lines.push(' const relative = pathname.slice(__staticPrefix.length);');
2613
+ lines.push(' const filePath = __path.join(__staticDir, relative);');
2614
+ lines.push(' const resolved = __path.resolve(filePath);');
2615
+ lines.push(' if (!resolved.startsWith(__resolvedStaticDir + __path.sep) && resolved !== __resolvedStaticDir) return null;');
2233
2616
  lines.push(' try {');
2234
2617
  lines.push(' const file = Bun.file(filePath);');
2235
2618
  lines.push(' if (await file.exists()) {');
@@ -2257,6 +2640,20 @@ export class ServerCodegen extends BaseCodegen {
2257
2640
  lines.push('// ── WebSocket Handlers ──');
2258
2641
  lines.push('const __wsClients = new Set();');
2259
2642
  lines.push('const __wsRooms = new Map();');
2643
+ // Per-client message rate limiting
2644
+ lines.push('const __wsRateLimit = new Map();');
2645
+ lines.push('const __wsRateLimitMax = 100;'); // max messages per window
2646
+ lines.push('const __wsRateLimitWindow = 10000;'); // 10 second window
2647
+ lines.push('function __wsCheckRate(ws) {');
2648
+ lines.push(' const now = Date.now();');
2649
+ lines.push(' let entry = __wsRateLimit.get(ws);');
2650
+ lines.push(' if (!entry || now - entry.windowStart >= __wsRateLimitWindow) {');
2651
+ lines.push(' entry = { count: 0, windowStart: now };');
2652
+ lines.push(' __wsRateLimit.set(ws, entry);');
2653
+ lines.push(' }');
2654
+ lines.push(' entry.count++;');
2655
+ lines.push(' return entry.count <= __wsRateLimitMax;');
2656
+ lines.push('}');
2260
2657
  lines.push('function broadcast(data, exclude = null) {');
2261
2658
  lines.push(' const msg = typeof data === "string" ? data : JSON.stringify(data);');
2262
2659
  lines.push(' for (const c of __wsClients) { if (c !== exclude) c.send(msg); }');
@@ -2457,7 +2854,7 @@ export class ServerCodegen extends BaseCodegen {
2457
2854
  lines.push(' const __pStart = __rawUrl.indexOf("/", 12);'); // skip "http://x:p" or "https://x:p"
2458
2855
  lines.push(' const __qIdx = __rawUrl.indexOf("?", __pStart);');
2459
2856
  lines.push(' const __pathname = __qIdx === -1 ? __rawUrl.slice(__pStart) : __rawUrl.slice(__pStart, __qIdx);');
2460
- lines.push(' const __rid = req.headers.get("X-Request-Id") || __genRequestId();');
2857
+ lines.push(' const __rid = __sanitizeRequestId(req.headers.get("X-Request-Id"));');
2461
2858
  lines.push(' const __startTime = Date.now();');
2462
2859
  lines.push(' const __cors = __getCorsHeaders(req);');
2463
2860
 
@@ -2482,7 +2879,7 @@ export class ServerCodegen extends BaseCodegen {
2482
2879
  lines.push(' if (upgraded) return undefined;');
2483
2880
  lines.push(' return new Response("WebSocket upgrade failed", { status: 400 });');
2484
2881
  lines.push(' } catch (__authErr) {');
2485
- lines.push(' return Response.json({ error: "Unauthorized" }, { status: 401 });');
2882
+ lines.push(' return __errorResponse(401, "AUTH_REQUIRED", "Unauthorized");');
2486
2883
  lines.push(' }');
2487
2884
  } else {
2488
2885
  lines.push(' const upgraded = __server.upgrade(req, { data: { rid: __rid } });');
@@ -2499,7 +2896,7 @@ export class ServerCodegen extends BaseCodegen {
2499
2896
  // Max body size check
2500
2897
  lines.push(' const __contentLength = parseInt(req.headers.get("Content-Length") || "0", 10);');
2501
2898
  lines.push(' if (__contentLength > __maxBodySize) {');
2502
- lines.push(' return Response.json({ error: "Payload Too Large" }, { status: 413, headers: __cors });');
2899
+ lines.push(' return __errorResponse(413, "PAYLOAD_TOO_LARGE", "Payload Too Large", null, __cors);');
2503
2900
  lines.push(' }');
2504
2901
 
2505
2902
  // Global rate limit check (F2)
@@ -2507,7 +2904,7 @@ export class ServerCodegen extends BaseCodegen {
2507
2904
  lines.push(' const __clientIp = req.headers.get("x-forwarded-for") || "unknown";');
2508
2905
  lines.push(' const __rl = __checkRateLimit(__clientIp, __rateLimitMax, __rateLimitWindow);');
2509
2906
  lines.push(' if (__rl.limited) {');
2510
- lines.push(' return Response.json({ error: "Too Many Requests" }, { status: 429, headers: { ...__cors, "Retry-After": String(__rl.retryAfter) } });');
2907
+ lines.push(' return __errorResponse(429, "RATE_LIMITED", "Too Many Requests", null, { ...__cors, "Retry-After": String(__rl.retryAfter) });');
2511
2908
  lines.push(' }');
2512
2909
  }
2513
2910
 
@@ -2527,6 +2924,28 @@ export class ServerCodegen extends BaseCodegen {
2527
2924
  lines.push(' req.__session = __createSession(__sessionId);');
2528
2925
  }
2529
2926
 
2927
+ // CSRF validation on state-mutating requests
2928
+ if (needsCsrf) {
2929
+ lines.push(' if (req.method !== "GET" && req.method !== "HEAD" && req.method !== "OPTIONS") {');
2930
+ lines.push(' const __csrfToken = req.headers.get("X-Tova-CSRF") || req.headers.get("x-tova-csrf");');
2931
+ lines.push(' const __csrfValid = await __validateCSRFToken(__csrfToken);');
2932
+ lines.push(' if (!__csrfValid) {');
2933
+ lines.push(' __log("warn", "CSRF validation failed", { rid: __rid, method: req.method, path: __pathname });');
2934
+ lines.push(' return __errorResponse(403, "CSRF_INVALID", "CSRF token invalid or missing", null, __cors);');
2935
+ lines.push(' }');
2936
+ lines.push(' }');
2937
+ }
2938
+
2939
+ // Idempotency key check
2940
+ lines.push(' const __idempotencyKey = req.headers.get("Idempotency-Key");');
2941
+ lines.push(' if (__idempotencyKey && req.method !== "GET" && req.method !== "HEAD" && req.method !== "OPTIONS") {');
2942
+ lines.push(' const __cached = __checkIdempotencyKey(__idempotencyKey);');
2943
+ lines.push(' if (__cached) {');
2944
+ lines.push(' const __cachedHeaders = { ...__cors, ...(__cached.headers || {}), "X-Idempotent-Replayed": "true" };');
2945
+ lines.push(' return new Response(JSON.stringify(__cached.body), { status: __cached.status, headers: { "Content-Type": "application/json", ...__cachedHeaders } });');
2946
+ lines.push(' }');
2947
+ lines.push(' }');
2948
+
2530
2949
  // Static file serving
2531
2950
  if (staticDecl) {
2532
2951
  lines.push(` if (__pathname.startsWith(__staticPrefix)) {`);
@@ -2556,7 +2975,7 @@ export class ServerCodegen extends BaseCodegen {
2556
2975
  lines.push(' for (const [k, v] of Object.entries(__cors)) headers.set(k, v);');
2557
2976
  lines.push(' return new Response(res.body, { status: res.status, headers });');
2558
2977
  lines.push(' } catch (err) {');
2559
- lines.push(' if (err.message === "__BODY_TOO_LARGE__") return Response.json({ error: "Payload Too Large" }, { status: 413, headers: __cors });');
2978
+ lines.push(' if (err.message === "__BODY_TOO_LARGE__") return __errorResponse(413, "PAYLOAD_TOO_LARGE", "Payload Too Large", null, __cors);');
2560
2979
  if (errorHandler) {
2561
2980
  lines.push(' try {');
2562
2981
  lines.push(' const errRes = await __errorHandler(err, req);');
@@ -2569,7 +2988,7 @@ export class ServerCodegen extends BaseCodegen {
2569
2988
  lines.push(' } catch { /**/ }');
2570
2989
  }
2571
2990
  lines.push(' __log("error", `Unhandled error: ${err.message}`, { error: err.stack || err.message });');
2572
- lines.push(' return Response.json({ error: "Internal Server Error" }, { status: 500, headers: __cors });');
2991
+ lines.push(' return __errorResponse(500, "INTERNAL_ERROR", "Internal Server Error", null, __cors);');
2573
2992
  lines.push(' }');
2574
2993
  } else {
2575
2994
  lines.push(' try {');
@@ -2578,7 +2997,7 @@ export class ServerCodegen extends BaseCodegen {
2578
2997
  lines.push(' for (const [k, v] of Object.entries(__cors)) res.headers.set(k, v);');
2579
2998
  lines.push(' return res;');
2580
2999
  lines.push(' } catch (err) {');
2581
- lines.push(' if (err.message === "__BODY_TOO_LARGE__") return Response.json({ error: "Payload Too Large" }, { status: 413, headers: __cors });');
3000
+ lines.push(' if (err.message === "__BODY_TOO_LARGE__") return __errorResponse(413, "PAYLOAD_TOO_LARGE", "Payload Too Large", null, __cors);');
2582
3001
  if (errorHandler) {
2583
3002
  lines.push(' try {');
2584
3003
  lines.push(' const errRes = await __errorHandler(err, req);');
@@ -2590,7 +3009,7 @@ export class ServerCodegen extends BaseCodegen {
2590
3009
  lines.push(' } catch { /**/ }');
2591
3010
  }
2592
3011
  lines.push(' __log("error", `Unhandled error: ${err.message}`, { error: err.stack || err.message });');
2593
- lines.push(' return Response.json({ error: "Internal Server Error" }, { status: 500, headers: __cors });');
3012
+ lines.push(' return __errorResponse(500, "INTERNAL_ERROR", "Internal Server Error", null, __cors);');
2594
3013
  lines.push(' }');
2595
3014
  }
2596
3015
  lines.push(' }');
@@ -2614,7 +3033,7 @@ export class ServerCodegen extends BaseCodegen {
2614
3033
  lines.push(' for (const [k, v] of Object.entries(__cors)) res.headers.set(k, v);');
2615
3034
  lines.push(' return res;');
2616
3035
  lines.push(' } catch (err) {');
2617
- lines.push(' if (err.message === "__BODY_TOO_LARGE__") return Response.json({ error: "Payload Too Large" }, { status: 413, headers: __cors });');
3036
+ lines.push(' if (err.message === "__BODY_TOO_LARGE__") return __errorResponse(413, "PAYLOAD_TOO_LARGE", "Payload Too Large", null, __cors);');
2618
3037
  if (errorHandler) {
2619
3038
  lines.push(' try {');
2620
3039
  lines.push(' const errRes = await __errorHandler(err, req);');
@@ -2626,7 +3045,7 @@ export class ServerCodegen extends BaseCodegen {
2626
3045
  lines.push(' } catch { /**/ }');
2627
3046
  }
2628
3047
  lines.push(' __log("error", `Unhandled error: ${err.message}`, { error: err.stack || err.message });');
2629
- lines.push(' return Response.json({ error: "Internal Server Error" }, { status: 500, headers: __cors });');
3048
+ lines.push(' return __errorResponse(500, "INTERNAL_ERROR", "Internal Server Error", null, __cors);');
2630
3049
  lines.push(' }');
2631
3050
  } else {
2632
3051
  lines.push(' try {');
@@ -2635,7 +3054,7 @@ export class ServerCodegen extends BaseCodegen {
2635
3054
  lines.push(' for (const [k, v] of Object.entries(__cors)) res.headers.set(k, v);');
2636
3055
  lines.push(' return res;');
2637
3056
  lines.push(' } catch (err) {');
2638
- lines.push(' if (err.message === "__BODY_TOO_LARGE__") return Response.json({ error: "Payload Too Large" }, { status: 413, headers: __cors });');
3057
+ lines.push(' if (err.message === "__BODY_TOO_LARGE__") return __errorResponse(413, "PAYLOAD_TOO_LARGE", "Payload Too Large", null, __cors);');
2639
3058
  if (errorHandler) {
2640
3059
  lines.push(' try {');
2641
3060
  lines.push(' const errRes = await __errorHandler(err, req);');
@@ -2647,7 +3066,7 @@ export class ServerCodegen extends BaseCodegen {
2647
3066
  lines.push(' } catch { /**/ }');
2648
3067
  }
2649
3068
  lines.push(' __log("error", `Unhandled error: ${err.message}`, { error: err.stack || err.message });');
2650
- lines.push(' return Response.json({ error: "Internal Server Error" }, { status: 500, headers: __cors });');
3069
+ lines.push(' return __errorResponse(500, "INTERNAL_ERROR", "Internal Server Error", null, __cors);');
2651
3070
  lines.push(' }');
2652
3071
  }
2653
3072
 
@@ -2659,7 +3078,7 @@ export class ServerCodegen extends BaseCodegen {
2659
3078
  lines.push(' if (__pathname === "/" && typeof __clientHTML !== "undefined") {');
2660
3079
  lines.push(' return new Response(__clientHTML, { status: 200, headers: { "Content-Type": "text/html", ...(__cors) } });');
2661
3080
  lines.push(' }');
2662
- lines.push(' const __notFound = Response.json({ error: "Not Found" }, { status: 404, headers: __cors });');
3081
+ lines.push(' const __notFound = __errorResponse(404, "NOT_FOUND", "Not Found", null, __cors);');
2663
3082
  lines.push(' __log("warn", "Not Found", { rid: __rid, method: req.method, path: __pathname, status: 404, ms: Date.now() - __startTime });');
2664
3083
  lines.push(' return __notFound;');
2665
3084
 
@@ -2670,7 +3089,7 @@ export class ServerCodegen extends BaseCodegen {
2670
3089
  lines.push(' if (__res && __sessionIsNew) {');
2671
3090
  lines.push(' const __signed = await __signSessionId(__sessionId);');
2672
3091
  lines.push(' const __h = new Headers(__res.headers);');
2673
- lines.push(' __h.set("Set-Cookie", `${__sessionCookieName}=${__signed}; Path=/; HttpOnly; Max-Age=${__sessionMaxAge}`);');
3092
+ lines.push(' __h.set("Set-Cookie", `${__sessionCookieName}=${__signed}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${__sessionMaxAge}`);');
2674
3093
  lines.push(' return new Response(__res.body, { status: __res.status, headers: __h });');
2675
3094
  lines.push(' }');
2676
3095
  lines.push(' return __res;');
@@ -2695,18 +3114,39 @@ export class ServerCodegen extends BaseCodegen {
2695
3114
  const portVar = blockName ? `PORT_${blockName.toUpperCase().replace(/[^A-Z0-9]/g, '_')}` : 'PORT';
2696
3115
  lines.push('// ── Start Server ──');
2697
3116
  lines.push(`const __port = process.env.${portVar} || process.env.PORT || 3000;`);
2698
- // Compression wrapper
2699
- if (compressionConfig) {
2700
- lines.push('const __fetchHandler = async (req) => {');
3117
+ // Idempotency + Compression wrapper
3118
+ if (!isFastMode) {
3119
+ lines.push('const __idempotentFetch = async (req) => {');
3120
+ if (compressionConfig) {
3121
+ lines.push(' const __rawRes = await __handleRequest(req);');
3122
+ lines.push(' if (!__rawRes) return __rawRes;');
3123
+ lines.push(' const res = await __compressResponse(req, __rawRes);');
3124
+ } else {
3125
+ lines.push(' const res = await __handleRequest(req);');
3126
+ }
3127
+ lines.push(' const __idk = req.headers.get("Idempotency-Key");');
3128
+ lines.push(' if (__idk && res && req.method !== "GET" && req.method !== "HEAD" && req.method !== "OPTIONS") {');
3129
+ lines.push(' try {');
3130
+ lines.push(' const __cloned = res.clone();');
3131
+ lines.push(' const __resBody = await __cloned.json();');
3132
+ lines.push(' __storeIdempotencyResult(__idk, res.status, __resBody, {});');
3133
+ lines.push(' } catch {}');
3134
+ lines.push(' }');
3135
+ lines.push(' if (res) __applySecurityHeaders(res.headers);');
3136
+ lines.push(' return res;');
3137
+ lines.push('};');
3138
+ } else if (compressionConfig) {
3139
+ lines.push('const __idempotentFetch = async (req) => {');
2701
3140
  lines.push(' const res = await __handleRequest(req);');
2702
3141
  lines.push(' if (!res) return res;');
2703
3142
  lines.push(' return __compressResponse(req, res);');
2704
3143
  lines.push('};');
2705
3144
  }
3145
+ const fetchHandler = (!isFastMode || compressionConfig) ? '__idempotentFetch' : '__handleRequest';
2706
3146
  lines.push(`const __server = Bun.serve({`);
2707
3147
  lines.push(` port: __port,`);
2708
3148
  lines.push(` maxRequestBodySize: __maxBodySize,`);
2709
- lines.push(` fetch: ${compressionConfig ? '__fetchHandler' : '__handleRequest'},`);
3149
+ lines.push(` fetch: ${fetchHandler},`);
2710
3150
  if (tlsConfig) {
2711
3151
  const certExpr = tlsConfig.cert ? this.genExpression(tlsConfig.cert) : 'undefined';
2712
3152
  const keyExpr = tlsConfig.key ? this.genExpression(tlsConfig.key) : 'undefined';
@@ -2720,18 +3160,23 @@ export class ServerCodegen extends BaseCodegen {
2720
3160
  }
2721
3161
  if (wsDecl) {
2722
3162
  lines.push(` websocket: {`);
3163
+ lines.push(` idleTimeout: 120,`); // 2 minute idle timeout with auto-ping
2723
3164
  if (wsDecl.handlers.on_open) {
2724
3165
  lines.push(` open(ws) { __wsClients.add(ws); __wsHandlers.on_open(ws); },`);
2725
3166
  } else {
2726
3167
  lines.push(` open(ws) { __wsClients.add(ws); },`);
2727
3168
  }
2728
3169
  if (wsDecl.handlers.on_message) {
2729
- lines.push(` message(ws, message) { __wsHandlers.on_message(ws, message); },`);
3170
+ // Rate limit check before dispatching to user handler
3171
+ lines.push(` message(ws, message) {`);
3172
+ lines.push(` if (!__wsCheckRate(ws)) { ws.send(JSON.stringify({ error: "RATE_LIMITED", message: "Too many messages" })); return; }`);
3173
+ lines.push(` __wsHandlers.on_message(ws, message);`);
3174
+ lines.push(` },`);
2730
3175
  }
2731
3176
  if (wsDecl.handlers.on_close) {
2732
- lines.push(` close(ws, code, reason) { __wsClients.delete(ws); for (const [,r] of __wsRooms) r.delete(ws); __wsHandlers.on_close(ws, code, reason); },`);
3177
+ lines.push(` close(ws, code, reason) { __wsClients.delete(ws); __wsRateLimit.delete(ws); for (const [,r] of __wsRooms) r.delete(ws); __wsHandlers.on_close(ws, code, reason); },`);
2733
3178
  } else {
2734
- lines.push(` close(ws) { __wsClients.delete(ws); for (const [,r] of __wsRooms) r.delete(ws); },`);
3179
+ lines.push(` close(ws) { __wsClients.delete(ws); __wsRateLimit.delete(ws); for (const [,r] of __wsRooms) r.delete(ws); },`);
2735
3180
  }
2736
3181
  if (wsDecl.handlers.on_error) {
2737
3182
  lines.push(` error(ws, error) { __wsHandlers.on_error(ws, error); },`);