tova 0.4.7 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import { BaseCodegen } from './base-codegen.js';
2
+ import { SecurityCodegen } from './security-codegen.js';
2
3
 
3
4
  export class ServerCodegen extends BaseCodegen {
4
5
  _astUsesIdentifier(blocks, name) {
@@ -153,9 +154,16 @@ export class ServerCodegen extends BaseCodegen {
153
154
  }
154
155
  }
155
156
 
156
- generate(serverBlocks, sharedCode, blockName = null, peerBlocks = null, sharedBlocks = []) {
157
+ generate(serverBlocks, sharedCode, blockName = null, peerBlocks = null, sharedBlocks = [], securityConfig = null) {
157
158
  const lines = [];
158
159
 
160
+ // Generate security code fragments if security block is present
161
+ let securityFragments = null;
162
+ if (securityConfig) {
163
+ const secGen = new SecurityCodegen();
164
+ securityFragments = secGen.generateServerSecurity(securityConfig);
165
+ }
166
+
159
167
  // Shared code
160
168
  if (sharedCode.trim()) {
161
169
  lines.push('// ── Shared ──');
@@ -282,6 +290,19 @@ export class ServerCodegen extends BaseCodegen {
282
290
  collectFromBody(block.body);
283
291
  }
284
292
 
293
+ // Security block overrides: security block takes precedence over inline declarations
294
+ if (securityFragments) {
295
+ if (securityFragments.authConfig && !authConfig) {
296
+ authConfig = securityFragments.authConfig;
297
+ }
298
+ if (securityFragments.corsConfig && !corsConfig) {
299
+ corsConfig = securityFragments.corsConfig;
300
+ }
301
+ if (securityFragments.rateLimitConfig && !rateLimitConfig) {
302
+ rateLimitConfig = securityFragments.rateLimitConfig;
303
+ }
304
+ }
305
+
285
306
  // Collect type declarations from shared blocks for model/ORM generation
286
307
  const sharedTypes = new Map(); // typeName -> { fields: [{ name, type }] }
287
308
  const _collectTypes = (stmts) => {
@@ -333,13 +354,14 @@ export class ServerCodegen extends BaseCodegen {
333
354
  const usesDb = this._astUsesIdentifier(serverBlocks, 'db');
334
355
 
335
356
  // Check if rate limiting is needed
336
- const needsRateLimitStore = !!rateLimitConfig || routes.some(r => (r.decorators || []).some(d => d.name === 'rate_limit'));
357
+ const hasSecurityProtectRateLimit = securityConfig && securityConfig.protects && securityConfig.protects.some(p => p.config.rate_limit);
358
+ const needsRateLimitStore = !!rateLimitConfig || routes.some(r => (r.decorators || []).some(d => d.name === 'rate_limit')) || hasSecurityProtectRateLimit;
337
359
 
338
360
  // Fast mode: emit a minimal request handler when no complex features are used
339
361
  const allRoutesStatic = routes.every(r => !r.path.includes(':') && !r.path.includes('*'));
340
362
  const hasDynamicRoutes = !allRoutesStatic;
341
363
  const isFastMode = !corsConfig && !authConfig && !sessionConfig && !rateLimitConfig &&
342
- !errorHandler && !wsDecl && !staticDecl && !compressionConfig &&
364
+ !errorHandler && !wsDecl && !staticDecl && !compressionConfig && !securityFragments &&
343
365
  middlewares.length === 0 && !dbConfig && subscriptions.length === 0 &&
344
366
  backgroundJobs.length === 0 && sseDecls.length === 0 && !cacheConfig &&
345
367
  routes.every(r => !(r.decorators || []).length && !r._groupMiddlewares?.length && !r._version);
@@ -839,6 +861,13 @@ export class ServerCodegen extends BaseCodegen {
839
861
  lines.push(' const claims = { ...payload, iat: now };');
840
862
  lines.push(' if (options.expires_in) claims.exp = now + options.expires_in;');
841
863
  lines.push(' if (options.exp) claims.exp = options.exp;');
864
+ // Fix 2: Auto-include iss/aud from auth config when not explicitly set
865
+ if (authConfig && authConfig.issuer) {
866
+ lines.push(` if (!claims.iss) claims.iss = ${this.genExpression(authConfig.issuer)};`);
867
+ }
868
+ if (authConfig && authConfig.audience) {
869
+ lines.push(` if (!claims.aud) claims.aud = ${this.genExpression(authConfig.audience)};`);
870
+ }
842
871
  lines.push(' const __b64url = (obj) => btoa(JSON.stringify(obj)).replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/, "");');
843
872
  lines.push(' const __headerB64 = __b64url(header);');
844
873
  lines.push(' const __payloadB64 = __b64url(claims);');
@@ -920,6 +949,7 @@ export class ServerCodegen extends BaseCodegen {
920
949
  lines.push(' "Access-Control-Max-Age": __corsMaxAge,');
921
950
  lines.push(' };');
922
951
  lines.push(' if (__corsCredentials) h["Access-Control-Allow-Credentials"] = "true";');
952
+ lines.push(' if (!__corsOrigins.includes("*")) h["Vary"] = "Origin";');
923
953
  lines.push(' return h;');
924
954
  lines.push('}');
925
955
  } else if (authConfig || sessionConfig) {
@@ -935,7 +965,7 @@ export class ServerCodegen extends BaseCodegen {
935
965
  lines.push(' if (!origin) return __corsHeadersConst;');
936
966
  lines.push(' const reqUrl = req.url ? new URL(req.url) : null;');
937
967
  lines.push(' if (reqUrl && origin === reqUrl.origin) {');
938
- lines.push(' return { ...__corsHeadersConst, "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Credentials": "true" };');
968
+ lines.push(' return { ...__corsHeadersConst, "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Credentials": "true", "Vary": "Origin" };');
939
969
  lines.push(' }');
940
970
  lines.push(' return __corsHeadersConst;');
941
971
  lines.push('}');
@@ -963,14 +993,32 @@ export class ServerCodegen extends BaseCodegen {
963
993
  lines.push(' "Referrer-Policy": "strict-origin-when-cross-origin",');
964
994
  lines.push(' "Permissions-Policy": "camera=(), microphone=(), geolocation=()",');
965
995
  lines.push('});');
966
- if (tlsConfig) {
967
- lines.push('const __hstsHeader = { "Strict-Transport-Security": "max-age=31536000; includeSubDomains" };');
996
+ // HSTS: explicit security block config, auto-enable with auth, or TLS config
997
+ const hstsConf = securityFragments && securityFragments.hstsConfig;
998
+ const hstsExplicitlyDisabled = hstsConf && hstsConf.enabled && hstsConf.enabled.type === 'BooleanLiteral' && hstsConf.enabled.value === false;
999
+ const needsHsts = !hstsExplicitlyDisabled && (tlsConfig || hstsConf);
1000
+ if (needsHsts) {
1001
+ if (hstsConf && !hstsConf.__autoEnabled && hstsConf.max_age) {
1002
+ // Custom HSTS from security block
1003
+ const maxAge = hstsConf.max_age.value || 31536000;
1004
+ const inclSub = hstsConf.include_subdomains ? (hstsConf.include_subdomains.value !== false) : true;
1005
+ const preload = hstsConf.preload ? hstsConf.preload.value === true : false;
1006
+ let hstsVal = `max-age=${maxAge}`;
1007
+ if (inclSub) hstsVal += '; includeSubDomains';
1008
+ if (preload) hstsVal += '; preload';
1009
+ lines.push(`const __hstsHeader = { "Strict-Transport-Security": ${JSON.stringify(hstsVal)} };`);
1010
+ } else {
1011
+ lines.push('const __hstsHeader = { "Strict-Transport-Security": "max-age=31536000; includeSubDomains" };');
1012
+ }
968
1013
  }
969
1014
  lines.push('function __applySecurityHeaders(headers) {');
970
1015
  lines.push(' for (const [k, v] of Object.entries(__securityHeaders)) headers.set(k, v);');
971
- if (tlsConfig) {
1016
+ if (needsHsts) {
972
1017
  lines.push(' headers.set("Strict-Transport-Security", __hstsHeader["Strict-Transport-Security"]);');
973
1018
  }
1019
+ if (securityFragments && securityFragments.cspCode) {
1020
+ lines.push(' if (typeof __getCspHeader === "function") headers.set("Content-Security-Policy", __getCspHeader());');
1021
+ }
974
1022
  lines.push('}');
975
1023
  lines.push('');
976
1024
  }
@@ -995,18 +1043,48 @@ export class ServerCodegen extends BaseCodegen {
995
1043
  } else {
996
1044
  // JWT auth (default)
997
1045
  const secretExpr = authConfig.secret ? this.genExpression(authConfig.secret) : 'process.env.AUTH_SECRET || process.env.JWT_SECRET';
1046
+ const isCookieAuth = authConfig.storage && authConfig.storage.type === 'StringLiteral' && authConfig.storage.value === 'cookie';
998
1047
  lines.push(`const __authSecret = ${secretExpr};`);
999
1048
  if (!authConfig.secret) {
1000
1049
  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
1050
  }
1051
+ if (isCookieAuth) {
1052
+ const expiresExpr = authConfig.expires ? this.genExpression(authConfig.expires) : '86400';
1053
+ lines.push(`const __authCookieMaxAge = ${expiresExpr};`);
1054
+ lines.push('function __setAuthCookie(res, token) {');
1055
+ lines.push(' const h = new Headers(res.headers);');
1056
+ lines.push(' h.set("Set-Cookie", `__tova_auth=${token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${__authCookieMaxAge}`);');
1057
+ lines.push(' return new Response(res.body, { status: res.status, headers: h });');
1058
+ lines.push('}');
1059
+ lines.push('function __clearAuthCookie(res) {');
1060
+ lines.push(' const h = new Headers(res.headers);');
1061
+ lines.push(' h.set("Set-Cookie", "__tova_auth=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0");');
1062
+ lines.push(' return new Response(res.body, { status: res.status, headers: h });');
1063
+ lines.push('}');
1064
+ }
1002
1065
  lines.push('let __authKey = null;');
1003
1066
  lines.push('async function __authenticate(req) {');
1004
- lines.push(' const authHeader = req.headers.get("Authorization");');
1005
- lines.push(' if (!authHeader || !authHeader.startsWith("Bearer ")) return null;');
1006
- lines.push(' const token = authHeader.slice(7);');
1067
+ if (isCookieAuth) {
1068
+ // Read token from cookie first, fall back to Authorization header
1069
+ lines.push(' let token = null;');
1070
+ lines.push(' const __cookieStr = req.headers.get("Cookie") || "";');
1071
+ lines.push(' const __authCookie = __cookieStr.split(";").map(s => s.trim()).find(s => s.startsWith("__tova_auth="));');
1072
+ lines.push(' if (__authCookie) token = __authCookie.slice("__tova_auth=".length);');
1073
+ lines.push(' if (!token) {');
1074
+ lines.push(' const authHeader = req.headers.get("Authorization");');
1075
+ lines.push(' if (authHeader && authHeader.startsWith("Bearer ")) token = authHeader.slice(7);');
1076
+ lines.push(' }');
1077
+ lines.push(' if (!token) return null;');
1078
+ } else {
1079
+ lines.push(' const authHeader = req.headers.get("Authorization");');
1080
+ lines.push(' if (!authHeader || !authHeader.startsWith("Bearer ")) return null;');
1081
+ lines.push(' const token = authHeader.slice(7);');
1082
+ }
1007
1083
  lines.push(' try {');
1008
1084
  lines.push(' const parts = token.split(".");');
1009
1085
  lines.push(' if (parts.length !== 3) return null;');
1086
+ lines.push(' const __header = JSON.parse(atob(parts[0].replace(/-/g, "+").replace(/_/g, "/")));');
1087
+ lines.push(' if (__header.alg !== "HS256") return null;');
1010
1088
  lines.push(' if (!__authKey) {');
1011
1089
  lines.push(' __authKey = await crypto.subtle.importKey(');
1012
1090
  lines.push(' "raw", new TextEncoder().encode(__authSecret),');
@@ -1022,6 +1100,14 @@ export class ServerCodegen extends BaseCodegen {
1022
1100
  lines.push(' if (__sigBuf.length !== __tokBuf.length || !require("crypto").timingSafeEqual(__sigBuf, __tokBuf)) return null;');
1023
1101
  lines.push(' const __payload = JSON.parse(atob(parts[1].replace(/-/g, "+").replace(/_/g, "/")));');
1024
1102
  lines.push(' if (__payload.exp && __payload.exp < Math.floor(Date.now() / 1000)) return null;');
1103
+ lines.push(' if (__payload.nbf && __payload.nbf > Math.floor(Date.now() / 1000)) return null;');
1104
+ // Fix 2: JWT iss/aud claim validation
1105
+ if (authConfig.issuer) {
1106
+ lines.push(` if (__payload.iss !== ${this.genExpression(authConfig.issuer)}) return null;`);
1107
+ }
1108
+ if (authConfig.audience) {
1109
+ lines.push(` if (__payload.aud !== ${this.genExpression(authConfig.audience)}) return null;`);
1110
+ }
1025
1111
  lines.push(' return __payload;');
1026
1112
  lines.push(' } catch { return null; }');
1027
1113
  lines.push('}');
@@ -1029,6 +1115,62 @@ export class ServerCodegen extends BaseCodegen {
1029
1115
  lines.push('');
1030
1116
  }
1031
1117
 
1118
+ // ════════════════════════════════════════════════════════════
1119
+ // 9b. Security Block — roles, protection, CSP, sensitive, audit
1120
+ // ════════════════════════════════════════════════════════════
1121
+ if (securityFragments) {
1122
+ if (securityFragments.roleDefinitions) {
1123
+ lines.push(securityFragments.roleDefinitions);
1124
+ lines.push('');
1125
+ }
1126
+ if (securityFragments.protectCode) {
1127
+ lines.push(securityFragments.protectCode);
1128
+ lines.push('');
1129
+ }
1130
+ if (securityFragments.cspCode) {
1131
+ lines.push(securityFragments.cspCode);
1132
+ lines.push('');
1133
+ }
1134
+ if (securityFragments.sensitiveCode) {
1135
+ lines.push(securityFragments.sensitiveCode);
1136
+ lines.push('');
1137
+ }
1138
+ if (securityFragments.auditCode) {
1139
+ lines.push(securityFragments.auditCode);
1140
+ lines.push('');
1141
+ }
1142
+ }
1143
+
1144
+ // ════════════════════════════════════════════════════════════
1145
+ // 9c. Client IP Helper — trust_proxy aware
1146
+ // ════════════════════════════════════════════════════════════
1147
+ if (!isFastMode) {
1148
+ lines.push('// ── Client IP ──');
1149
+ const trustProxy = securityFragments && securityFragments.trustProxyConfig;
1150
+ if (trustProxy === true) {
1151
+ lines.push('function __getClientIp(req) {');
1152
+ lines.push(' const xff = req.headers.get("x-forwarded-for");');
1153
+ lines.push(' if (xff) return xff.split(",")[0].trim();');
1154
+ lines.push(' return req.headers.get("x-real-ip") || __server.requestIP(req)?.address || "unknown";');
1155
+ lines.push('}');
1156
+ } else if (trustProxy === 'loopback') {
1157
+ lines.push('function __getClientIp(req) {');
1158
+ lines.push(' const direct = req.headers.get("x-real-ip") || __server.requestIP(req)?.address || "unknown";');
1159
+ lines.push(' if (direct === "127.0.0.1" || direct === "::1" || direct === "::ffff:127.0.0.1") {');
1160
+ lines.push(' const xff = req.headers.get("x-forwarded-for");');
1161
+ lines.push(' if (xff) return xff.split(",")[0].trim();');
1162
+ lines.push(' }');
1163
+ lines.push(' return direct;');
1164
+ lines.push('}');
1165
+ } else {
1166
+ // Default: do not trust x-forwarded-for
1167
+ lines.push('function __getClientIp(req) {');
1168
+ lines.push(' return req.headers.get("x-real-ip") || __server.requestIP(req)?.address || "unknown";');
1169
+ lines.push('}');
1170
+ }
1171
+ lines.push('');
1172
+ }
1173
+
1032
1174
  // ════════════════════════════════════════════════════════════
1033
1175
  // 10. Max Body Size
1034
1176
  // ════════════════════════════════════════════════════════════
@@ -1232,13 +1374,29 @@ export class ServerCodegen extends BaseCodegen {
1232
1374
  lines.push(' }');
1233
1375
  lines.push('}, 60000);');
1234
1376
  }
1377
+ // Fix 8: Session regeneration helper
1378
+ lines.push('function __regenerateSession(req) {');
1379
+ lines.push(' const oldData = req.__session ? { ...req.__session.data } : {};');
1380
+ lines.push(' const newId = crypto.randomUUID();');
1381
+ lines.push(' if (req.__session && req.__session.destroy) req.__session.destroy();');
1382
+ lines.push(' req.__session = __createSession(newId);');
1383
+ lines.push(' for (const [k, v] of Object.entries(oldData)) req.__session.set(k, v);');
1384
+ lines.push(' req.__sessionRegenerated = true;');
1385
+ lines.push(' req.__newSessionId = newId;');
1386
+ lines.push(' return newId;');
1387
+ lines.push('}');
1235
1388
  lines.push('');
1236
1389
  }
1237
1390
 
1238
1391
  // ════════════════════════════════════════════════════════════
1239
1392
  // 11d. CSRF Protection — server-side token generation + validation
1240
1393
  // ════════════════════════════════════════════════════════════
1241
- const needsCsrf = !isFastMode && (sessionConfig || authConfig);
1394
+ // Check if CSRF is explicitly disabled via security block: csrf { enabled: false }
1395
+ const csrfExplicitlyDisabled = securityFragments && securityFragments.csrfConfig &&
1396
+ securityFragments.csrfConfig.enabled &&
1397
+ ((securityFragments.csrfConfig.enabled.type === 'BooleanLiteral' && securityFragments.csrfConfig.enabled.value === false) ||
1398
+ (securityFragments.csrfConfig.enabled === false));
1399
+ const needsCsrf = !isFastMode && !csrfExplicitlyDisabled && (sessionConfig || authConfig);
1242
1400
  if (needsCsrf) {
1243
1401
  lines.push('// ── CSRF Protection ──');
1244
1402
  lines.push('let __csrfKey = null;');
@@ -1252,32 +1410,57 @@ export class ServerCodegen extends BaseCodegen {
1252
1410
  lines.push(' }');
1253
1411
  lines.push(' return __csrfKey;');
1254
1412
  lines.push('}');
1255
- lines.push('async function __generateCSRFToken() {');
1413
+ lines.push('async function __generateCSRFToken(bindingId) {');
1256
1414
  lines.push(' const key = await __getCSRFKey();');
1257
1415
  lines.push(' const timestamp = Date.now().toString(36);');
1258
1416
  lines.push(' const nonce = crypto.getRandomValues(new Uint8Array(8));');
1259
1417
  lines.push(' const nonceHex = [...nonce].map(b => b.toString(16).padStart(2, "0")).join("");');
1260
- lines.push(' const data = timestamp + ":" + nonceHex;');
1418
+ lines.push(' const data = timestamp + ":" + nonceHex + ":" + (bindingId || "anon");');
1261
1419
  lines.push(' const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));');
1262
1420
  lines.push(' const sigHex = [...new Uint8Array(sig)].map(b => b.toString(16).padStart(2, "0")).join("");');
1263
1421
  lines.push(' return data + ":" + sigHex;');
1264
1422
  lines.push('}');
1265
- lines.push('async function __validateCSRFToken(token) {');
1423
+ lines.push('async function __validateCSRFToken(token, bindingId) {');
1266
1424
  lines.push(' if (!token || typeof token !== "string") return false;');
1267
1425
  lines.push(' const parts = token.split(":");');
1268
- lines.push(' if (parts.length !== 3) return false;');
1269
- lines.push(' const [timestamp, nonce, sig] = parts;');
1426
+ lines.push(' if (parts.length !== 4) return false;');
1427
+ lines.push(' const [timestamp, nonce, binding, sig] = parts;');
1428
+ lines.push(' if (binding !== (bindingId || "anon")) return false;');
1270
1429
  lines.push(' const age = Date.now() - parseInt(timestamp, 36);');
1271
1430
  lines.push(' if (isNaN(age) || age < 0 || age > 86400000) return false;');
1272
1431
  lines.push(' const key = await __getCSRFKey();');
1273
- lines.push(' const data = timestamp + ":" + nonce;');
1432
+ lines.push(' const data = timestamp + ":" + nonce + ":" + binding;');
1274
1433
  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);');
1434
+ lines.push(' const expectedBytes = new Uint8Array(expectedSig);');
1435
+ lines.push(' let sigBytes; try { sigBytes = new Uint8Array(Buffer.from(sig, "hex")); } catch { return false; }');
1436
+ lines.push(' if (sigBytes.length !== expectedBytes.length) return false;');
1437
+ lines.push(' return require("crypto").timingSafeEqual(sigBytes, expectedBytes);');
1280
1438
  lines.push('}');
1439
+ // Fix 9: CSRF exempt patterns
1440
+ const csrfExempt = securityFragments && securityFragments.csrfConfig && securityFragments.csrfConfig.exempt;
1441
+ if (csrfExempt) {
1442
+ lines.push('const __csrfExemptPatterns = [');
1443
+ // csrfExempt is an AST node (ArrayLiteral/ArrayExpression)
1444
+ if (csrfExempt.elements) {
1445
+ for (const elem of csrfExempt.elements) {
1446
+ if (elem.type === 'StringLiteral' || elem.value) {
1447
+ const pattern = elem.value;
1448
+ const regexPattern = pattern
1449
+ .replace(/\*\*/g, '\x00GLOBSTAR\x00')
1450
+ .replace(/\*/g, '\x00STAR\x00')
1451
+ .replace(/[.+?^${}()|[\]\\/]/g, '\\$&')
1452
+ .replace(/\x00STAR\x00/g, '[^/]*')
1453
+ .replace(/\x00GLOBSTAR\x00/g, '.*');
1454
+ lines.push(` /^${regexPattern}$/,`);
1455
+ }
1456
+ }
1457
+ }
1458
+ lines.push('];');
1459
+ lines.push('function __isCsrfExempt(path) {');
1460
+ lines.push(' for (const p of __csrfExemptPatterns) { if (p.test(path)) return true; }');
1461
+ lines.push(' return false;');
1462
+ lines.push('}');
1463
+ }
1281
1464
  lines.push('');
1282
1465
  }
1283
1466
 
@@ -1957,8 +2140,14 @@ export class ServerCodegen extends BaseCodegen {
1957
2140
  // CSRF Token Endpoint
1958
2141
  if (needsCsrf) {
1959
2142
  lines.push('// ── CSRF Token Endpoint ──');
1960
- lines.push('__addRoute("GET", "/csrf-token", async () => {');
1961
- lines.push(' const token = await __generateCSRFToken();');
2143
+ if (sessionConfig) {
2144
+ lines.push('__addRoute("GET", "/csrf-token", async (req) => {');
2145
+ lines.push(' const __bindingId = req.__session ? req.__session.get("__sid") || "anon" : "anon";');
2146
+ lines.push(' const token = await __generateCSRFToken(__bindingId);');
2147
+ } else {
2148
+ lines.push('__addRoute("GET", "/csrf-token", async () => {');
2149
+ lines.push(' const token = await __generateCSRFToken();');
2150
+ }
1962
2151
  lines.push(' return Response.json({ token });');
1963
2152
  lines.push('});');
1964
2153
  lines.push('');
@@ -1988,12 +2177,29 @@ export class ServerCodegen extends BaseCodegen {
1988
2177
  } else {
1989
2178
  lines.push(` const result = await ${name}();`);
1990
2179
  }
1991
- lines.push(` return Response.json({ result });`);
2180
+ if (securityFragments && securityFragments.hasAutoSanitize) {
2181
+ const userRef = authConfig ? 'await __authenticate(req)' : 'null';
2182
+ lines.push(` return Response.json({ result: __autoSanitize(result, ${userRef}) });`);
2183
+ } else {
2184
+ lines.push(` return Response.json({ result });`);
2185
+ }
1992
2186
  lines.push(`});`);
1993
2187
  lines.push('');
1994
2188
  }
1995
2189
  }
1996
2190
 
2191
+ // Cookie auth logout endpoint
2192
+ if (authConfig) {
2193
+ const isCookieAuth = authConfig.storage && authConfig.storage.type === 'StringLiteral' && authConfig.storage.value === 'cookie';
2194
+ if (isCookieAuth) {
2195
+ lines.push('// ── Cookie Auth Logout ──');
2196
+ lines.push('__addRoute("POST", "/rpc/__logout", async (req) => {');
2197
+ lines.push(' return __clearAuthCookie(Response.json({ ok: true }));');
2198
+ lines.push('});');
2199
+ lines.push('');
2200
+ }
2201
+ }
2202
+
1997
2203
  // ════════════════════════════════════════════════════════════
1998
2204
  // 16. Event RPC endpoint (F5) — if multi-server + subscriptions
1999
2205
  // ════════════════════════════════════════════════════════════
@@ -2079,7 +2285,7 @@ export class ServerCodegen extends BaseCodegen {
2079
2285
  if (rateLimitDec && needsRateLimitStore) {
2080
2286
  const rlMax = rateLimitDec.args[0] ? this.genExpression(rateLimitDec.args[0]) : '100';
2081
2287
  const rlWindow = rateLimitDec.args[1] ? this.genExpression(rateLimitDec.args[1]) : '60';
2082
- lines.push(` const __rlIp = req.headers.get("x-forwarded-for") || "unknown";`);
2288
+ lines.push(` const __rlIp = __getClientIp(req);`);
2083
2289
  lines.push(` const __rlRoute = __checkRateLimit(\`route:${path}:\${__rlIp}\`, ${rlMax}, ${rlWindow});`);
2084
2290
  lines.push(` if (__rlRoute.limited) return __errorResponse(429, "RATE_LIMITED", "Too Many Requests", null, { "Retry-After": String(__rlRoute.retryAfter) });`);
2085
2291
  }
@@ -2322,10 +2528,20 @@ export class ServerCodegen extends BaseCodegen {
2322
2528
  lines.push(` return new Response(res.body, { status: res.status, headers: h });`);
2323
2529
  lines.push(` };`);
2324
2530
  lines.push(` if (__result instanceof Response) return __addVersionHeaders(__result);`);
2325
- lines.push(` return __addVersionHeaders(Response.json(__result));`);
2531
+ if (securityFragments && securityFragments.hasAutoSanitize) {
2532
+ const userRef = (hasAuth && authConfig) ? '__user' : (authConfig ? 'await __authenticate(req)' : 'null');
2533
+ lines.push(` return __addVersionHeaders(Response.json(__autoSanitize(__result, ${userRef})));`);
2534
+ } else {
2535
+ lines.push(` return __addVersionHeaders(Response.json(__result));`);
2536
+ }
2326
2537
  } else {
2327
2538
  lines.push(` if (__result instanceof Response) return __result;`);
2328
- lines.push(` return Response.json(__result);`);
2539
+ if (securityFragments && securityFragments.hasAutoSanitize) {
2540
+ const userRef = (hasAuth && authConfig) ? '__user' : (authConfig ? 'await __authenticate(req)' : 'null');
2541
+ lines.push(` return Response.json(__autoSanitize(__result, ${userRef}));`);
2542
+ } else {
2543
+ lines.push(` return Response.json(__result);`);
2544
+ }
2329
2545
  }
2330
2546
  lines.push(`}${versionArg});`);
2331
2547
  lines.push('');
@@ -2796,7 +3012,10 @@ export class ServerCodegen extends BaseCodegen {
2796
3012
  lines.push(' const __rawUrl = req.url;');
2797
3013
  lines.push(' const __pStart = __rawUrl.indexOf("/", 12);');
2798
3014
  lines.push(' const __qIdx = __rawUrl.indexOf("?", __pStart);');
2799
- lines.push(' const __pathname = __qIdx === -1 ? __rawUrl.slice(__pStart) : __rawUrl.slice(__pStart, __qIdx);');
3015
+ lines.push(' let __pathname = __qIdx === -1 ? __rawUrl.slice(__pStart) : __rawUrl.slice(__pStart, __qIdx);');
3016
+ lines.push(' try { __pathname = decodeURIComponent(__pathname); } catch {}');
3017
+ lines.push(' __pathname = __pathname.replace(/\\/\\/+/g, "/");');
3018
+ lines.push(' if (__pathname.length > 1 && __pathname.endsWith("/")) __pathname = __pathname.slice(0, -1);');
2800
3019
  lines.push(' const __method = req.method;');
2801
3020
 
2802
3021
  // OPTIONS fast path
@@ -2853,7 +3072,20 @@ export class ServerCodegen extends BaseCodegen {
2853
3072
  lines.push(' const __rawUrl = req.url;');
2854
3073
  lines.push(' const __pStart = __rawUrl.indexOf("/", 12);'); // skip "http://x:p" or "https://x:p"
2855
3074
  lines.push(' const __qIdx = __rawUrl.indexOf("?", __pStart);');
2856
- lines.push(' const __pathname = __qIdx === -1 ? __rawUrl.slice(__pStart) : __rawUrl.slice(__pStart, __qIdx);');
3075
+ lines.push(' let __pathname = __qIdx === -1 ? __rawUrl.slice(__pStart) : __rawUrl.slice(__pStart, __qIdx);');
3076
+ lines.push(' try { __pathname = decodeURIComponent(__pathname); } catch {}');
3077
+ lines.push(' __pathname = __pathname.replace(/\\/\\/+/g, "/");');
3078
+ // Resolve ../ sequences to prevent path traversal
3079
+ lines.push(' if (__pathname.includes("..")) {');
3080
+ lines.push(' const __parts = __pathname.split("/");');
3081
+ lines.push(' const __resolved = [];');
3082
+ lines.push(' for (const __seg of __parts) {');
3083
+ lines.push(' if (__seg === "..") { __resolved.pop(); }');
3084
+ lines.push(' else if (__seg !== ".") { __resolved.push(__seg); }');
3085
+ lines.push(' }');
3086
+ lines.push(' __pathname = __resolved.join("/") || "/";');
3087
+ lines.push(' }');
3088
+ lines.push(' if (__pathname.length > 1 && __pathname.endsWith("/")) __pathname = __pathname.slice(0, -1);');
2857
3089
  lines.push(' const __rid = __sanitizeRequestId(req.headers.get("X-Request-Id"));');
2858
3090
  lines.push(' const __startTime = Date.now();');
2859
3091
  lines.push(' const __cors = __getCorsHeaders(req);');
@@ -2901,7 +3133,7 @@ export class ServerCodegen extends BaseCodegen {
2901
3133
 
2902
3134
  // Global rate limit check (F2)
2903
3135
  if (rateLimitConfig) {
2904
- lines.push(' const __clientIp = req.headers.get("x-forwarded-for") || "unknown";');
3136
+ lines.push(' const __clientIp = __getClientIp(req);');
2905
3137
  lines.push(' const __rl = __checkRateLimit(__clientIp, __rateLimitMax, __rateLimitWindow);');
2906
3138
  lines.push(' if (__rl.limited) {');
2907
3139
  lines.push(' return __errorResponse(429, "RATE_LIMITED", "Too Many Requests", null, { ...__cors, "Retry-After": String(__rl.retryAfter) });');
@@ -2926,13 +3158,48 @@ export class ServerCodegen extends BaseCodegen {
2926
3158
 
2927
3159
  // CSRF validation on state-mutating requests
2928
3160
  if (needsCsrf) {
3161
+ const hasCsrfExempt = securityFragments && securityFragments.csrfConfig && securityFragments.csrfConfig.exempt;
2929
3162
  lines.push(' if (req.method !== "GET" && req.method !== "HEAD" && req.method !== "OPTIONS") {');
3163
+ if (hasCsrfExempt) {
3164
+ lines.push(' if (!__isCsrfExempt(__pathname)) {');
3165
+ }
2930
3166
  lines.push(' const __csrfToken = req.headers.get("X-Tova-CSRF") || req.headers.get("x-tova-csrf");');
2931
- lines.push(' const __csrfValid = await __validateCSRFToken(__csrfToken);');
3167
+ if (sessionConfig) {
3168
+ lines.push(' const __csrfBindingId = req.__session ? req.__session.get("__sid") || "anon" : "anon";');
3169
+ lines.push(' const __csrfValid = await __validateCSRFToken(__csrfToken, __csrfBindingId);');
3170
+ } else {
3171
+ lines.push(' const __csrfValid = await __validateCSRFToken(__csrfToken);');
3172
+ }
2932
3173
  lines.push(' if (!__csrfValid) {');
2933
3174
  lines.push(' __log("warn", "CSRF validation failed", { rid: __rid, method: req.method, path: __pathname });');
2934
3175
  lines.push(' return __errorResponse(403, "CSRF_INVALID", "CSRF token invalid or missing", null, __cors);');
2935
3176
  lines.push(' }');
3177
+ if (hasCsrfExempt) {
3178
+ lines.push(' }');
3179
+ }
3180
+ lines.push(' }');
3181
+ }
3182
+
3183
+ // Security block: route protection check
3184
+ if (securityFragments && securityFragments.protectCode) {
3185
+ lines.push(' // Security block: route protection');
3186
+ if (authConfig) {
3187
+ lines.push(' const __secUser = await __authenticate(req);');
3188
+ } else {
3189
+ lines.push(' const __secUser = null;');
3190
+ }
3191
+ lines.push(' const __protection = __checkProtection(__pathname, __secUser);');
3192
+ lines.push(' if (!__protection.allowed) {');
3193
+ lines.push(' const __statusCode = __secUser ? 403 : 401;');
3194
+ lines.push(' const __errorCode = __secUser ? "FORBIDDEN" : "AUTH_REQUIRED";');
3195
+ lines.push(' return __errorResponse(__statusCode, __errorCode, __protection.reason, null, __cors);');
3196
+ lines.push(' }');
3197
+ lines.push(' if (__protection.rateLimit && __protection.rateLimit.max) {');
3198
+ lines.push(' const __protectIp = __getClientIp(req);');
3199
+ lines.push(' const __protectRl = __checkRateLimit("protect:" + __pathname + ":" + __protectIp, __protection.rateLimit.max, __protection.rateLimit.window || 60);');
3200
+ lines.push(' if (__protectRl.limited) {');
3201
+ lines.push(' return __errorResponse(429, "RATE_LIMITED", "Too Many Requests", null, { ...__cors, "Retry-After": String(__protectRl.retryAfter) });');
3202
+ lines.push(' }');
2936
3203
  lines.push(' }');
2937
3204
  }
2938
3205
 
@@ -3086,8 +3353,9 @@ export class ServerCodegen extends BaseCodegen {
3086
3353
  lines.push(' } catch (__e) { throw __e; }');
3087
3354
  lines.push(' }).then(async (__res) => {');
3088
3355
  lines.push(' if (req.__session && req.__session.__flush) await req.__session.__flush();');
3089
- lines.push(' if (__res && __sessionIsNew) {');
3090
- lines.push(' const __signed = await __signSessionId(__sessionId);');
3356
+ lines.push(' if (__res && (__sessionIsNew || req.__sessionRegenerated)) {');
3357
+ lines.push(' const __sid = req.__newSessionId || __sessionId;');
3358
+ lines.push(' const __signed = await __signSessionId(__sid);');
3091
3359
  lines.push(' const __h = new Headers(__res.headers);');
3092
3360
  lines.push(' __h.set("Set-Cookie", `${__sessionCookieName}=${__signed}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${__sessionMaxAge}`);');
3093
3361
  lines.push(' return new Response(__res.body, { status: __res.status, headers: __h });');
package/src/parser/ast.js CHANGED
@@ -45,6 +45,14 @@ export class SharedBlock {
45
45
  }
46
46
  }
47
47
 
48
+ export class SecurityBlock {
49
+ constructor(body, loc) {
50
+ this.type = 'SecurityBlock';
51
+ this.body = body;
52
+ this.loc = loc;
53
+ }
54
+ }
55
+
48
56
  // ============================================================
49
57
  // Declarations
50
58
  // ============================================================
@@ -683,6 +691,19 @@ export {
683
691
  ModelDeclaration, AiConfigDeclaration,
684
692
  } from './server-ast.js';
685
693
 
694
+ // ============================================================
695
+ // Security-specific nodes (lazy-loaded from security-ast.js, re-exported for backward compat)
696
+ // ============================================================
697
+
698
+ export {
699
+ SecurityAuthDeclaration, SecurityRoleDeclaration,
700
+ SecurityProtectDeclaration, SecuritySensitiveDeclaration,
701
+ SecurityCorsDeclaration, SecurityCspDeclaration,
702
+ SecurityRateLimitDeclaration, SecurityCsrfDeclaration,
703
+ SecurityAuditDeclaration,
704
+ SecurityTrustProxyDeclaration, SecurityHstsDeclaration,
705
+ } from './security-ast.js';
706
+
686
707
  export class TestBlock {
687
708
  constructor(name, body, loc, options = {}) {
688
709
  this.type = 'TestBlock';
@@ -2,6 +2,7 @@ import { TokenType } from '../lexer/tokens.js';
2
2
  import * as AST from './ast.js';
3
3
  import { installServerParser } from './server-parser.js';
4
4
  import { installClientParser } from './client-parser.js';
5
+ import { installSecurityParser } from './security-parser.js';
5
6
 
6
7
  export class Parser {
7
8
  static MAX_EXPRESSION_DEPTH = 200;
@@ -291,6 +292,13 @@ export class Parser {
291
292
  if (this.check(TokenType.IDENTIFIER) && this.current().value === 'data' && this.peek(1).type === TokenType.LBRACE) {
292
293
  return this.parseDataBlock();
293
294
  }
295
+ // security block: security { ... }
296
+ if (this.check(TokenType.IDENTIFIER) && this.current().value === 'security' && this.peek(1).type === TokenType.LBRACE) {
297
+ if (!Parser.prototype._securityParserInstalled) {
298
+ installSecurityParser(Parser);
299
+ }
300
+ return this.parseSecurityBlock();
301
+ }
294
302
  // test block: test "name" { ... } or test { ... }
295
303
  if (this.check(TokenType.IDENTIFIER) && this.current().value === 'test') {
296
304
  const next = this.peek(1);