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.
@@ -0,0 +1,408 @@
1
+ // Security code generator for the Tova language
2
+ // Produces code fragments consumed by server-codegen and client-codegen.
3
+
4
+ import { BaseCodegen } from './base-codegen.js';
5
+
6
+ export class SecurityCodegen extends BaseCodegen {
7
+
8
+ /**
9
+ * Merge all SecurityBlock nodes into a single config object.
10
+ * Multiple security blocks are merged (last wins on conflicts).
11
+ */
12
+ static mergeSecurityBlocks(securityBlocks) {
13
+ const config = {
14
+ auth: null, // SecurityAuthDeclaration
15
+ roles: [], // Array of SecurityRoleDeclaration
16
+ protects: [], // Array of SecurityProtectDeclaration
17
+ sensitives: [], // Array of SecuritySensitiveDeclaration
18
+ cors: null, // SecurityCorsDeclaration
19
+ csp: null, // SecurityCspDeclaration
20
+ rateLimit: null, // SecurityRateLimitDeclaration
21
+ csrf: null, // SecurityCsrfDeclaration
22
+ audit: null, // SecurityAuditDeclaration
23
+ trustProxy: null, // SecurityTrustProxyDeclaration
24
+ hsts: null, // SecurityHstsDeclaration
25
+ };
26
+
27
+ for (const block of securityBlocks) {
28
+ for (const node of block.body) {
29
+ switch (node.type) {
30
+ case 'SecurityAuthDeclaration':
31
+ config.auth = node;
32
+ break;
33
+ case 'SecurityRoleDeclaration':
34
+ config.roles.push(node);
35
+ break;
36
+ case 'SecurityProtectDeclaration':
37
+ config.protects.push(node);
38
+ break;
39
+ case 'SecuritySensitiveDeclaration':
40
+ config.sensitives.push(node);
41
+ break;
42
+ case 'SecurityCorsDeclaration':
43
+ config.cors = node;
44
+ break;
45
+ case 'SecurityCspDeclaration':
46
+ config.csp = node;
47
+ break;
48
+ case 'SecurityRateLimitDeclaration':
49
+ config.rateLimit = node;
50
+ break;
51
+ case 'SecurityCsrfDeclaration':
52
+ config.csrf = node;
53
+ break;
54
+ case 'SecurityAuditDeclaration':
55
+ config.audit = node;
56
+ break;
57
+ case 'SecurityTrustProxyDeclaration':
58
+ config.trustProxy = node;
59
+ break;
60
+ case 'SecurityHstsDeclaration':
61
+ config.hsts = node;
62
+ break;
63
+ }
64
+ }
65
+ }
66
+
67
+ return config;
68
+ }
69
+
70
+ /**
71
+ * Generate server-side security code fragments.
72
+ * Returns an object with code strings for each security feature.
73
+ */
74
+ generateServerSecurity(securityConfig) {
75
+ const result = {
76
+ roleDefinitions: '',
77
+ authCode: '',
78
+ corsConfig: null, // config object for server-codegen to use
79
+ cspCode: '',
80
+ rateLimitConfig: null, // config object
81
+ csrfConfig: null, // config object
82
+ protectCode: '',
83
+ sensitiveCode: '',
84
+ auditCode: '',
85
+ trustProxyConfig: null, // trust_proxy value
86
+ hstsConfig: null, // hsts config object
87
+ hasAutoSanitize: false, // whether __autoSanitize was generated
88
+ };
89
+
90
+ // Role definitions
91
+ if (securityConfig.roles.length > 0) {
92
+ const lines = [];
93
+ lines.push('// ── Security Roles ──');
94
+ lines.push('const __securityRoles = {');
95
+ for (const role of securityConfig.roles) {
96
+ const perms = role.permissions.map(p => JSON.stringify(p)).join(', ');
97
+ lines.push(` ${JSON.stringify(role.name)}: [${perms}],`);
98
+ }
99
+ lines.push('};');
100
+ lines.push('function __getUserRoles(user) {');
101
+ lines.push(' if (!user) return [];');
102
+ lines.push(' if (Array.isArray(user.roles)) return user.roles;');
103
+ lines.push(' if (user.role) return [user.role];');
104
+ lines.push(' return [];');
105
+ lines.push('}');
106
+ lines.push('function __hasRole(user, roleName) {');
107
+ lines.push(' return __getUserRoles(user).includes(roleName);');
108
+ lines.push('}');
109
+ lines.push('function __hasPermission(user, permission) {');
110
+ lines.push(' const userRoles = __getUserRoles(user);');
111
+ lines.push(' for (const r of userRoles) {');
112
+ lines.push(' const perms = __securityRoles[r];');
113
+ lines.push(' if (perms && perms.includes(permission)) return true;');
114
+ lines.push(' }');
115
+ lines.push(' return false;');
116
+ lines.push('}');
117
+ result.roleDefinitions = lines.join('\n');
118
+ }
119
+
120
+ // Auth config — pass through to server codegen
121
+ if (securityConfig.auth) {
122
+ const authNode = securityConfig.auth;
123
+ // Convert security auth config to the format server-codegen expects
124
+ const config = { ...authNode.config };
125
+ // Set the auth type as a value property (server-codegen checks .type.value)
126
+ config.type = { value: authNode.authType, type: 'StringLiteral' };
127
+ result.authConfig = config;
128
+ }
129
+
130
+ // CORS config — pass through to server codegen
131
+ if (securityConfig.cors) {
132
+ result.corsConfig = securityConfig.cors.config;
133
+ }
134
+
135
+ // CSP header generation
136
+ if (securityConfig.csp) {
137
+ const lines = [];
138
+ lines.push('// ── Content Security Policy ──');
139
+ const directives = [];
140
+ for (const [key, valueNode] of Object.entries(securityConfig.csp.config)) {
141
+ const directive = key.replace(/_/g, '-');
142
+ directives.push({ directive, valueNode });
143
+ }
144
+ lines.push('function __getCspHeader() {');
145
+ lines.push(' const parts = [];');
146
+ for (const { directive, valueNode } of directives) {
147
+ lines.push(` parts.push("${directive} " + ${this.genExpression(valueNode)}.map(v => v === "self" ? "'self'" : v === "unsafe-inline" ? "'unsafe-inline'" : v === "unsafe-eval" ? "'unsafe-eval'" : v).join(" "));`);
148
+ }
149
+ lines.push(' return parts.join("; ");');
150
+ lines.push('}');
151
+ result.cspCode = lines.join('\n');
152
+ }
153
+
154
+ // Rate limit config — pass through to server codegen
155
+ if (securityConfig.rateLimit) {
156
+ result.rateLimitConfig = securityConfig.rateLimit.config;
157
+ }
158
+
159
+ // CSRF config
160
+ if (securityConfig.csrf) {
161
+ result.csrfConfig = securityConfig.csrf.config;
162
+ }
163
+
164
+ // Trust proxy config
165
+ if (securityConfig.trustProxy) {
166
+ result.trustProxyConfig = securityConfig.trustProxy.value;
167
+ }
168
+
169
+ // HSTS config
170
+ if (securityConfig.hsts) {
171
+ result.hstsConfig = securityConfig.hsts.config;
172
+ } else if (securityConfig.auth) {
173
+ // Auto-enable HSTS when auth is configured (default policy)
174
+ result.hstsConfig = { __autoEnabled: true };
175
+ }
176
+
177
+ // Route protection middleware
178
+ if (securityConfig.protects.length > 0) {
179
+ const lines = [];
180
+ lines.push('// ── Route Protection ──');
181
+ lines.push('const __protectRules = [');
182
+ for (const protect of securityConfig.protects) {
183
+ const pattern = protect.pattern;
184
+ const requireExpr = protect.config.require;
185
+ let requireStr = '"authenticated"';
186
+ if (requireExpr) {
187
+ if (requireExpr.type === 'Identifier') {
188
+ requireStr = JSON.stringify(requireExpr.name);
189
+ } else {
190
+ requireStr = this.genExpression(requireExpr);
191
+ }
192
+ }
193
+ // Convert glob-style pattern to regex
194
+ // 1. Replace ** with placeholder, 2. Replace * with placeholder
195
+ // 3. Escape all regex-special chars (including /), 4. Restore glob placeholders
196
+ const regexPattern = pattern
197
+ .replace(/\*\*/g, '\x00GLOBSTAR\x00')
198
+ .replace(/\*/g, '\x00STAR\x00')
199
+ .replace(/[.+?^${}()|[\]\\/]/g, '\\$&') // escape all regex specials including /
200
+ .replace(/\x00STAR\x00/g, '[^/]*') // * matches within one path segment
201
+ .replace(/\x00GLOBSTAR\x00/g, '.*'); // ** matches across segments
202
+
203
+ let rlMax = 'null';
204
+ let rlWindow = 'null';
205
+ if (protect.config.rate_limit) {
206
+ if (protect.config.rate_limit.max) {
207
+ rlMax = this.genExpression(protect.config.rate_limit.max);
208
+ }
209
+ if (protect.config.rate_limit.window) {
210
+ rlWindow = this.genExpression(protect.config.rate_limit.window);
211
+ }
212
+ }
213
+
214
+ lines.push(` { pattern: /^${regexPattern}$/, require: ${requireStr}, rateLimit: { max: ${rlMax}, window: ${rlWindow} } },`);
215
+ }
216
+ lines.push('];');
217
+ lines.push('function __checkProtection(path, user) {');
218
+ lines.push(' for (const rule of __protectRules) {');
219
+ lines.push(' if (rule.pattern.test(path)) {');
220
+ lines.push(' if (rule.require === "authenticated") {');
221
+ lines.push(' if (!user) return { allowed: false, reason: "Authentication required" };');
222
+ lines.push(' } else {');
223
+ lines.push(' if (!user) return { allowed: false, reason: "Authentication required" };');
224
+ lines.push(' if (!__hasRole(user, rule.require)) return { allowed: false, reason: "Insufficient permissions" };');
225
+ lines.push(' }');
226
+ lines.push(' return { allowed: true, rateLimit: rule.rateLimit };');
227
+ lines.push(' }');
228
+ lines.push(' }');
229
+ lines.push(' return { allowed: true, rateLimit: null };');
230
+ lines.push('}');
231
+ result.protectCode = lines.join('\n');
232
+ }
233
+
234
+ // Sensitive field sanitization
235
+ if (securityConfig.sensitives.length > 0) {
236
+ const lines = [];
237
+ lines.push('// ── Sensitive Field Sanitization ──');
238
+
239
+ // Identity comparison helper for visible_to: ["self"]
240
+ // Checks multiple common identity fields instead of hardcoded user.id === obj.id
241
+ const hasVisibleTo = securityConfig.sensitives.some(s => s.config.visible_to);
242
+ if (hasVisibleTo) {
243
+ lines.push('function __isSameIdentity(user, obj) {');
244
+ lines.push(' const __idFields = ["id", "_id", "userId", "user_id", "uuid"];');
245
+ lines.push(' for (const f of __idFields) {');
246
+ lines.push(' if (user[f] != null && obj[f] != null && user[f] === obj[f]) return true;');
247
+ lines.push(' }');
248
+ lines.push(' return false;');
249
+ lines.push('}');
250
+ }
251
+
252
+ // Group by type
253
+ const byType = {};
254
+ for (const s of securityConfig.sensitives) {
255
+ if (!byType[s.typeName]) byType[s.typeName] = [];
256
+ byType[s.typeName].push(s);
257
+ }
258
+
259
+ for (const [typeName, fields] of Object.entries(byType)) {
260
+ const fnName = `__sanitize${typeName}`;
261
+ lines.push(`function ${fnName}(obj, user) {`);
262
+ lines.push(' if (!obj) return obj;');
263
+ lines.push(' const result = { ...obj };');
264
+ for (const field of fields) {
265
+ if (field.config.never_expose) {
266
+ lines.push(` delete result.${field.fieldName};`);
267
+ } else if (field.config.visible_to) {
268
+ const visibleExpr = this.genExpression(field.config.visible_to);
269
+ lines.push(` const __visibleTo = ${visibleExpr};`);
270
+ lines.push(` const __canSee = __visibleTo.some(v => v === "self" ? (user && __isSameIdentity(user, obj)) : __hasRole(user, v));`);
271
+ lines.push(` if (!__canSee) delete result.${field.fieldName};`);
272
+ }
273
+ }
274
+ lines.push(' return result;');
275
+ lines.push('}');
276
+ }
277
+
278
+ // Fix 6a: Auto-sanitize dispatcher
279
+ const typeNames = Object.keys(byType);
280
+ lines.push('function __autoSanitize(data, user) {');
281
+ lines.push(' if (data == null) return data;');
282
+ lines.push(' if (Array.isArray(data)) return data.map(item => __autoSanitize(item, user));');
283
+ lines.push(' if (typeof data !== "object") return data;');
284
+ lines.push(' const __typeName = data.__type || data.__tag || (data.constructor && data.constructor.name !== "Object" ? data.constructor.name : null);');
285
+ for (const typeName of typeNames) {
286
+ lines.push(` if (__typeName === ${JSON.stringify(typeName)}) return __sanitize${typeName}(data, user);`);
287
+ }
288
+ // Recurse into nested objects
289
+ lines.push(' const __out = {};');
290
+ lines.push(' for (const [k, v] of Object.entries(data)) {');
291
+ lines.push(' __out[k] = __autoSanitize(v, user);');
292
+ lines.push(' }');
293
+ lines.push(' return __out;');
294
+ lines.push('}');
295
+
296
+ result.sensitiveCode = lines.join('\n');
297
+ result.hasAutoSanitize = true;
298
+ }
299
+
300
+ // Audit logging
301
+ if (securityConfig.audit) {
302
+ const lines = [];
303
+ lines.push('// ── Audit Logging ──');
304
+ const storeExpr = securityConfig.audit.config.store
305
+ ? this.genExpression(securityConfig.audit.config.store)
306
+ : '"audit_log"';
307
+ const retainExpr = securityConfig.audit.config.retain
308
+ ? this.genExpression(securityConfig.audit.config.retain)
309
+ : '90';
310
+ lines.push(`const __auditStore = ${storeExpr};`);
311
+ lines.push('if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(__auditStore)) throw new Error("Invalid audit store table name: " + __auditStore);');
312
+ lines.push(`const __auditRetainDays = ${retainExpr};`);
313
+
314
+ if (securityConfig.audit.config.events) {
315
+ lines.push(`const __auditEvents = ${this.genExpression(securityConfig.audit.config.events)};`);
316
+ } else {
317
+ lines.push('const __auditEvents = [];');
318
+ }
319
+
320
+ lines.push('async function __auditLog(event, details, user) {');
321
+ lines.push(' const entry = {');
322
+ lines.push(' event,');
323
+ lines.push(' timestamp: new Date().toISOString(),');
324
+ lines.push(' user: user ? { id: user.id, roles: __getUserRoles ? __getUserRoles(user) : (user.roles || [user.role].filter(Boolean)) } : null,');
325
+ lines.push(' details,');
326
+ lines.push(' };');
327
+ lines.push(' if (typeof db !== "undefined" && db.run) {');
328
+ lines.push(` try { await db.run("INSERT INTO " + __auditStore + " (event, timestamp, user_id, details) VALUES (?, ?, ?, ?)", entry.event, entry.timestamp, entry.user ? entry.user.id : null, JSON.stringify(entry.details)); } catch (__auditErr) { console.error("[tova:audit] Failed to write audit log:", __auditErr.message || __auditErr); }`);
329
+ lines.push(' }');
330
+ lines.push('}');
331
+ result.auditCode = lines.join('\n');
332
+ }
333
+
334
+ return result;
335
+ }
336
+
337
+ /**
338
+ * Generate client-side security code fragments.
339
+ */
340
+ generateClientSecurity(securityConfig) {
341
+ const lines = [];
342
+
343
+ // Auth token injection for RPC proxy
344
+ if (securityConfig.auth) {
345
+ // Check if auth storage is "cookie" (HttpOnly cookie mode)
346
+ const storageNode = securityConfig.auth.config.storage;
347
+ const isCookieAuth = storageNode && storageNode.type === 'StringLiteral' && storageNode.value === 'cookie';
348
+
349
+ if (isCookieAuth) {
350
+ // HttpOnly cookie mode: server manages tokens via Set-Cookie
351
+ // Client just ensures credentials are included in fetch
352
+ lines.push('// ── Security: Auth Token (HttpOnly Cookie) ──');
353
+ lines.push('function getAuthToken() { return null; /* managed by HttpOnly cookie */ }');
354
+ lines.push('function setAuthToken(_token) { /* no-op: server sets HttpOnly cookie */ }');
355
+ lines.push('function clearAuthToken() {');
356
+ lines.push(' fetch("/rpc/__logout", { method: "POST", credentials: "include" }).catch(() => {});');
357
+ lines.push('}');
358
+ lines.push('configureRPC({ credentials: "include" });');
359
+ lines.push('');
360
+ } else {
361
+ // localStorage mode (default)
362
+ lines.push('// ── Security: Auth Token ──');
363
+ lines.push('function getAuthToken() {');
364
+ lines.push(' return localStorage.getItem("__tova_auth_token");');
365
+ lines.push('}');
366
+ lines.push('function setAuthToken(token) {');
367
+ lines.push(' localStorage.setItem("__tova_auth_token", token);');
368
+ lines.push('}');
369
+ lines.push('function clearAuthToken() {');
370
+ lines.push(' localStorage.removeItem("__tova_auth_token");');
371
+ lines.push('}');
372
+ lines.push('addRPCInterceptor({');
373
+ lines.push(' request({ options }) {');
374
+ lines.push(' const token = getAuthToken();');
375
+ lines.push(' if (token) options.headers["Authorization"] = "Bearer " + token;');
376
+ lines.push(' return options;');
377
+ lines.push(' }');
378
+ lines.push('});');
379
+ lines.push('');
380
+ }
381
+ }
382
+
383
+ // Role definitions and can() helper (Fix 8: advisory comment)
384
+ if (securityConfig.roles.length > 0) {
385
+ lines.push('// ── Security: Roles ──');
386
+ lines.push('// NOTE: Client-side role checking is for UI purposes only. All authorization is enforced server-side.');
387
+ lines.push('const __clientRoles = {');
388
+ for (const role of securityConfig.roles) {
389
+ const perms = role.permissions.map(p => JSON.stringify(p)).join(', ');
390
+ lines.push(` ${JSON.stringify(role.name)}: [${perms}],`);
391
+ }
392
+ lines.push('};');
393
+ lines.push('let __currentUserRoles = [];');
394
+ lines.push('function setUserRole(role) { __currentUserRoles = Array.isArray(role) ? role : [role]; }');
395
+ lines.push('function getUserRole() { return __currentUserRoles; }');
396
+ lines.push('function can(permission) {');
397
+ lines.push(' for (const r of __currentUserRoles) {');
398
+ lines.push(' const perms = __clientRoles[r];');
399
+ lines.push(' if (perms && perms.includes(permission)) return true;');
400
+ lines.push(' }');
401
+ lines.push(' return false;');
402
+ lines.push('}');
403
+ lines.push('');
404
+ }
405
+
406
+ return lines.join('\n');
407
+ }
408
+ }