@kuratchi/js 0.0.15 → 0.0.17

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.
Files changed (70) hide show
  1. package/README.md +160 -1
  2. package/dist/cli.js +78 -45
  3. package/dist/compiler/api-route-pipeline.d.ts +8 -0
  4. package/dist/compiler/api-route-pipeline.js +23 -0
  5. package/dist/compiler/asset-pipeline.d.ts +7 -0
  6. package/dist/compiler/asset-pipeline.js +33 -0
  7. package/dist/compiler/client-module-pipeline.d.ts +25 -0
  8. package/dist/compiler/client-module-pipeline.js +257 -0
  9. package/dist/compiler/compiler-shared.d.ts +73 -0
  10. package/dist/compiler/compiler-shared.js +4 -0
  11. package/dist/compiler/component-pipeline.d.ts +15 -0
  12. package/dist/compiler/component-pipeline.js +158 -0
  13. package/dist/compiler/config-reading.d.ts +12 -0
  14. package/dist/compiler/config-reading.js +380 -0
  15. package/dist/compiler/convention-discovery.d.ts +9 -0
  16. package/dist/compiler/convention-discovery.js +83 -0
  17. package/dist/compiler/durable-object-pipeline.d.ts +9 -0
  18. package/dist/compiler/durable-object-pipeline.js +255 -0
  19. package/dist/compiler/error-page-pipeline.d.ts +1 -0
  20. package/dist/compiler/error-page-pipeline.js +16 -0
  21. package/dist/compiler/import-linking.d.ts +36 -0
  22. package/dist/compiler/import-linking.js +140 -0
  23. package/dist/compiler/index.d.ts +7 -7
  24. package/dist/compiler/index.js +181 -3321
  25. package/dist/compiler/layout-pipeline.d.ts +31 -0
  26. package/dist/compiler/layout-pipeline.js +155 -0
  27. package/dist/compiler/page-route-pipeline.d.ts +16 -0
  28. package/dist/compiler/page-route-pipeline.js +62 -0
  29. package/dist/compiler/parser.d.ts +4 -0
  30. package/dist/compiler/parser.js +436 -55
  31. package/dist/compiler/root-layout-pipeline.d.ts +10 -0
  32. package/dist/compiler/root-layout-pipeline.js +532 -0
  33. package/dist/compiler/route-discovery.d.ts +7 -0
  34. package/dist/compiler/route-discovery.js +87 -0
  35. package/dist/compiler/route-pipeline.d.ts +57 -0
  36. package/dist/compiler/route-pipeline.js +291 -0
  37. package/dist/compiler/route-state-pipeline.d.ts +26 -0
  38. package/dist/compiler/route-state-pipeline.js +139 -0
  39. package/dist/compiler/routes-module-feature-blocks.d.ts +2 -0
  40. package/dist/compiler/routes-module-feature-blocks.js +330 -0
  41. package/dist/compiler/routes-module-pipeline.d.ts +2 -0
  42. package/dist/compiler/routes-module-pipeline.js +6 -0
  43. package/dist/compiler/routes-module-runtime-shell.d.ts +2 -0
  44. package/dist/compiler/routes-module-runtime-shell.js +91 -0
  45. package/dist/compiler/routes-module-types.d.ts +45 -0
  46. package/dist/compiler/routes-module-types.js +1 -0
  47. package/dist/compiler/script-transform.d.ts +16 -0
  48. package/dist/compiler/script-transform.js +218 -0
  49. package/dist/compiler/server-module-pipeline.d.ts +13 -0
  50. package/dist/compiler/server-module-pipeline.js +124 -0
  51. package/dist/compiler/template.d.ts +13 -1
  52. package/dist/compiler/template.js +337 -71
  53. package/dist/compiler/worker-output-pipeline.d.ts +13 -0
  54. package/dist/compiler/worker-output-pipeline.js +37 -0
  55. package/dist/compiler/wrangler-sync.d.ts +14 -0
  56. package/dist/compiler/wrangler-sync.js +185 -0
  57. package/dist/runtime/app.js +15 -3
  58. package/dist/runtime/context.d.ts +4 -0
  59. package/dist/runtime/context.js +40 -2
  60. package/dist/runtime/do.js +21 -6
  61. package/dist/runtime/generated-worker.d.ts +55 -0
  62. package/dist/runtime/generated-worker.js +543 -0
  63. package/dist/runtime/index.d.ts +4 -1
  64. package/dist/runtime/index.js +2 -0
  65. package/dist/runtime/router.d.ts +6 -1
  66. package/dist/runtime/router.js +125 -31
  67. package/dist/runtime/security.d.ts +101 -0
  68. package/dist/runtime/security.js +298 -0
  69. package/dist/runtime/types.d.ts +29 -2
  70. package/package.json +5 -1
