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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tova",
3
- "version": "0.4.7",
3
+ "version": "0.5.0",
4
4
  "description": "Tova — a modern programming language that transpiles to JavaScript, unifying frontend and backend",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -338,6 +338,9 @@ export class Analyzer {
338
338
 
339
339
  this.visitProgram(this.ast);
340
340
 
341
+ // Cross-block security validation (after all blocks are visited)
342
+ this._validateSecurityCrossBlock();
343
+
341
344
  // Check for unused variables/imports (#9)
342
345
  this._collectAllScopes(this.globalScope);
343
346
  this._checkUnusedSymbols();
@@ -788,6 +791,16 @@ export class Analyzer {
788
791
  case 'GuardStatement': return this.visitGuardStatement(node);
789
792
  case 'InterfaceDeclaration': return this.visitInterfaceDeclaration(node);
790
793
  case 'DataBlock': return this.visitDataBlock(node);
794
+ case 'SecurityBlock': return this.visitSecurityBlock(node);
795
+ case 'SecurityAuthDeclaration': return;
796
+ case 'SecurityRoleDeclaration': return;
797
+ case 'SecurityProtectDeclaration': return;
798
+ case 'SecuritySensitiveDeclaration': return;
799
+ case 'SecurityCorsDeclaration': return;
800
+ case 'SecurityCspDeclaration': return;
801
+ case 'SecurityRateLimitDeclaration': return;
802
+ case 'SecurityCsrfDeclaration': return;
803
+ case 'SecurityAuditDeclaration': return;
791
804
  case 'SourceDeclaration': return;
792
805
  case 'PipelineDeclaration': return;
793
806
  case 'ValidateBlock': return;
@@ -991,6 +1004,261 @@ export class Analyzer {
991
1004
  }
992
1005
  }
993
1006
 
1007
+ visitSecurityBlock(node) {
1008
+ // Per-block: only check for duplicate role names within this block
1009
+ const localRoles = new Set();
1010
+ for (const stmt of node.body) {
1011
+ if (stmt.type === 'SecurityRoleDeclaration') {
1012
+ if (localRoles.has(stmt.name)) {
1013
+ this.warnings.push({
1014
+ message: `Duplicate role definition: '${stmt.name}'`,
1015
+ loc: stmt.loc,
1016
+ code: 'W_DUPLICATE_ROLE',
1017
+ });
1018
+ }
1019
+ localRoles.add(stmt.name);
1020
+ }
1021
+ }
1022
+ }
1023
+
1024
+ _validateSecurityCrossBlock() {
1025
+ // Collect ALL security declarations across ALL security blocks in the AST
1026
+ const allRoles = new Set();
1027
+ const allProtects = [];
1028
+ const allSensitives = [];
1029
+ let hasAuth = false;
1030
+ let hasProtect = false;
1031
+ let authDecl = null;
1032
+ let corsDecl = null;
1033
+ let rateLimitDecl = null;
1034
+ let csrfDecl = null;
1035
+
1036
+ const roleDecls = []; // track all role declarations for cross-block duplicate detection
1037
+ for (const node of this.ast.body) {
1038
+ if (node.type !== 'SecurityBlock') continue;
1039
+ for (const stmt of node.body) {
1040
+ if (stmt.type === 'SecurityRoleDeclaration') {
1041
+ roleDecls.push(stmt);
1042
+ allRoles.add(stmt.name);
1043
+ } else if (stmt.type === 'SecurityProtectDeclaration') {
1044
+ allProtects.push(stmt);
1045
+ hasProtect = true;
1046
+ } else if (stmt.type === 'SecuritySensitiveDeclaration') {
1047
+ allSensitives.push(stmt);
1048
+ } else if (stmt.type === 'SecurityAuthDeclaration') {
1049
+ hasAuth = true;
1050
+ authDecl = stmt;
1051
+ } else if (stmt.type === 'SecurityCorsDeclaration') {
1052
+ corsDecl = stmt;
1053
+ } else if (stmt.type === 'SecurityRateLimitDeclaration') {
1054
+ rateLimitDecl = stmt;
1055
+ } else if (stmt.type === 'SecurityCsrfDeclaration') {
1056
+ csrfDecl = stmt;
1057
+ }
1058
+ }
1059
+ }
1060
+
1061
+ // W_DUPLICATE_ROLE across blocks: detect roles with same name in different security blocks
1062
+ const seenRoleNames = new Map(); // name -> first declaration
1063
+ for (const decl of roleDecls) {
1064
+ const prev = seenRoleNames.get(decl.name);
1065
+ if (prev && prev.loc !== decl.loc) {
1066
+ // Only warn if this is from a different block (same-block dupes handled by visitSecurityBlock)
1067
+ const prevInSameBlock = this.ast.body.some(b =>
1068
+ b.type === 'SecurityBlock' && b.body.includes(prev) && b.body.includes(decl)
1069
+ );
1070
+ if (!prevInSameBlock) {
1071
+ this.warnings.push({
1072
+ message: `Role '${decl.name}' is defined in multiple security blocks — later definition overwrites earlier one`,
1073
+ loc: decl.loc,
1074
+ code: 'W_DUPLICATE_ROLE',
1075
+ });
1076
+ }
1077
+ }
1078
+ seenRoleNames.set(decl.name, decl);
1079
+ }
1080
+
1081
+ // W_UNKNOWN_AUTH_TYPE — validate auth type is a known value
1082
+ if (authDecl && authDecl.authType) {
1083
+ const validAuthTypes = ['jwt', 'api_key'];
1084
+ if (!validAuthTypes.includes(authDecl.authType)) {
1085
+ this.warnings.push({
1086
+ message: `Unknown auth type '${authDecl.authType}' — supported types are: ${validAuthTypes.join(', ')}`,
1087
+ loc: authDecl.loc,
1088
+ code: 'W_UNKNOWN_AUTH_TYPE',
1089
+ });
1090
+ }
1091
+ }
1092
+
1093
+ // Fix 2: W_HARDCODED_SECRET — warn if auth secret is a string literal
1094
+ if (authDecl && authDecl.config.secret) {
1095
+ const secretNode = authDecl.config.secret;
1096
+ if (secretNode.type === 'StringLiteral') {
1097
+ this.warnings.push({
1098
+ message: 'Auth secret is hardcoded as a string literal — use env("SECRET_NAME") instead',
1099
+ loc: authDecl.loc,
1100
+ code: 'W_HARDCODED_SECRET',
1101
+ });
1102
+ }
1103
+ }
1104
+
1105
+ // Fix 7: W_CORS_WILDCARD — warn if cors origins contains "*"
1106
+ if (corsDecl && corsDecl.config.origins) {
1107
+ const originsNode = corsDecl.config.origins;
1108
+ if (originsNode.elements) {
1109
+ for (const elem of originsNode.elements) {
1110
+ if (elem.type === 'StringLiteral' && elem.value === '*') {
1111
+ this.warnings.push({
1112
+ message: 'CORS origins contains wildcard "*" — consider restricting to specific origins',
1113
+ loc: corsDecl.loc,
1114
+ code: 'W_CORS_WILDCARD',
1115
+ });
1116
+ break;
1117
+ }
1118
+ }
1119
+ }
1120
+ }
1121
+
1122
+ // W_INVALID_RATE_LIMIT — validate rate limit max/window are positive numbers
1123
+ const _rlNumericValue = (node) => {
1124
+ if (!node) return null;
1125
+ if (node.type === 'NumberLiteral') return node.value;
1126
+ if (node.type === 'UnaryExpression' && node.operator === '-' && node.operand && node.operand.type === 'NumberLiteral') return -node.operand.value;
1127
+ return null;
1128
+ };
1129
+ if (rateLimitDecl && rateLimitDecl.config) {
1130
+ const rlMaxVal = _rlNumericValue(rateLimitDecl.config.max);
1131
+ const rlWindowVal = _rlNumericValue(rateLimitDecl.config.window);
1132
+ if (rlMaxVal !== null && rlMaxVal <= 0) {
1133
+ this.warnings.push({
1134
+ message: `Rate limit max must be a positive number, got ${rlMaxVal}`,
1135
+ loc: rateLimitDecl.loc,
1136
+ code: 'W_INVALID_RATE_LIMIT',
1137
+ });
1138
+ }
1139
+ if (rlWindowVal !== null && rlWindowVal <= 0) {
1140
+ this.warnings.push({
1141
+ message: `Rate limit window must be a positive number, got ${rlWindowVal}`,
1142
+ loc: rateLimitDecl.loc,
1143
+ code: 'W_INVALID_RATE_LIMIT',
1144
+ });
1145
+ }
1146
+ }
1147
+
1148
+ // W_CSRF_DISABLED — warn when CSRF is explicitly disabled
1149
+ if (csrfDecl && csrfDecl.config && csrfDecl.config.enabled) {
1150
+ const enabledNode = csrfDecl.config.enabled;
1151
+ if ((enabledNode.type === 'BooleanLiteral' && enabledNode.value === false) ||
1152
+ (enabledNode.type === 'Identifier' && enabledNode.name === 'false')) {
1153
+ this.warnings.push({
1154
+ message: 'CSRF protection is explicitly disabled — this increases vulnerability to cross-site request forgery attacks',
1155
+ loc: csrfDecl.loc,
1156
+ code: 'W_CSRF_DISABLED',
1157
+ });
1158
+ }
1159
+ }
1160
+
1161
+ // W_LOCALSTORAGE_TOKEN — warn when auth uses default localStorage storage (XSS-vulnerable)
1162
+ if (authDecl && authDecl.authType === 'jwt') {
1163
+ const storageNode = authDecl.config.storage;
1164
+ const isCookieAuth = storageNode && storageNode.type === 'StringLiteral' && storageNode.value === 'cookie';
1165
+ if (!isCookieAuth) {
1166
+ this.warnings.push({
1167
+ message: 'Auth tokens stored in localStorage are vulnerable to XSS attacks — consider using storage: "cookie" for HttpOnly cookie storage',
1168
+ loc: authDecl.loc,
1169
+ code: 'W_LOCALSTORAGE_TOKEN',
1170
+ });
1171
+ }
1172
+ }
1173
+
1174
+ // Fix 5: W_INMEMORY_RATELIMIT — warn that rate limiting is in-memory only
1175
+ if (rateLimitDecl) {
1176
+ this.warnings.push({
1177
+ message: 'Rate limiting uses in-memory storage — not shared across server instances. Consider an external store for production multi-instance deployments',
1178
+ loc: rateLimitDecl.loc,
1179
+ code: 'W_INMEMORY_RATELIMIT',
1180
+ });
1181
+ }
1182
+
1183
+ // Fix 6: W_NO_AUTH_RATELIMIT — warn when auth exists but no rate limiting protects against brute-force
1184
+ if (hasAuth && !rateLimitDecl) {
1185
+ const hasAuthRateLimit = allProtects.some(p => p.config && p.config.rate_limit);
1186
+ if (!hasAuthRateLimit) {
1187
+ this.warnings.push({
1188
+ message: 'Auth is configured without rate limiting — consider adding rate_limit to protect against brute-force attacks',
1189
+ loc: authDecl.loc,
1190
+ code: 'W_NO_AUTH_RATELIMIT',
1191
+ });
1192
+ }
1193
+ }
1194
+
1195
+ // Fix 7: W_HASH_NOT_ENFORCED — warn when sensitive declares hash but it's not auto-enforced
1196
+ for (const s of allSensitives) {
1197
+ if (s.config && s.config.hash) {
1198
+ const hashVal = s.config.hash.value || s.config.hash;
1199
+ this.warnings.push({
1200
+ message: `sensitive ${s.typeName}.${s.fieldName} declares hash: "${hashVal}" but hashing is not automatically enforced — use hash_password() in your write handlers`,
1201
+ loc: s.loc,
1202
+ code: 'W_HASH_NOT_ENFORCED',
1203
+ });
1204
+ }
1205
+ }
1206
+
1207
+ // No security blocks → nothing to validate (but allow auth/cors checks above)
1208
+ if (!hasProtect && allSensitives.length === 0) return;
1209
+
1210
+ // W_PROTECT_WITHOUT_AUTH: protect rules exist but no auth configured
1211
+ if (hasProtect && !hasAuth) {
1212
+ // Find first protect for location
1213
+ this.warnings.push({
1214
+ message: 'Route protection rules exist but no auth is configured — all protected routes will be inaccessible',
1215
+ loc: allProtects[0].loc,
1216
+ code: 'W_PROTECT_WITHOUT_AUTH',
1217
+ });
1218
+ }
1219
+
1220
+ // W_UNDEFINED_ROLE: protect rules reference roles not defined anywhere
1221
+ for (const protect of allProtects) {
1222
+ const requireExpr = protect.config.require;
1223
+ if (!requireExpr) {
1224
+ // W_PROTECT_NO_REQUIRE: protect rule has no require key
1225
+ this.warnings.push({
1226
+ message: `Protect rule for "${protect.pattern}" has no 'require' — route is unprotected`,
1227
+ loc: protect.loc,
1228
+ code: 'W_PROTECT_NO_REQUIRE',
1229
+ });
1230
+ continue;
1231
+ }
1232
+ if (requireExpr.type === 'Identifier' && requireExpr.name !== 'authenticated') {
1233
+ if (!allRoles.has(requireExpr.name)) {
1234
+ this.warnings.push({
1235
+ message: `Protect rule references undefined role '${requireExpr.name}'`,
1236
+ loc: protect.loc,
1237
+ code: 'W_UNDEFINED_ROLE',
1238
+ });
1239
+ }
1240
+ }
1241
+ }
1242
+
1243
+ // W_UNDEFINED_ROLE: sensitive visible_to references roles not defined anywhere
1244
+ for (const sensitive of allSensitives) {
1245
+ const visibleTo = sensitive.config.visible_to;
1246
+ if (visibleTo && (visibleTo.type === 'ArrayExpression' || visibleTo.type === 'ArrayLiteral')) {
1247
+ for (const elem of visibleTo.elements) {
1248
+ if (elem.type === 'Identifier' && elem.name !== 'self') {
1249
+ if (!allRoles.has(elem.name)) {
1250
+ this.warnings.push({
1251
+ message: `Sensitive field '${sensitive.typeName}.${sensitive.fieldName}' visible_to references undefined role '${elem.name}'`,
1252
+ loc: sensitive.loc,
1253
+ code: 'W_UNDEFINED_ROLE',
1254
+ });
1255
+ }
1256
+ }
1257
+ }
1258
+ }
1259
+ }
1260
+ }
1261
+
994
1262
  // visitClientBlock and other client visitors are in client-analyzer.js (lazy-loaded)
995
1263
 
996
1264
  visitSharedBlock(node) {
@@ -1,5 +1,6 @@
1
1
  import { BaseCodegen } from './base-codegen.js';
2
2
  import { getClientStdlib, buildSelectiveStdlib, RESULT_OPTION, PROPAGATE } from '../stdlib/inline.js';
3
+ import { SecurityCodegen } from './security-codegen.js';
3
4
 
4
5
  export class ClientCodegen extends BaseCodegen {
5
6
  constructor() {
@@ -184,7 +185,7 @@ export class ClientCodegen extends BaseCodegen {
184
185
  return `${asyncPrefix}(${params}) => ${this.genExpression(node.body)}`;
185
186
  }
186
187
 
187
- generate(clientBlocks, sharedCode, sharedBuiltins = null) {
188
+ generate(clientBlocks, sharedCode, sharedBuiltins = null, securityConfig = null) {
188
189
  this._sharedBuiltins = sharedBuiltins || new Set();
189
190
  const lines = [];
190
191
 
@@ -238,6 +239,16 @@ export class ClientCodegen extends BaseCodegen {
238
239
  lines.push('});');
239
240
  lines.push('');
240
241
 
242
+ // Security block: auth token injection and role helpers
243
+ if (securityConfig) {
244
+ const secGen = new SecurityCodegen();
245
+ const clientSecurity = secGen.generateClientSecurity(securityConfig);
246
+ if (clientSecurity.trim()) {
247
+ lines.push(clientSecurity);
248
+ lines.push('');
249
+ }
250
+ }
251
+
241
252
  const states = [];
242
253
  const computeds = [];
243
254
  const effects = [];
@@ -6,6 +6,7 @@ import { SharedCodegen } from './shared-codegen.js';
6
6
  import { BUILTIN_NAMES } from '../stdlib/inline.js';
7
7
  import { ServerCodegen } from './server-codegen.js';
8
8
  import { ClientCodegen } from './client-codegen.js';
9
+ import { SecurityCodegen } from './security-codegen.js';
9
10
 
10
11
  function getServerCodegen() {
11
12
  return ServerCodegen;
@@ -42,6 +43,7 @@ export class CodeGenerator {
42
43
  const testBlocks = [];
43
44
  const benchBlocks = [];
44
45
  const dataBlocks = [];
46
+ const securityBlocks = [];
45
47
 
46
48
  for (const node of this.ast.body) {
47
49
  switch (node.type) {
@@ -51,6 +53,7 @@ export class CodeGenerator {
51
53
  case 'TestBlock': testBlocks.push(node); break;
52
54
  case 'BenchBlock': benchBlocks.push(node); break;
53
55
  case 'DataBlock': dataBlocks.push(node); break;
56
+ case 'SecurityBlock': securityBlocks.push(node); break;
54
57
  default: topLevel.push(node); break;
55
58
  }
56
59
  }
@@ -59,6 +62,7 @@ export class CodeGenerator {
59
62
  const isModule = sharedBlocks.length === 0 && serverBlocks.length === 0
60
63
  && clientBlocks.length === 0 && testBlocks.length === 0
61
64
  && benchBlocks.length === 0 && dataBlocks.length === 0
65
+ && securityBlocks.length === 0
62
66
  && topLevel.length > 0;
63
67
 
64
68
  if (isModule) {
@@ -127,6 +131,11 @@ export class CodeGenerator {
127
131
  }
128
132
  }
129
133
 
134
+ // Merge security blocks into a single config
135
+ const securityConfig = securityBlocks.length > 0
136
+ ? SecurityCodegen.mergeSecurityBlocks(securityBlocks)
137
+ : null;
138
+
130
139
  // Generate server outputs (one per named group)
131
140
  const servers = {};
132
141
  for (const [name, blocks] of serverGroups) {
@@ -143,7 +152,7 @@ export class CodeGenerator {
143
152
  }
144
153
  }
145
154
  }
146
- servers[key] = gen.generate(blocks, combinedShared, name, peerBlocks, sharedBlocks);
155
+ servers[key] = gen.generate(blocks, combinedShared, name, peerBlocks, sharedBlocks, securityConfig);
147
156
  }
148
157
 
149
158
  // Generate client outputs (one per named group)
@@ -152,7 +161,7 @@ export class CodeGenerator {
152
161
  const gen = new (getClientCodegen())();
153
162
  gen._sourceMapsEnabled = this._sourceMaps;
154
163
  const key = name || 'default';
155
- clients[key] = gen.generate(blocks, combinedShared, sharedGen._usedBuiltins);
164
+ clients[key] = gen.generate(blocks, combinedShared, sharedGen._usedBuiltins, securityConfig);
156
165
  }
157
166
 
158
167
  // Generate tests if test blocks exist