tova 0.4.6 → 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 +14 -2
- package/src/analyzer/analyzer.js +278 -5
- package/src/analyzer/type-registry.js +22 -3
- package/src/codegen/base-codegen.js +15 -4
- package/src/codegen/client-codegen.js +21 -7
- package/src/codegen/codegen.js +14 -4
- package/src/codegen/security-codegen.js +408 -0
- package/src/codegen/server-codegen.js +809 -96
- package/src/lsp/server.js +44 -25
- package/src/parser/ast.js +21 -0
- package/src/parser/parser.js +8 -0
- package/src/parser/security-ast.js +95 -0
- package/src/parser/security-parser.js +299 -0
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tova",
|
|
3
|
-
"version": "0.
|
|
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",
|
|
@@ -37,5 +37,17 @@
|
|
|
37
37
|
},
|
|
38
38
|
"author": "Enoch Kujem Abassey",
|
|
39
39
|
"keywords": ["language", "transpiler", "fullstack", "javascript"],
|
|
40
|
-
"license": "MIT"
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@codemirror/autocomplete": "^6.20.0",
|
|
43
|
+
"@codemirror/commands": "^6.10.2",
|
|
44
|
+
"@codemirror/language": "^6.12.1",
|
|
45
|
+
"@codemirror/lint": "^6.9.4",
|
|
46
|
+
"@codemirror/search": "^6.6.0",
|
|
47
|
+
"@codemirror/state": "^6.5.4",
|
|
48
|
+
"@codemirror/theme-one-dark": "^6.1.3",
|
|
49
|
+
"@codemirror/view": "^6.39.15",
|
|
50
|
+
"@lezer/highlight": "^1.2.3",
|
|
51
|
+
"codemirror": "^6.0.2"
|
|
52
|
+
}
|
|
41
53
|
}
|
package/src/analyzer/analyzer.js
CHANGED
|
@@ -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) {
|
|
@@ -2547,11 +2815,13 @@ export class Analyzer {
|
|
|
2547
2815
|
if (typeName) {
|
|
2548
2816
|
const existingImpls = this.typeRegistry.impls.get(typeName) || [];
|
|
2549
2817
|
for (const method of node.methods) {
|
|
2818
|
+
const hasSelf = (method.params || []).some(p => p.name === 'self');
|
|
2550
2819
|
existingImpls.push({
|
|
2551
2820
|
name: method.name,
|
|
2552
2821
|
params: (method.params || []).map(p => p.name),
|
|
2553
2822
|
paramTypes: (method.params || []).map(p => typeAnnotationToType(p.typeAnnotation)),
|
|
2554
2823
|
returnType: typeAnnotationToType(method.returnType),
|
|
2824
|
+
isAssociated: !hasSelf,
|
|
2555
2825
|
});
|
|
2556
2826
|
}
|
|
2557
2827
|
this.typeRegistry.impls.set(typeName, existingImpls);
|
|
@@ -2583,13 +2853,16 @@ export class Analyzer {
|
|
|
2583
2853
|
|
|
2584
2854
|
// Validate that methods reference the type
|
|
2585
2855
|
for (const method of node.methods) {
|
|
2856
|
+
const hasSelf = (method.params || []).some(p => p.name === 'self');
|
|
2586
2857
|
this.pushScope('function');
|
|
2587
2858
|
try {
|
|
2588
|
-
// self is
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2859
|
+
// self is only available for instance methods (not associated functions)
|
|
2860
|
+
if (hasSelf) {
|
|
2861
|
+
try {
|
|
2862
|
+
this.currentScope.define('self',
|
|
2863
|
+
new Symbol('self', 'variable', null, true, method.loc));
|
|
2864
|
+
} catch (e) { /* ignore */ }
|
|
2865
|
+
}
|
|
2593
2866
|
for (const p of method.params) {
|
|
2594
2867
|
if (p.name && p.name !== 'self') {
|
|
2595
2868
|
try {
|
|
@@ -25,7 +25,7 @@ export class TypeRegistry {
|
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* Get all members (fields + impl methods) for a type name.
|
|
28
|
-
* Used for dot-completion.
|
|
28
|
+
* Used for dot-completion on instances.
|
|
29
29
|
*/
|
|
30
30
|
getMembers(typeName) {
|
|
31
31
|
const fields = new Map();
|
|
@@ -48,17 +48,36 @@ export class TypeRegistry {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
// Get
|
|
51
|
+
// Get instance methods (methods with self)
|
|
52
52
|
const implMethods = this.impls.get(typeName);
|
|
53
53
|
if (implMethods) {
|
|
54
54
|
for (const method of implMethods) {
|
|
55
|
-
|
|
55
|
+
if (!method.isAssociated) {
|
|
56
|
+
methods.push(method);
|
|
57
|
+
}
|
|
56
58
|
}
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
return { fields, methods };
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Get associated functions for a type name (functions without self).
|
|
66
|
+
* Used for dot-completion on the type itself (e.g., Point.origin()).
|
|
67
|
+
*/
|
|
68
|
+
getAssociatedFunctions(typeName) {
|
|
69
|
+
const functions = [];
|
|
70
|
+
const implMethods = this.impls.get(typeName);
|
|
71
|
+
if (implMethods) {
|
|
72
|
+
for (const method of implMethods) {
|
|
73
|
+
if (method.isAssociated) {
|
|
74
|
+
functions.push(method);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return functions;
|
|
79
|
+
}
|
|
80
|
+
|
|
62
81
|
/**
|
|
63
82
|
* Get variant names for a type (for match completion).
|
|
64
83
|
*/
|
|
@@ -2898,11 +2898,22 @@ export class BaseCodegen {
|
|
|
2898
2898
|
}
|
|
2899
2899
|
const body = this.genBlockBody(method.body);
|
|
2900
2900
|
this.popScope();
|
|
2901
|
-
|
|
2902
|
-
if (
|
|
2903
|
-
|
|
2901
|
+
|
|
2902
|
+
if (hasSelf) {
|
|
2903
|
+
// Instance method → prototype
|
|
2904
|
+
const selfBinding = `\n${this.i()} const self = this;`;
|
|
2905
|
+
if (hasPropagate) {
|
|
2906
|
+
lines.push(`${this.i()}${node.typeName}.prototype.${method.name} = ${asyncPrefix}function(${paramStr}) {${selfBinding}\n${this.i()} try {\n${body}\n${this.i()} } catch (__e) {\n${this.i()} if (__e && __e.__tova_propagate) return __e.value;\n${this.i()} throw __e;\n${this.i()} }\n${this.i()}};`);
|
|
2907
|
+
} else {
|
|
2908
|
+
lines.push(`${this.i()}${node.typeName}.prototype.${method.name} = ${asyncPrefix}function(${paramStr}) {${selfBinding}\n${body}\n${this.i()}};`);
|
|
2909
|
+
}
|
|
2904
2910
|
} else {
|
|
2905
|
-
|
|
2911
|
+
// Associated function → constructor namespace (callable as Type.method())
|
|
2912
|
+
if (hasPropagate) {
|
|
2913
|
+
lines.push(`${this.i()}${node.typeName}.${method.name} = ${asyncPrefix}function(${paramStr}) {\n${this.i()} try {\n${body}\n${this.i()} } catch (__e) {\n${this.i()} if (__e && __e.__tova_propagate) return __e.value;\n${this.i()} throw __e;\n${this.i()} }\n${this.i()}};`);
|
|
2914
|
+
} else {
|
|
2915
|
+
lines.push(`${this.i()}${node.typeName}.${method.name} = ${asyncPrefix}function(${paramStr}) {\n${body}\n${this.i()}};`);
|
|
2916
|
+
}
|
|
2906
2917
|
}
|
|
2907
2918
|
}
|
|
2908
2919
|
return lines.join('\n');
|
|
@@ -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,13 +185,14 @@ 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
|
|
|
191
192
|
// Runtime imports
|
|
192
|
-
lines.push(`import { createSignal, createEffect, createComputed, mount, hydrate, tova_el, tova_fragment, tova_keyed, tova_transition, tova_inject_css, batch, onMount, onUnmount, onCleanup, onBeforeUpdate, createRef, createContext, provide, inject, createErrorBoundary, ErrorBoundary, ErrorInfo, createRoot, watch, untrack, Dynamic, Portal, lazy, Suspense, __tova_action } from './runtime/reactivity.js';`);
|
|
193
|
-
lines.push(`import { rpc } from './runtime/rpc.js';`);
|
|
193
|
+
lines.push(`import { createSignal, createEffect, createComputed, mount, hydrate, tova_el, tova_fragment, tova_keyed, tova_transition, tova_inject_css, batch, onMount, onUnmount, onCleanup, onBeforeUpdate, createRef, createContext, provide, inject, createErrorBoundary, ErrorBoundary, ErrorInfo, createRoot, watch, untrack, Dynamic, Portal, lazy, Suspense, Head, createResource, __tova_action, TransitionGroup, createForm, configureCSP } from './runtime/reactivity.js';`);
|
|
194
|
+
lines.push(`import { rpc, configureRPC, addRPCInterceptor, setCSRFToken } from './runtime/rpc.js';`);
|
|
195
|
+
lines.push(`import { navigate, getCurrentRoute, getParams, getPath, getQuery, defineRoutes, onRouteChange, beforeNavigate, afterNavigate, Router, Outlet, Link, Redirect } from './runtime/router.js';`);
|
|
194
196
|
|
|
195
197
|
// Hoist import lines from shared code to the top of the module
|
|
196
198
|
let sharedRest = sharedCode;
|
|
@@ -237,6 +239,16 @@ export class ClientCodegen extends BaseCodegen {
|
|
|
237
239
|
lines.push('});');
|
|
238
240
|
lines.push('');
|
|
239
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
|
+
|
|
240
252
|
const states = [];
|
|
241
253
|
const computeds = [];
|
|
242
254
|
const effects = [];
|
|
@@ -394,14 +406,16 @@ export class ClientCodegen extends BaseCodegen {
|
|
|
394
406
|
return p.join('');
|
|
395
407
|
}
|
|
396
408
|
|
|
397
|
-
// Generate a
|
|
409
|
+
// Generate a scope hash from component name + CSS content (for CSS scoping)
|
|
410
|
+
// Uses FNV-1a for better distribution and 8-char output to reduce collision risk.
|
|
398
411
|
_genScopeId(name, css) {
|
|
399
412
|
const str = name + ':' + (css || '');
|
|
400
|
-
let h =
|
|
413
|
+
let h = 0x811c9dc5; // FNV offset basis
|
|
401
414
|
for (let i = 0; i < str.length; i++) {
|
|
402
|
-
h
|
|
415
|
+
h ^= str.charCodeAt(i);
|
|
416
|
+
h = Math.imul(h, 0x01000193); // FNV prime
|
|
403
417
|
}
|
|
404
|
-
return
|
|
418
|
+
return (h >>> 0).toString(36).padStart(8, '0').slice(0, 8);
|
|
405
419
|
}
|
|
406
420
|
|
|
407
421
|
// Scope CSS selectors by appending [data-tova-HASH] to each selector
|
package/src/codegen/codegen.js
CHANGED
|
@@ -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
|
|
@@ -279,12 +288,13 @@ export class CodeGenerator {
|
|
|
279
288
|
break;
|
|
280
289
|
}
|
|
281
290
|
case 'ValidateBlock': {
|
|
282
|
-
// Validate: validator function
|
|
291
|
+
// Validate: validator function — include rule expression in error for debuggability
|
|
283
292
|
const rules = stmt.rules.map(r => gen.genExpression(r));
|
|
284
293
|
lines.push(`function __validate_${stmt.typeName}(it) {`);
|
|
285
294
|
lines.push(` const errors = [];`);
|
|
286
295
|
for (let i = 0; i < rules.length; i++) {
|
|
287
|
-
|
|
296
|
+
const escapedRule = rules[i].replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
297
|
+
lines.push(` if (!(${rules[i]})) errors.push("Validation failed for ${stmt.typeName}: expected \`${escapedRule}\`");`);
|
|
288
298
|
}
|
|
289
299
|
lines.push(` return errors.length === 0 ? { valid: true, errors: [] } : { valid: false, errors };`);
|
|
290
300
|
lines.push(`}`);
|