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.
- package/package.json +14 -2
- package/src/analyzer/analyzer.js +10 -5
- package/src/analyzer/type-registry.js +22 -3
- package/src/codegen/base-codegen.js +15 -4
- package/src/codegen/client-codegen.js +9 -6
- package/src/codegen/codegen.js +3 -2
- package/src/codegen/server-codegen.js +526 -81
- package/src/lsp/server.js +44 -25
- package/src/parser/server-ast.js +2 -1
- package/src/parser/server-parser.js +12 -1
- package/src/runtime/embedded.js +3 -3
- package/src/runtime/reactivity.js +405 -23
- package/src/runtime/router.js +215 -25
- package/src/runtime/rpc.js +152 -17
- package/src/runtime/ssr.js +66 -10
- package/src/runtime/testing.js +241 -0
- package/src/version.js +1 -1
|
@@ -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
|
|
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 : "
|
|
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('
|
|
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) : '
|
|
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) {
|
|
947
|
-
lines.push('
|
|
948
|
-
lines.push('
|
|
949
|
-
lines.push('
|
|
950
|
-
lines.push('
|
|
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.
|
|
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('
|
|
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('
|
|
990
|
-
lines.push('
|
|
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) : '
|
|
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.
|
|
1142
|
-
lines.push(' job.
|
|
1143
|
-
lines.push('
|
|
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,
|
|
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) {
|
|
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
|
|
1557
|
-
lines.push('
|
|
1558
|
-
lines.push('
|
|
1559
|
-
lines.push('
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1812
|
-
|
|
1813
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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")
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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 (
|
|
2700
|
-
lines.push('const
|
|
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: ${
|
|
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
|
-
|
|
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); },`);
|