@kuratchi/js 0.0.16 → 0.0.18
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/README.md +168 -11
- package/dist/cli.js +13 -13
- package/dist/compiler/client-module-pipeline.js +5 -5
- package/dist/compiler/compiler-shared.d.ts +18 -0
- package/dist/compiler/component-pipeline.js +4 -9
- package/dist/compiler/config-reading.d.ts +2 -1
- package/dist/compiler/config-reading.js +57 -0
- package/dist/compiler/durable-object-pipeline.js +1 -1
- package/dist/compiler/import-linking.js +2 -1
- package/dist/compiler/index.d.ts +6 -6
- package/dist/compiler/index.js +57 -23
- package/dist/compiler/layout-pipeline.js +6 -6
- package/dist/compiler/parser.js +10 -11
- package/dist/compiler/root-layout-pipeline.js +444 -429
- package/dist/compiler/route-pipeline.js +36 -41
- package/dist/compiler/route-state-pipeline.d.ts +1 -0
- package/dist/compiler/route-state-pipeline.js +3 -3
- package/dist/compiler/routes-module-feature-blocks.js +63 -63
- package/dist/compiler/routes-module-runtime-shell.js +65 -55
- package/dist/compiler/routes-module-types.d.ts +2 -1
- package/dist/compiler/server-module-pipeline.js +1 -1
- package/dist/compiler/template.js +24 -15
- package/dist/compiler/worker-output-pipeline.d.ts +1 -0
- package/dist/compiler/worker-output-pipeline.js +10 -2
- package/dist/create.js +1 -1
- package/dist/runtime/context.d.ts +4 -0
- package/dist/runtime/context.js +40 -2
- package/dist/runtime/do.js +21 -6
- package/dist/runtime/generated-worker.d.ts +22 -0
- package/dist/runtime/generated-worker.js +154 -23
- package/dist/runtime/index.d.ts +3 -1
- package/dist/runtime/index.js +1 -0
- package/dist/runtime/router.d.ts +5 -1
- package/dist/runtime/router.js +116 -31
- package/dist/runtime/security.d.ts +101 -0
- package/dist/runtime/security.js +312 -0
- package/dist/runtime/types.d.ts +21 -0
- package/package.json +1 -1
package/dist/runtime/do.js
CHANGED
|
@@ -30,6 +30,10 @@ export function __getDoSelf() {
|
|
|
30
30
|
}
|
|
31
31
|
const _resolvers = new Map();
|
|
32
32
|
const _classBindings = new WeakMap();
|
|
33
|
+
const _rpcProxyCache = new WeakMap();
|
|
34
|
+
function __isDevMode() {
|
|
35
|
+
return !!globalThis.__kuratchi_DEV__;
|
|
36
|
+
}
|
|
33
37
|
/** @internal — called by compiler-generated init code */
|
|
34
38
|
export function __registerDoResolver(binding, resolver) {
|
|
35
39
|
_resolvers.set(binding, resolver);
|
|
@@ -94,36 +98,47 @@ export class kuratchiDO {
|
|
|
94
98
|
*/
|
|
95
99
|
static rpc() {
|
|
96
100
|
const klass = this;
|
|
97
|
-
|
|
101
|
+
// Cache proxy per class to avoid repeated Proxy creation
|
|
102
|
+
let cached = _rpcProxyCache.get(klass);
|
|
103
|
+
if (cached)
|
|
104
|
+
return cached;
|
|
105
|
+
const proxy = new Proxy({}, {
|
|
98
106
|
get(_, method) {
|
|
99
107
|
return async (...args) => {
|
|
100
108
|
const binding = this.binding || _classBindings.get(klass);
|
|
101
109
|
if (!binding) {
|
|
102
110
|
throw new Error(`[KuratchiJS] Missing DO binding for class '${this?.name || 'UnknownDO'}'. Add static binding or ensure compiler binding registration is active.`);
|
|
103
111
|
}
|
|
104
|
-
|
|
112
|
+
if (__isDevMode())
|
|
113
|
+
console.log(`[rpc] ${binding}.${method}() — resolving stub...`);
|
|
105
114
|
const stub = await __getDoStub(binding);
|
|
106
115
|
if (!stub) {
|
|
107
116
|
throw new Error(`[KuratchiJS] Not authenticated — cannot call '${method}' on ${binding}`);
|
|
108
117
|
}
|
|
109
|
-
|
|
110
|
-
|
|
118
|
+
if (__isDevMode()) {
|
|
119
|
+
console.log(`[rpc] ${binding}.${method}() — stub type: ${stub?.constructor?.name ?? typeof stub}`);
|
|
120
|
+
console.log(`[rpc] ${binding}.${method}() — calling with ${args.length} arg(s)...`);
|
|
121
|
+
}
|
|
111
122
|
try {
|
|
112
123
|
// Call method directly on the stub — DO NOT detach with stub[method]
|
|
113
124
|
// then .apply(). Workers RPC stubs are Proxy-based; detaching breaks
|
|
114
125
|
// the runtime's interception and causes DataCloneError trying to
|
|
115
126
|
// serialize the DurableObject as `this`.
|
|
116
127
|
const result = await stub[method](...args);
|
|
117
|
-
|
|
128
|
+
if (__isDevMode())
|
|
129
|
+
console.log(`[rpc] ${binding}.${method}() — returned, result type: ${typeof result}`);
|
|
118
130
|
return result;
|
|
119
131
|
}
|
|
120
132
|
catch (err) {
|
|
121
|
-
|
|
133
|
+
if (__isDevMode())
|
|
134
|
+
console.error(`[rpc] ${binding}.${method}() — THREW: ${err.message}`);
|
|
122
135
|
throw err;
|
|
123
136
|
}
|
|
124
137
|
};
|
|
125
138
|
},
|
|
126
139
|
});
|
|
140
|
+
_rpcProxyCache.set(klass, proxy);
|
|
141
|
+
return proxy;
|
|
127
142
|
}
|
|
128
143
|
}
|
|
129
144
|
/**
|
|
@@ -14,8 +14,28 @@ export interface GeneratedPageRoute {
|
|
|
14
14
|
load?: (params: Record<string, string>) => Promise<unknown> | unknown;
|
|
15
15
|
actions?: Record<string, (...args: any[]) => Promise<unknown> | unknown>;
|
|
16
16
|
rpc?: Record<string, (...args: any[]) => Promise<unknown> | unknown>;
|
|
17
|
+
/** Allowed query function names for this route (for query override validation) */
|
|
18
|
+
allowedQueries?: string[];
|
|
17
19
|
render: (data: Record<string, any>) => PageRenderOutput;
|
|
18
20
|
}
|
|
21
|
+
export interface SecurityOptions {
|
|
22
|
+
/** Enable CSRF protection (default: true) */
|
|
23
|
+
csrfEnabled?: boolean;
|
|
24
|
+
/** CSRF cookie name (default: '__kuratchi_csrf') */
|
|
25
|
+
csrfCookieName?: string;
|
|
26
|
+
/** CSRF header name (default: 'x-kuratchi-csrf') */
|
|
27
|
+
csrfHeaderName?: string;
|
|
28
|
+
/** Require authentication for RPC (default: false) */
|
|
29
|
+
rpcRequireAuth?: boolean;
|
|
30
|
+
/** Require authentication for form actions (default: false) */
|
|
31
|
+
actionRequireAuth?: boolean;
|
|
32
|
+
/** Content Security Policy directive string */
|
|
33
|
+
contentSecurityPolicy?: string | null;
|
|
34
|
+
/** Strict-Transport-Security header value */
|
|
35
|
+
strictTransportSecurity?: string | null;
|
|
36
|
+
/** Permissions-Policy header value */
|
|
37
|
+
permissionsPolicy?: string | null;
|
|
38
|
+
}
|
|
19
39
|
export interface GeneratedWorkerOptions {
|
|
20
40
|
routes: Array<GeneratedPageRoute | GeneratedApiRoute>;
|
|
21
41
|
layout: (content: string, head?: string) => Promise<string> | string;
|
|
@@ -27,6 +47,8 @@ export interface GeneratedWorkerOptions {
|
|
|
27
47
|
workflowStatusRpc?: Record<string, (instanceId: string) => Promise<unknown>>;
|
|
28
48
|
initializeRequest?: (ctx: RuntimeContext) => Promise<void> | void;
|
|
29
49
|
preRouteChecks?: (ctx: RuntimeContext) => Promise<Response | null | undefined> | Response | null | undefined;
|
|
50
|
+
/** Security configuration */
|
|
51
|
+
security?: SecurityOptions;
|
|
30
52
|
}
|
|
31
53
|
export declare function createGeneratedWorker(opts: GeneratedWorkerOptions): {
|
|
32
54
|
fetch(request: Request, env: Record<string, any>, ctx: ExecutionContext): Promise<Response>;
|
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
import { __esc, __getLocals, __setLocal, __setRequestContext, buildDefaultBreadcrumbs } from './context.js';
|
|
2
2
|
import { Router } from './router.js';
|
|
3
|
+
import { initCsrf, getCsrfCookieHeader, validateRpcRequest, validateActionRequest, validateSignedFragment, validateQueryOverride, parseQueryArgs, CSRF_DEFAULTS, } from './security.js';
|
|
3
4
|
export function createGeneratedWorker(opts) {
|
|
4
5
|
const router = new Router();
|
|
5
6
|
const runtimeEntries = __getRuntimeEntries(opts.runtimeDefinition);
|
|
6
7
|
for (let i = 0; i < opts.routes.length; i++) {
|
|
7
8
|
router.add(opts.routes[i].pattern, i);
|
|
8
9
|
}
|
|
10
|
+
// Security configuration with defaults
|
|
11
|
+
const securityConfig = {
|
|
12
|
+
csrfEnabled: opts.security?.csrfEnabled ?? true,
|
|
13
|
+
csrfCookieName: opts.security?.csrfCookieName ?? CSRF_DEFAULTS.cookieName,
|
|
14
|
+
csrfHeaderName: opts.security?.csrfHeaderName ?? CSRF_DEFAULTS.headerName,
|
|
15
|
+
rpcRequireAuth: opts.security?.rpcRequireAuth ?? false,
|
|
16
|
+
actionRequireAuth: opts.security?.actionRequireAuth ?? false,
|
|
17
|
+
contentSecurityPolicy: opts.security?.contentSecurityPolicy ?? null,
|
|
18
|
+
strictTransportSecurity: opts.security?.strictTransportSecurity ?? null,
|
|
19
|
+
permissionsPolicy: opts.security?.permissionsPolicy ?? null,
|
|
20
|
+
};
|
|
21
|
+
// Initialize configurable security headers
|
|
22
|
+
__initSecurityHeaders(securityConfig);
|
|
9
23
|
return {
|
|
10
24
|
async fetch(request, env, ctx) {
|
|
11
25
|
__setRequestContext(ctx, request, env);
|
|
@@ -17,12 +31,17 @@ export function createGeneratedWorker(opts) {
|
|
|
17
31
|
params: {},
|
|
18
32
|
locals: __getLocals(),
|
|
19
33
|
};
|
|
34
|
+
// Initialize CSRF token for the request
|
|
35
|
+
if (securityConfig.csrfEnabled) {
|
|
36
|
+
initCsrf(request, securityConfig.csrfCookieName);
|
|
37
|
+
}
|
|
20
38
|
if (opts.initializeRequest) {
|
|
21
39
|
await opts.initializeRequest(runtimeCtx);
|
|
22
40
|
}
|
|
23
41
|
const coreFetch = async () => {
|
|
24
42
|
const { url } = runtimeCtx;
|
|
25
|
-
const
|
|
43
|
+
const signedFragmentId = request.headers.get('x-kuratchi-fragment');
|
|
44
|
+
let fragmentId = null;
|
|
26
45
|
const preRoute = opts.preRouteChecks ? await opts.preRouteChecks(runtimeCtx) : null;
|
|
27
46
|
if (preRoute instanceof Response) {
|
|
28
47
|
return __secHeaders(preRoute);
|
|
@@ -53,28 +72,58 @@ export function createGeneratedWorker(opts) {
|
|
|
53
72
|
}
|
|
54
73
|
runtimeCtx.params = match.params;
|
|
55
74
|
__setLocal('params', match.params);
|
|
75
|
+
__setLocal('__currentRoutePath', url.pathname);
|
|
56
76
|
const route = opts.routes[match.index];
|
|
57
77
|
if ('__api' in route && route.__api) {
|
|
58
78
|
return __dispatchApiRoute(route, runtimeCtx);
|
|
59
79
|
}
|
|
60
80
|
const pageRoute = route;
|
|
81
|
+
// Validate fragment ID if present
|
|
82
|
+
if (signedFragmentId) {
|
|
83
|
+
const fragmentValidation = validateSignedFragment(signedFragmentId, url.pathname);
|
|
84
|
+
if (!fragmentValidation.valid) {
|
|
85
|
+
return __secHeaders(new Response(fragmentValidation.reason || 'Invalid fragment', { status: 403 }));
|
|
86
|
+
}
|
|
87
|
+
fragmentId = fragmentValidation.fragmentId;
|
|
88
|
+
}
|
|
89
|
+
// Validate and parse query override if present
|
|
61
90
|
const queryFn = request.headers.get('x-kuratchi-query-fn') || '';
|
|
62
91
|
const queryArgsRaw = request.headers.get('x-kuratchi-query-args') || '[]';
|
|
63
92
|
let queryArgs = [];
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
93
|
+
if (queryFn) {
|
|
94
|
+
// Validate query function is allowed for this route
|
|
95
|
+
const allowedQueries = pageRoute.allowedQueries || [];
|
|
96
|
+
// Also allow RPC functions as queries
|
|
97
|
+
const rpcFunctions = pageRoute.rpc ? Object.keys(pageRoute.rpc) : [];
|
|
98
|
+
const allAllowed = [...allowedQueries, ...rpcFunctions];
|
|
99
|
+
if (allAllowed.length > 0) {
|
|
100
|
+
const queryValidation = validateQueryOverride(queryFn, allAllowed);
|
|
101
|
+
if (!queryValidation.valid) {
|
|
102
|
+
return __secHeaders(new Response(JSON.stringify({ ok: false, error: queryValidation.reason }), {
|
|
103
|
+
status: 403,
|
|
104
|
+
headers: { 'content-type': 'application/json' },
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// Parse and validate query arguments
|
|
109
|
+
const argsValidation = parseQueryArgs(queryArgsRaw);
|
|
110
|
+
if (!argsValidation.valid) {
|
|
111
|
+
return __secHeaders(new Response(JSON.stringify({ ok: false, error: argsValidation.reason }), {
|
|
112
|
+
status: 400,
|
|
113
|
+
headers: { 'content-type': 'application/json' },
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
queryArgs = argsValidation.args;
|
|
67
117
|
}
|
|
68
|
-
catch { }
|
|
69
118
|
__setLocal('__queryOverride', queryFn ? { fn: queryFn, args: queryArgs } : null);
|
|
70
119
|
if (!__getLocals().__breadcrumbs) {
|
|
71
120
|
__setLocal('breadcrumbs', buildDefaultBreadcrumbs(url.pathname, match.params));
|
|
72
121
|
}
|
|
73
|
-
const rpcResponse = await __handleRpc(pageRoute, opts.workflowStatusRpc ?? {}, runtimeCtx);
|
|
122
|
+
const rpcResponse = await __handleRpc(pageRoute, opts.workflowStatusRpc ?? {}, runtimeCtx, securityConfig);
|
|
74
123
|
if (rpcResponse)
|
|
75
124
|
return rpcResponse;
|
|
76
125
|
if (request.method === 'POST') {
|
|
77
|
-
const actionResponse = await __handleAction(pageRoute, opts.layoutActions, opts.layout, runtimeCtx, fragmentId);
|
|
126
|
+
const actionResponse = await __handleAction(pageRoute, opts.layoutActions, opts.layout, runtimeCtx, fragmentId, securityConfig);
|
|
78
127
|
if (actionResponse)
|
|
79
128
|
return actionResponse;
|
|
80
129
|
}
|
|
@@ -101,7 +150,7 @@ export function createGeneratedWorker(opts) {
|
|
|
101
150
|
return __secHeaders(handled);
|
|
102
151
|
console.error('[kuratchi] Route load/render error:', err);
|
|
103
152
|
const pageErrStatus = err?.isPageError && err.status ? err.status : 500;
|
|
104
|
-
const errDetail =
|
|
153
|
+
const errDetail = __sanitizeErrorDetail(err);
|
|
105
154
|
return __secHeaders(new Response(await opts.layout(__renderError(opts.errorPages, pageErrStatus, errDetail)), {
|
|
106
155
|
status: pageErrStatus,
|
|
107
156
|
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
@@ -147,7 +196,7 @@ async function __dispatchApiRoute(route, runtimeCtx) {
|
|
|
147
196
|
}
|
|
148
197
|
return __secHeaders(await handler(runtimeCtx));
|
|
149
198
|
}
|
|
150
|
-
async function __handleRpc(route, workflowStatusRpc, runtimeCtx) {
|
|
199
|
+
async function __handleRpc(route, workflowStatusRpc, runtimeCtx, securityConfig) {
|
|
151
200
|
const { request, url } = runtimeCtx;
|
|
152
201
|
const rpcName = url.searchParams.get('_rpc');
|
|
153
202
|
const hasRouteRpc = rpcName && route.rpc && Object.hasOwn(route.rpc, rpcName);
|
|
@@ -155,8 +204,22 @@ async function __handleRpc(route, workflowStatusRpc, runtimeCtx) {
|
|
|
155
204
|
if (!(request.method === 'GET' && rpcName && (hasRouteRpc || hasWorkflowRpc))) {
|
|
156
205
|
return null;
|
|
157
206
|
}
|
|
207
|
+
// Validate RPC request security
|
|
208
|
+
const rpcSecConfig = {
|
|
209
|
+
requireAuth: securityConfig.rpcRequireAuth,
|
|
210
|
+
validateCsrf: securityConfig.csrfEnabled,
|
|
211
|
+
allowedMethods: ['GET', 'POST'],
|
|
212
|
+
};
|
|
213
|
+
const rpcValidation = validateRpcRequest(request, rpcSecConfig);
|
|
214
|
+
if (!rpcValidation.valid) {
|
|
215
|
+
return __secHeaders(new Response(JSON.stringify({ ok: false, error: rpcValidation.reason || 'Forbidden' }), {
|
|
216
|
+
status: rpcValidation.status,
|
|
217
|
+
headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
// Legacy header check (still required for backward compatibility)
|
|
158
221
|
if (request.headers.get('x-kuratchi-rpc') !== '1') {
|
|
159
|
-
return __secHeaders(new Response(JSON.stringify({ ok: false, error: '
|
|
222
|
+
return __secHeaders(new Response(JSON.stringify({ ok: false, error: 'Missing RPC header' }), {
|
|
160
223
|
status: 403,
|
|
161
224
|
headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
|
|
162
225
|
}));
|
|
@@ -170,27 +233,41 @@ async function __handleRpc(route, workflowStatusRpc, runtimeCtx) {
|
|
|
170
233
|
}
|
|
171
234
|
const rpcFn = hasRouteRpc ? route.rpc[rpcName] : workflowStatusRpc[rpcName];
|
|
172
235
|
const rpcResult = await rpcFn(...rpcArgs);
|
|
173
|
-
return
|
|
236
|
+
return __attachCookies(new Response(JSON.stringify({ ok: true, data: rpcResult }), {
|
|
174
237
|
headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
|
|
175
238
|
}));
|
|
176
239
|
}
|
|
177
240
|
catch (err) {
|
|
178
241
|
console.error('[kuratchi] RPC error:', err);
|
|
179
|
-
const errMsg =
|
|
242
|
+
const errMsg = __sanitizeErrorMessage(err);
|
|
180
243
|
return __secHeaders(new Response(JSON.stringify({ ok: false, error: errMsg }), {
|
|
181
244
|
status: 500,
|
|
182
245
|
headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
|
|
183
246
|
}));
|
|
184
247
|
}
|
|
185
248
|
}
|
|
186
|
-
async function __handleAction(route, layoutActions, layout, runtimeCtx, fragmentId) {
|
|
249
|
+
async function __handleAction(route, layoutActions, layout, runtimeCtx, fragmentId, securityConfig) {
|
|
187
250
|
const { request, url, params } = runtimeCtx;
|
|
188
251
|
if (request.method !== 'POST')
|
|
189
252
|
return null;
|
|
190
|
-
if (!__isSameOrigin(request, url)) {
|
|
191
|
-
return __secHeaders(new Response('Forbidden', { status: 403 }));
|
|
192
|
-
}
|
|
193
253
|
const formData = await request.formData();
|
|
254
|
+
// Validate action request security
|
|
255
|
+
const actionSecConfig = {
|
|
256
|
+
validateCsrf: securityConfig.csrfEnabled,
|
|
257
|
+
requireSameOrigin: true,
|
|
258
|
+
};
|
|
259
|
+
const actionValidation = validateActionRequest(request, url, formData, actionSecConfig);
|
|
260
|
+
if (!actionValidation.valid) {
|
|
261
|
+
return __secHeaders(new Response(actionValidation.reason || 'Forbidden', { status: actionValidation.status }));
|
|
262
|
+
}
|
|
263
|
+
// Check authentication if required
|
|
264
|
+
if (securityConfig.actionRequireAuth) {
|
|
265
|
+
const locals = __getLocals();
|
|
266
|
+
const user = locals.user || locals.session?.user;
|
|
267
|
+
if (!user) {
|
|
268
|
+
return __secHeaders(new Response('Authentication required', { status: 401 }));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
194
271
|
const actionName = formData.get('_action');
|
|
195
272
|
const actionKey = typeof actionName === 'string' ? actionName : null;
|
|
196
273
|
const actionFn = (actionKey && route.actions && Object.hasOwn(route.actions, actionKey) ? route.actions[actionKey] : null)
|
|
@@ -223,7 +300,7 @@ async function __handleAction(route, layoutActions, layout, runtimeCtx, fragment
|
|
|
223
300
|
}
|
|
224
301
|
console.error('[kuratchi] Action error:', err);
|
|
225
302
|
if (isFetchAction) {
|
|
226
|
-
const errMsg =
|
|
303
|
+
const errMsg = __sanitizeErrorMessage(err);
|
|
227
304
|
return __secHeaders(new Response(JSON.stringify({ ok: false, error: errMsg }), {
|
|
228
305
|
status: 500,
|
|
229
306
|
headers: { 'content-type': 'application/json' },
|
|
@@ -238,7 +315,7 @@ async function __handleAction(route, layoutActions, layout, runtimeCtx, fragment
|
|
|
238
315
|
if (!(key in data))
|
|
239
316
|
data[key] = { error: undefined, loading: false, success: false };
|
|
240
317
|
});
|
|
241
|
-
const errMsg =
|
|
318
|
+
const errMsg = __sanitizeErrorMessage(err, 'Action failed');
|
|
242
319
|
data[actionKey] = { error: errMsg, loading: false, success: false };
|
|
243
320
|
return await __renderPage(layout, route, data, fragmentId);
|
|
244
321
|
}
|
|
@@ -299,18 +376,26 @@ function __isSameOrigin(request, url) {
|
|
|
299
376
|
}
|
|
300
377
|
}
|
|
301
378
|
function __secHeaders(response) {
|
|
302
|
-
for (const [key, value] of Object.entries(
|
|
379
|
+
for (const [key, value] of Object.entries(__configuredSecHeaders)) {
|
|
303
380
|
if (!response.headers.has(key))
|
|
304
381
|
response.headers.set(key, value);
|
|
305
382
|
}
|
|
306
383
|
return response;
|
|
307
384
|
}
|
|
308
385
|
function __attachCookies(response) {
|
|
309
|
-
const
|
|
310
|
-
|
|
386
|
+
const locals = __getLocals();
|
|
387
|
+
const cookies = locals.__setCookieHeaders;
|
|
388
|
+
const csrfCookie = getCsrfCookieHeader();
|
|
389
|
+
const hasCookies = (cookies && cookies.length > 0) || csrfCookie;
|
|
390
|
+
if (hasCookies) {
|
|
311
391
|
const newResponse = new Response(response.body, response);
|
|
312
|
-
|
|
313
|
-
|
|
392
|
+
if (cookies) {
|
|
393
|
+
for (const header of cookies)
|
|
394
|
+
newResponse.headers.append('Set-Cookie', header);
|
|
395
|
+
}
|
|
396
|
+
if (csrfCookie) {
|
|
397
|
+
newResponse.headers.append('Set-Cookie', csrfCookie);
|
|
398
|
+
}
|
|
314
399
|
return __secHeaders(newResponse);
|
|
315
400
|
}
|
|
316
401
|
return __secHeaders(response);
|
|
@@ -393,11 +478,57 @@ function __getRuntimeEntries(runtimeDefinition) {
|
|
|
393
478
|
function __isDevMode() {
|
|
394
479
|
return !!globalThis.__kuratchi_DEV__;
|
|
395
480
|
}
|
|
481
|
+
/**
|
|
482
|
+
* Sanitize error messages for client responses.
|
|
483
|
+
* In production, only expose safe error messages to prevent information leakage.
|
|
484
|
+
* In dev mode, expose full error details for debugging.
|
|
485
|
+
*/
|
|
486
|
+
function __sanitizeErrorMessage(err, fallback = 'Internal Server Error') {
|
|
487
|
+
// Always allow explicit user-facing errors (ActionError, PageError)
|
|
488
|
+
if (err?.isActionError || err?.isPageError) {
|
|
489
|
+
return err.message || fallback;
|
|
490
|
+
}
|
|
491
|
+
// In dev mode, expose full error message for debugging
|
|
492
|
+
if (__isDevMode() && err?.message) {
|
|
493
|
+
return err.message;
|
|
494
|
+
}
|
|
495
|
+
// In production, use generic message to prevent information leakage
|
|
496
|
+
return fallback;
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Sanitize error details for HTML error pages.
|
|
500
|
+
* Returns undefined in production to hide error details.
|
|
501
|
+
*/
|
|
502
|
+
function __sanitizeErrorDetail(err) {
|
|
503
|
+
// PageError messages are always safe to show
|
|
504
|
+
if (err?.isPageError) {
|
|
505
|
+
return err.message;
|
|
506
|
+
}
|
|
507
|
+
// In dev mode, show error details
|
|
508
|
+
if (__isDevMode() && err?.message) {
|
|
509
|
+
return err.message;
|
|
510
|
+
}
|
|
511
|
+
// In production, hide error details
|
|
512
|
+
return undefined;
|
|
513
|
+
}
|
|
396
514
|
const __defaultSecHeaders = {
|
|
397
515
|
'X-Content-Type-Options': 'nosniff',
|
|
398
516
|
'X-Frame-Options': 'DENY',
|
|
399
517
|
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
|
400
518
|
};
|
|
519
|
+
let __configuredSecHeaders = { ...__defaultSecHeaders };
|
|
520
|
+
function __initSecurityHeaders(config) {
|
|
521
|
+
__configuredSecHeaders = { ...__defaultSecHeaders };
|
|
522
|
+
if (config.contentSecurityPolicy) {
|
|
523
|
+
__configuredSecHeaders['Content-Security-Policy'] = config.contentSecurityPolicy;
|
|
524
|
+
}
|
|
525
|
+
if (config.strictTransportSecurity) {
|
|
526
|
+
__configuredSecHeaders['Strict-Transport-Security'] = config.strictTransportSecurity;
|
|
527
|
+
}
|
|
528
|
+
if (config.permissionsPolicy) {
|
|
529
|
+
__configuredSecHeaders['Permissions-Policy'] = config.permissionsPolicy;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
401
532
|
const __errorMessages = {
|
|
402
533
|
400: 'Bad Request',
|
|
403
534
|
401: 'Unauthorized',
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -5,7 +5,9 @@ export { defineRuntime } from './runtime.js';
|
|
|
5
5
|
export { Router, filePathToPattern } from './router.js';
|
|
6
6
|
export { getCtx, getRequest, getLocals, getParams, getParam, RedirectError, redirect, goto, setBreadcrumbs, getBreadcrumbs, breadcrumbsHome, breadcrumbsPrev, breadcrumbsNext, breadcrumbsCurrent, buildDefaultBreadcrumbs, } from './context.js';
|
|
7
7
|
export { kuratchiDO, doRpc } from './do.js';
|
|
8
|
+
export { initCsrf, getCsrfToken, validateCsrf, getCsrfCookieHeader, validateRpcRequest, validateActionRequest, applySecurityHeaders, signFragmentId, validateSignedFragment, validateQueryOverride, parseQueryArgs, CSRF_DEFAULTS, } from './security.js';
|
|
9
|
+
export type { RpcSecurityConfig, ActionSecurityConfig, SecurityHeadersConfig, } from './security.js';
|
|
8
10
|
export { extractSubdomainSlug, extractSlugFromPrefix, matchContainerViewPath, rewriteProxyLocationHeader, buildContainerRequest, createContainerEnvVars, startContainer, proxyToContainer, handleContainerRouting, forwardJsonPostToContainerDO, matchSiteViewPath, buildSiteContainerRequest, createWpContainerEnvVars, startSiteContainer, proxyToSiteContainer, } from './containers.js';
|
|
9
|
-
export type { AppConfig, Env, AuthConfig, RouteContext, RouteModule, ApiRouteModule, HttpMethod, LayoutModule, PageRenderOutput, PageRenderResult, RuntimeContext, RuntimeDefinition, RuntimeStep, RuntimeNext, RuntimeErrorResult, } from './types.js';
|
|
11
|
+
export type { AppConfig, Env, AuthConfig, SecurityConfig, RouteContext, RouteModule, ApiRouteModule, HttpMethod, LayoutModule, PageRenderOutput, PageRenderResult, RuntimeContext, RuntimeDefinition, RuntimeStep, RuntimeNext, RuntimeErrorResult, } from './types.js';
|
|
10
12
|
export type { RpcOf } from './do.js';
|
|
11
13
|
export { url, pathname, searchParams, headers, method, params, slug } from './request.js';
|
package/dist/runtime/index.js
CHANGED
|
@@ -5,6 +5,7 @@ export { defineRuntime } from './runtime.js';
|
|
|
5
5
|
export { Router, filePathToPattern } from './router.js';
|
|
6
6
|
export { getCtx, getRequest, getLocals, getParams, getParam, RedirectError, redirect, goto, setBreadcrumbs, getBreadcrumbs, breadcrumbsHome, breadcrumbsPrev, breadcrumbsNext, breadcrumbsCurrent, buildDefaultBreadcrumbs, } from './context.js';
|
|
7
7
|
export { kuratchiDO, doRpc } from './do.js';
|
|
8
|
+
export { initCsrf, getCsrfToken, validateCsrf, getCsrfCookieHeader, validateRpcRequest, validateActionRequest, applySecurityHeaders, signFragmentId, validateSignedFragment, validateQueryOverride, parseQueryArgs, CSRF_DEFAULTS, } from './security.js';
|
|
8
9
|
export { extractSubdomainSlug, extractSlugFromPrefix, matchContainerViewPath, rewriteProxyLocationHeader, buildContainerRequest, createContainerEnvVars, startContainer, proxyToContainer, handleContainerRouting, forwardJsonPostToContainerDO,
|
|
9
10
|
// Compatibility aliases
|
|
10
11
|
matchSiteViewPath, buildSiteContainerRequest, createWpContainerEnvVars, startSiteContainer, proxyToSiteContainer, } from './containers.js';
|
package/dist/runtime/router.d.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
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
|
*/
|
|
9
11
|
export interface MatchResult {
|
|
10
12
|
params: Record<string, string>;
|
|
@@ -12,11 +14,13 @@ export interface MatchResult {
|
|
|
12
14
|
}
|
|
13
15
|
export declare class Router {
|
|
14
16
|
private staticRoutes;
|
|
15
|
-
private
|
|
17
|
+
private root;
|
|
16
18
|
/** Register a pattern (e.g. '/blog/:slug') and associate it with an index. */
|
|
17
19
|
add(pattern: string, index: number): void;
|
|
18
20
|
/** Match a pathname against registered routes. Returns null if no match. */
|
|
19
21
|
match(pathname: string): MatchResult | null;
|
|
22
|
+
/** Recursive radix tree matching with backtracking */
|
|
23
|
+
private matchNode;
|
|
20
24
|
}
|
|
21
25
|
/**
|
|
22
26
|
* Convert a file-system path to a route pattern.
|
package/dist/runtime/router.js
CHANGED
|
@@ -5,56 +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
20
|
staticRoutes = new Map();
|
|
11
|
-
|
|
21
|
+
root = createNode('', 0 /* NodeType.Static */);
|
|
12
22
|
/** Register a pattern (e.g. '/blog/:slug') and associate it with an index. */
|
|
13
23
|
add(pattern, index) {
|
|
14
24
|
if (!pattern.includes(':') && !pattern.includes('*')) {
|
|
15
25
|
this.staticRoutes.set(pattern, index);
|
|
16
26
|
return;
|
|
17
27
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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;
|
|
40
80
|
}
|
|
41
81
|
/** Match a pathname against registered routes. Returns null if no match. */
|
|
42
82
|
match(pathname) {
|
|
43
83
|
// Normalize: strip trailing slash (except root)
|
|
44
84
|
const normalized = pathname === '/' ? '/' : pathname.replace(/\/$/, '');
|
|
85
|
+
// Fast path: static routes (O(1))
|
|
45
86
|
const staticIdx = this.staticRoutes.get(normalized);
|
|
46
87
|
if (staticIdx !== undefined) {
|
|
47
88
|
return { params: {}, index: staticIdx };
|
|
48
89
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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;
|
|
57
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;
|
|
58
143
|
}
|
|
59
144
|
return null;
|
|
60
145
|
}
|