@@ -5,47 +5,141 @@
5
5
  * /todos → static
6
6
  * /blog/:slug → named param
7
7
  * /files/*rest → catch-all
8
+ *
9
+ * Uses a radix tree for O(log n) dynamic route matching instead of O(n) linear scan.
8
10
  */
11
+ function createNode(segment, type, paramName) {
12
+ return {
13
+ segment,
14
+ type,
15
+ paramName,
16
+ children: new Map(),
17
+ };
18
+ }
9
19
  export class Router {
10
- routes = [];
20
+ staticRoutes = new Map();
21
+ root = createNode('', 0 /* NodeType.Static */);
11
22
  /** Register a pattern (e.g. '/blog/:slug') and associate it with an index. */
12
23
  add(pattern, index) {
13
- const paramNames = [];
14
- // Convert pattern to regex
15
- // :param → named capture group
16
- // *param → catch-all capture group
17
- let regexStr = pattern
18
- // Catch-all: /files/*rest /files/(?<rest>.+)
19
- .replace(/\*(\w+)/g, (_match, name) => {
20
- paramNames.push(name);
21
- return `(?<${name}>.+)`;
22
- })
23
- // Named params: /blog/:slug → /blog/(?<slug>[^/]+)
24
- .replace(/:(\w+)/g, (_match, name) => {
25
- paramNames.push(name);
26
- return `(?<${name}>[^/]+)`;
27
- });
28
- // Anchor
29
- regexStr = `^${regexStr}$`;
30
- this.routes.push({
31
- regex: new RegExp(regexStr),
32
- paramNames,
33
- index,
34
- });
24
+ if (!pattern.includes(':') && !pattern.includes('*')) {
25
+ this.staticRoutes.set(pattern, index);
26
+ return;
27
+ }
28
+ // Parse pattern into segments
29
+ const segments = pattern.split('/').filter(Boolean);
30
+ let node = this.root;
31
+ for (let i = 0; i < segments.length; i++) {
32
+ const seg = segments[i];
33
+ if (seg.startsWith('*')) {
34
+ // Catch-all: *param
35
+ const paramName = seg.slice(1);
36
+ if (!node.catchAllChild) {
37
+ node.catchAllChild = createNode('', 2 /* NodeType.CatchAll */, paramName);
38
+ }
39
+ node = node.catchAllChild;
40
+ // Catch-all must be last segment
41
+ break;
42
+ }
43
+ else if (seg.startsWith(':')) {
44
+ // Param: :param
45
+ const paramName = seg.slice(1);
46
+ if (!node.paramChild) {
47
+ node.paramChild = createNode('', 1 /* NodeType.Param */, paramName);
48
+ }
49
+ node = node.paramChild;
50
+ }
51
+ else {
52
+ // Static segment
53
+ const key = seg[0] || '';
54
+ let child = node.children.get(key);
55
+ if (!child) {
56
+ child = createNode(seg, 0 /* NodeType.Static */);
57
+ node.children.set(key, child);
58
+ }
59
+ else if (child.segment !== seg) {
60
+ // Handle prefix splitting for true radix tree (simplified: exact match only)
61
+ // For simplicity, we use segment-level matching which is sufficient for route patterns
62
+ let found = false;
63
+ for (const [, c] of node.children) {
64
+ if (c.segment === seg) {
65
+ child = c;
66
+ found = true;
67
+ break;
68
+ }
69
+ }
70
+ if (!found) {
71
+ child = createNode(seg, 0 /* NodeType.Static */);
72
+ // Use full segment as key for collision handling
73
+ node.children.set(seg, child);
74
+ }
75
+ }
76
+ node = child;
77
+ }
78
+ }
79
+ node.routeIndex = index;
35
80
  }
36
81
  /** Match a pathname against registered routes. Returns null if no match. */
37
82
  match(pathname) {
38
83
  // Normalize: strip trailing slash (except root)
39
84
  const normalized = pathname === '/' ? '/' : pathname.replace(/\/$/, '');
40
- for (const route of this.routes) {
41
- const m = normalized.match(route.regex);
42
- if (m) {
43
- const params = {};
44
- for (const name of route.paramNames) {
45
- params[name] = m.groups?.[name] ?? '';
46
- }
47
- return { params, index: route.index };
85
+ // Fast path: static routes (O(1))
86
+ const staticIdx = this.staticRoutes.get(normalized);
87
+ if (staticIdx !== undefined) {
88
+ return { params: {}, index: staticIdx };
89
+ }
90
+ // Radix tree traversal for dynamic routes
91
+ const segments = normalized.split('/').filter(Boolean);
92
+ const params = {};
93
+ const result = this.matchNode(this.root, segments, 0, params);
94
+ if (result !== null) {
95
+ return { params, index: result };
96
+ }
97
+ return null;
98
+ }
99
+ /** Recursive radix tree matching with backtracking */
100
+ matchNode(node, segments, segIdx, params) {
101
+ // Base case: consumed all segments
102
+ if (segIdx >= segments.length) {
103
+ return node.routeIndex ?? null;
104
+ }
105
+ const seg = segments[segIdx];
106
+ // 1. Try static children first (highest priority)
107
+ // Check by first char, then by full segment
108
+ const staticChild = node.children.get(seg[0]) ?? node.children.get(seg);
109
+ if (staticChild && staticChild.segment === seg) {
110
+ const result = this.matchNode(staticChild, segments, segIdx + 1, params);
111
+ if (result !== null)
112
+ return result;
113
+ }
114
+ // Also check full segment key for collision handling
115
+ for (const [key, child] of node.children) {
116
+ if (key !== seg[0] && child.segment === seg) {
117
+ const result = this.matchNode(child, segments, segIdx + 1, params);
118
+ if (result !== null)
119
+ return result;
120
+ }
121
+ }
122
+ // 2. Try param child (second priority)
123
+ if (node.paramChild) {
124
+ const paramName = node.paramChild.paramName;
125
+ const oldValue = params[paramName];
126
+ params[paramName] = seg;
127
+ const result = this.matchNode(node.paramChild, segments, segIdx + 1, params);
128
+ if (result !== null)
129
+ return result;
130
+ // Backtrack
131
+ if (oldValue !== undefined) {
132
+ params[paramName] = oldValue;
48
133
  }
134
+ else {
135
+ delete params[paramName];
136
+ }
137
+ }
138
+ // 3. Try catch-all child (lowest priority, consumes rest)
139
+ if (node.catchAllChild) {
140
+ const paramName = node.catchAllChild.paramName;
141
+ params[paramName] = segments.slice(segIdx).join('/');
142
+ return node.catchAllChild.routeIndex ?? null;
49
143
  }
50
144
  return null;
51
145
  }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * KuratchiJS Security Module
3
+ *
4
+ * Provides CSRF protection, request signing, and security utilities
5
+ * for the framework runtime.
6
+ */
7
+ /**
8
+ * Initialize CSRF protection for the current request.
9
+ * Generates a new token if one doesn't exist in cookies.
10
+ */
11
+ export declare function initCsrf(request: Request, cookieName?: string): string;
12
+ /**
13
+ * Get the current CSRF token for the request
14
+ */
15
+ export declare function getCsrfToken(): string;
16
+ /**
17
+ * Validate CSRF token from request against stored token.
18
+ * Checks both header and form field.
19
+ */
20
+ export declare function validateCsrf(request: Request, formData?: FormData, headerName?: string, formField?: string): {
21
+ valid: boolean;
22
+ reason?: string;
23
+ };
24
+ /**
25
+ * Get the Set-Cookie header for CSRF token if needed
26
+ */
27
+ export declare function getCsrfCookieHeader(): string | null;
28
+ export interface RpcSecurityConfig {
29
+ requireAuth: boolean;
30
+ validateCsrf: boolean;
31
+ allowedMethods: ('GET' | 'POST')[];
32
+ }
33
+ /**
34
+ * Generate a per-request RPC nonce for additional request signing
35
+ */
36
+ export declare function generateRpcNonce(): string;
37
+ /**
38
+ * Validate an RPC request meets security requirements
39
+ */
40
+ export declare function validateRpcRequest(request: Request, config: RpcSecurityConfig): {
41
+ valid: boolean;
42
+ status: number;
43
+ reason?: string;
44
+ };
45
+ export interface ActionSecurityConfig {
46
+ validateCsrf: boolean;
47
+ requireSameOrigin: boolean;
48
+ }
49
+ /**
50
+ * Validate an action request meets security requirements
51
+ */
52
+ export declare function validateActionRequest(request: Request, url: URL, formData: FormData, config: ActionSecurityConfig): {
53
+ valid: boolean;
54
+ status: number;
55
+ reason?: string;
56
+ };
57
+ export interface SecurityHeadersConfig {
58
+ contentSecurityPolicy?: string | null;
59
+ strictTransportSecurity?: string | null;
60
+ permissionsPolicy?: string | null;
61
+ }
62
+ /**
63
+ * Apply security headers to a response
64
+ */
65
+ export declare function applySecurityHeaders(response: Response, config?: SecurityHeadersConfig): Response;
66
+ /**
67
+ * Sign a fragment ID with the route path and CSRF token to prevent tampering.
68
+ * Format: fragmentId:signature
69
+ */
70
+ export declare function signFragmentId(fragmentId: string, routePath: string): string;
71
+ /**
72
+ * Validate a signed fragment ID against the current route and session.
73
+ * If no CSRF token is present (e.g., in tests), allows unsigned fragments.
74
+ */
75
+ export declare function validateSignedFragment(signedFragment: string, routePath: string): {
76
+ valid: boolean;
77
+ fragmentId: string | null;
78
+ reason?: string;
79
+ };
80
+ /**
81
+ * Validate that a query function is allowed for the current route.
82
+ * The allowedQueries set should contain the function names registered for this route.
83
+ */
84
+ export declare function validateQueryOverride(queryFn: string, allowedQueries: Set<string> | string[]): {
85
+ valid: boolean;
86
+ reason?: string;
87
+ };
88
+ /**
89
+ * Validate query arguments are safe JSON.
90
+ * Returns parsed arguments or null if invalid.
91
+ */
92
+ export declare function parseQueryArgs(argsRaw: string): {
93
+ valid: boolean;
94
+ args: unknown[];
95
+ reason?: string;
96
+ };
97
+ export declare const CSRF_DEFAULTS: {
98
+ readonly cookieName: "__kuratchi_csrf";
99
+ readonly headerName: "x-kuratchi-csrf";
100
+ readonly formField: "_csrf";
101
+ };
@@ -0,0 +1,298 @@
1
+ /**
2
+ * KuratchiJS Security Module
3
+ *
4
+ * Provides CSRF protection, request signing, and security utilities
5
+ * for the framework runtime.
6
+ */
7
+ import { __getLocals, __setLocal } from './context.js';
8
+ // ── CSRF Token Management ──────────────────────────────────────────
9
+ const CSRF_TOKEN_LENGTH = 32;
10
+ const CSRF_COOKIE_NAME = '__kuratchi_csrf';
11
+ const CSRF_HEADER_NAME = 'x-kuratchi-csrf';
12
+ const CSRF_FORM_FIELD = '_csrf';
13
+ /**
14
+ * Generate a cryptographically secure random token
15
+ */
16
+ function generateToken(length = CSRF_TOKEN_LENGTH) {
17
+ const bytes = new Uint8Array(length);
18
+ crypto.getRandomValues(bytes);
19
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
20
+ }
21
+ /**
22
+ * Initialize CSRF protection for the current request.
23
+ * Generates a new token if one doesn't exist in cookies.
24
+ */
25
+ export function initCsrf(request, cookieName = CSRF_COOKIE_NAME) {
26
+ const cookies = parseCookies(request.headers.get('cookie'));
27
+ let token = cookies[cookieName];
28
+ if (!token || token.length < CSRF_TOKEN_LENGTH) {
29
+ token = generateToken();
30
+ // Mark that we need to set the cookie
31
+ __setLocal('__csrfTokenNew', true);
32
+ }
33
+ __setLocal('__csrfToken', token);
34
+ __setLocal('__csrfCookieName', cookieName);
35
+ return token;
36
+ }
37
+ /**
38
+ * Get the current CSRF token for the request
39
+ */
40
+ export function getCsrfToken() {
41
+ const token = __getLocals().__csrfToken;
42
+ if (!token) {
43
+ throw new Error('[kuratchi] CSRF token not initialized. Ensure security middleware is active.');
44
+ }
45
+ return token;
46
+ }
47
+ /**
48
+ * Validate CSRF token from request against stored token.
49
+ * Checks both header and form field.
50
+ */
51
+ export function validateCsrf(request, formData, headerName = CSRF_HEADER_NAME, formField = CSRF_FORM_FIELD) {
52
+ const storedToken = __getLocals().__csrfToken;
53
+ if (!storedToken) {
54
+ return { valid: false, reason: 'No CSRF token in session' };
55
+ }
56
+ // Check header first (for fetch requests)
57
+ const headerToken = request.headers.get(headerName);
58
+ if (headerToken) {
59
+ if (timingSafeEqual(headerToken, storedToken)) {
60
+ return { valid: true };
61
+ }
62
+ return { valid: false, reason: 'CSRF header token mismatch' };
63
+ }
64
+ // Check form field (for traditional form submissions)
65
+ if (formData) {
66
+ const formToken = formData.get(formField);
67
+ if (typeof formToken === 'string' && timingSafeEqual(formToken, storedToken)) {
68
+ return { valid: true };
69
+ }
70
+ return { valid: false, reason: 'CSRF form token mismatch or missing' };
71
+ }
72
+ return { valid: false, reason: 'No CSRF token provided in request' };
73
+ }
74
+ /**
75
+ * Timing-safe string comparison to prevent timing attacks
76
+ */
77
+ function timingSafeEqual(a, b) {
78
+ if (a.length !== b.length) {
79
+ return false;
80
+ }
81
+ let result = 0;
82
+ for (let i = 0; i < a.length; i++) {
83
+ result |= a.charCodeAt(i) ^ b.charCodeAt(i);
84
+ }
85
+ return result === 0;
86
+ }
87
+ /**
88
+ * Get the Set-Cookie header for CSRF token if needed
89
+ */
90
+ export function getCsrfCookieHeader() {
91
+ const locals = __getLocals();
92
+ if (!locals.__csrfTokenNew) {
93
+ return null;
94
+ }
95
+ const token = locals.__csrfToken;
96
+ const cookieName = locals.__csrfCookieName || CSRF_COOKIE_NAME;
97
+ // SameSite=Lax allows the cookie to be sent on top-level navigations
98
+ // HttpOnly=false so client JS can read it for fetch requests
99
+ return `${cookieName}=${token}; Path=/; SameSite=Lax; Secure`;
100
+ }
101
+ // ── RPC Security ───────────────────────────────────────────────────
102
+ const RPC_NONCE_LENGTH = 16;
103
+ /**
104
+ * Generate a per-request RPC nonce for additional request signing
105
+ */
106
+ export function generateRpcNonce() {
107
+ return generateToken(RPC_NONCE_LENGTH);
108
+ }
109
+ /**
110
+ * Validate an RPC request meets security requirements
111
+ */
112
+ export function validateRpcRequest(request, config) {
113
+ const method = request.method;
114
+ // Check allowed methods
115
+ if (!config.allowedMethods.includes(method)) {
116
+ return {
117
+ valid: false,
118
+ status: 405,
119
+ reason: `RPC method ${method} not allowed. Use: ${config.allowedMethods.join(', ')}`,
120
+ };
121
+ }
122
+ // Check CSRF if enabled
123
+ if (config.validateCsrf) {
124
+ const csrfResult = validateCsrf(request);
125
+ if (!csrfResult.valid) {
126
+ return { valid: false, status: 403, reason: csrfResult.reason };
127
+ }
128
+ }
129
+ // Check authentication if required
130
+ if (config.requireAuth) {
131
+ const locals = __getLocals();
132
+ const user = locals.user || locals.session?.user;
133
+ if (!user) {
134
+ return { valid: false, status: 401, reason: 'Authentication required for RPC' };
135
+ }
136
+ }
137
+ return { valid: true, status: 200 };
138
+ }
139
+ /**
140
+ * Validate an action request meets security requirements
141
+ */
142
+ export function validateActionRequest(request, url, formData, config) {
143
+ // Check same-origin
144
+ if (config.requireSameOrigin && !isSameOrigin(request, url)) {
145
+ return { valid: false, status: 403, reason: 'Cross-origin action requests forbidden' };
146
+ }
147
+ // Check CSRF if enabled
148
+ if (config.validateCsrf) {
149
+ const csrfResult = validateCsrf(request, formData);
150
+ if (!csrfResult.valid) {
151
+ return { valid: false, status: 403, reason: csrfResult.reason };
152
+ }
153
+ }
154
+ return { valid: true, status: 200 };
155
+ }
156
+ const DEFAULT_SEC_HEADERS = {
157
+ 'X-Content-Type-Options': 'nosniff',
158
+ 'X-Frame-Options': 'DENY',
159
+ 'Referrer-Policy': 'strict-origin-when-cross-origin',
160
+ };
161
+ /**
162
+ * Apply security headers to a response
163
+ */
164
+ export function applySecurityHeaders(response, config) {
165
+ for (const [key, value] of Object.entries(DEFAULT_SEC_HEADERS)) {
166
+ if (!response.headers.has(key)) {
167
+ response.headers.set(key, value);
168
+ }
169
+ }
170
+ if (config?.contentSecurityPolicy && !response.headers.has('Content-Security-Policy')) {
171
+ response.headers.set('Content-Security-Policy', config.contentSecurityPolicy);
172
+ }
173
+ if (config?.strictTransportSecurity && !response.headers.has('Strict-Transport-Security')) {
174
+ response.headers.set('Strict-Transport-Security', config.strictTransportSecurity);
175
+ }
176
+ if (config?.permissionsPolicy && !response.headers.has('Permissions-Policy')) {
177
+ response.headers.set('Permissions-Policy', config.permissionsPolicy);
178
+ }
179
+ return response;
180
+ }
181
+ // ── Utility Functions ──────────────────────────────────────────────
182
+ function parseCookies(header) {
183
+ const map = {};
184
+ if (!header)
185
+ return map;
186
+ for (const pair of header.split(';')) {
187
+ const eq = pair.indexOf('=');
188
+ if (eq === -1)
189
+ continue;
190
+ map[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
191
+ }
192
+ return map;
193
+ }
194
+ function isSameOrigin(request, url) {
195
+ const fetchSite = request.headers.get('sec-fetch-site');
196
+ if (fetchSite && fetchSite !== 'same-origin' && fetchSite !== 'same-site' && fetchSite !== 'none') {
197
+ return false;
198
+ }
199
+ const origin = request.headers.get('origin');
200
+ if (!origin)
201
+ return true;
202
+ try {
203
+ return new URL(origin).origin === url.origin;
204
+ }
205
+ catch {
206
+ return false;
207
+ }
208
+ }
209
+ // ── Fragment Security ───────────────────────────────────────────────
210
+ /**
211
+ * Sign a fragment ID with the route path and CSRF token to prevent tampering.
212
+ * Format: fragmentId:signature
213
+ */
214
+ export function signFragmentId(fragmentId, routePath) {
215
+ const token = __getLocals().__csrfToken || '';
216
+ const payload = `${fragmentId}:${routePath}:${token}`;
217
+ const signature = simpleHash(payload);
218
+ return `${fragmentId}:${signature}`;
219
+ }
220
+ /**
221
+ * Validate a signed fragment ID against the current route and session.
222
+ * If no CSRF token is present (e.g., in tests), allows unsigned fragments.
223
+ */
224
+ export function validateSignedFragment(signedFragment, routePath) {
225
+ const token = __getLocals().__csrfToken || '';
226
+ // If no CSRF token is set, allow unsigned fragments (backward compat / tests)
227
+ if (!token) {
228
+ const colonIdx = signedFragment.lastIndexOf(':');
229
+ // If it looks signed, extract the fragment ID; otherwise use as-is
230
+ const fragmentId = colonIdx !== -1 ? signedFragment.slice(0, colonIdx) : signedFragment;
231
+ return { valid: true, fragmentId };
232
+ }
233
+ const colonIdx = signedFragment.lastIndexOf(':');
234
+ if (colonIdx === -1) {
235
+ // Unsigned fragment with CSRF enabled - reject for security
236
+ return { valid: false, fragmentId: null, reason: 'Fragment ID not signed' };
237
+ }
238
+ const fragmentId = signedFragment.slice(0, colonIdx);
239
+ const providedSignature = signedFragment.slice(colonIdx + 1);
240
+ const payload = `${fragmentId}:${routePath}:${token}`;
241
+ const expectedSignature = simpleHash(payload);
242
+ if (!timingSafeEqual(providedSignature, expectedSignature)) {
243
+ return { valid: false, fragmentId: null, reason: 'Fragment signature invalid' };
244
+ }
245
+ return { valid: true, fragmentId };
246
+ }
247
+ /**
248
+ * Simple hash function for signing (not cryptographic, but sufficient for HMAC-like signing
249
+ * when combined with a secret token). Uses FNV-1a for speed.
250
+ */
251
+ function simpleHash(str) {
252
+ let hash = 2166136261;
253
+ for (let i = 0; i < str.length; i++) {
254
+ hash ^= str.charCodeAt(i);
255
+ hash = (hash * 16777619) >>> 0;
256
+ }
257
+ return hash.toString(36);
258
+ }
259
+ // ── Query Override Security ────────────────────────────────────────
260
+ /**
261
+ * Validate that a query function is allowed for the current route.
262
+ * The allowedQueries set should contain the function names registered for this route.
263
+ */
264
+ export function validateQueryOverride(queryFn, allowedQueries) {
265
+ const allowed = allowedQueries instanceof Set ? allowedQueries : new Set(allowedQueries);
266
+ if (!queryFn) {
267
+ return { valid: false, reason: 'No query function specified' };
268
+ }
269
+ if (!allowed.has(queryFn)) {
270
+ return { valid: false, reason: `Query function '${queryFn}' not registered for this route` };
271
+ }
272
+ return { valid: true };
273
+ }
274
+ /**
275
+ * Validate query arguments are safe JSON.
276
+ * Returns parsed arguments or null if invalid.
277
+ */
278
+ export function parseQueryArgs(argsRaw) {
279
+ if (!argsRaw || argsRaw === '[]') {
280
+ return { valid: true, args: [] };
281
+ }
282
+ try {
283
+ const parsed = JSON.parse(argsRaw);
284
+ if (!Array.isArray(parsed)) {
285
+ return { valid: false, args: [], reason: 'Query args must be an array' };
286
+ }
287
+ return { valid: true, args: parsed };
288
+ }
289
+ catch {
290
+ return { valid: false, args: [], reason: 'Invalid JSON in query args' };
291
+ }
292
+ }
293
+ // ── Exports for Framework Use ──────────────────────────────────────
294
+ export const CSRF_DEFAULTS = {
295
+ cookieName: CSRF_COOKIE_NAME,
296
+ headerName: CSRF_HEADER_NAME,
297
+ formField: CSRF_FORM_FIELD,
298
+ };
@@ -18,6 +18,12 @@ export interface RouteContext<E extends Env = Env> {
18
18
  /** Parsed URL */
19
19
  url: URL;
20
20
  }
21
+ export interface PageRenderResult {
22
+ html: string;
23
+ head?: string;
24
+ fragments?: Record<string, string>;
25
+ }
26
+ export type PageRenderOutput = string | PageRenderResult;
21
27
  /** A compiled route module */
22
28
  export interface RouteModule {
23
29
  /** Pattern string (e.g., '/todos', '/blog/:slug') */
@@ -28,8 +34,8 @@ export interface RouteModule {
28
34
  actions?: Record<string, (formData: FormData, env: Env, ctx: RouteContext) => Promise<any>>;
29
35
  /** RPC functions â€" callable from client via fetch */
30
36
  rpc?: Record<string, (args: any[], env: Env, ctx: RouteContext) => Promise<any>>;
31
- /** Render function â€" returns HTML string from data */
32
- render: (data: Record<string, any>) => string;
37
+ /** Render function â€" returns route HTML and optional head content from data */
38
+ render: (data: Record<string, any>) => PageRenderOutput;
33
39
  /** Layout name (default: 'default') */
34
40
  layout?: string;
35
41
  }
@@ -224,6 +230,27 @@ export interface AuthConfig {
224
230
  binding?: string;
225
231
  [key: string]: any;
226
232
  } | Record<string, any>;
233
+ /** Security configuration â€" CSRF protection, RPC security */
234
+ security?: SecurityConfig;
235
+ }
236
+ /** Security configuration for kuratchi.config.ts */
237
+ export interface SecurityConfig {
238
+ /** Enable CSRF protection for actions and RPC (default: true) */
239
+ csrfEnabled?: boolean;
240
+ /** CSRF cookie name (default: '__kuratchi_csrf') */
241
+ csrfCookieName?: string;
242
+ /** CSRF header name for fetch requests (default: 'x-kuratchi-csrf') */
243
+ csrfHeaderName?: string;
244
+ /** Require authentication for RPC calls (default: false) */
245
+ rpcRequireAuth?: boolean;
246
+ /** Require authentication for form actions (default: false) */
247
+ actionRequireAuth?: boolean;
248
+ /** Content Security Policy directive string */
249
+ contentSecurityPolicy?: string;
250
+ /** Strict-Transport-Security header value */
251
+ strictTransportSecurity?: string;
252
+ /** Permissions-Policy header value */
253
+ permissionsPolicy?: string;
227
254
  }
228
255
  /** Runtime pipeline context - shared across runtime step handlers */
229
256
  export interface RuntimeContext<E extends Env = Env> {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuratchi/js",
3
- "version": "0.0.15",
3
+ "version": "0.0.17",
4
4
  "description": "A thin, Cloudflare Workers-native web framework with Svelte-inspired syntax",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -38,6 +38,10 @@
38
38
  "types": "./dist/runtime/do.d.ts",
39
39
  "import": "./dist/runtime/do.js"
40
40
  },
41
+ "./runtime/generated-worker.js": {
42
+ "types": "./dist/runtime/generated-worker.d.ts",
43
+ "import": "./dist/runtime/generated-worker.js"
44
+ },
41
45
  "./compiler": {
42
46
  "types": "./dist/compiler/index.d.ts",
43
47
  "import": "./dist/compiler/index.js"