pulse-js-framework 1.10.4 → 1.11.1
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 +11 -0
- package/cli/build.js +13 -3
- package/compiler/directives.js +356 -0
- package/compiler/lexer.js +18 -3
- package/compiler/parser/core.js +6 -0
- package/compiler/parser/view.js +2 -6
- package/compiler/preprocessor.js +43 -23
- package/compiler/sourcemap.js +3 -1
- package/compiler/transformer/actions.js +329 -0
- package/compiler/transformer/export.js +7 -0
- package/compiler/transformer/expressions.js +85 -33
- package/compiler/transformer/imports.js +3 -0
- package/compiler/transformer/index.js +2 -0
- package/compiler/transformer/store.js +1 -1
- package/compiler/transformer/style.js +45 -16
- package/compiler/transformer/view.js +23 -2
- package/loader/rollup-plugin-server-components.js +391 -0
- package/loader/vite-plugin-server-components.js +420 -0
- package/loader/webpack-loader-server-components.js +356 -0
- package/package.json +124 -82
- package/runtime/async.js +4 -0
- package/runtime/context.js +16 -3
- package/runtime/dom-adapter.js +5 -3
- package/runtime/dom-virtual-list.js +2 -1
- package/runtime/form.js +8 -3
- package/runtime/graphql/cache.js +1 -1
- package/runtime/graphql/client.js +22 -0
- package/runtime/graphql/hooks.js +12 -6
- package/runtime/graphql/subscriptions.js +2 -0
- package/runtime/hmr.js +6 -3
- package/runtime/http.js +1 -0
- package/runtime/i18n.js +2 -0
- package/runtime/lru-cache.js +3 -1
- package/runtime/native.js +46 -20
- package/runtime/pulse.js +3 -0
- package/runtime/router/core.js +5 -1
- package/runtime/router/index.js +17 -1
- package/runtime/router/psc-integration.js +301 -0
- package/runtime/security.js +58 -29
- package/runtime/server-components/actions-server.js +798 -0
- package/runtime/server-components/actions.js +389 -0
- package/runtime/server-components/client.js +447 -0
- package/runtime/server-components/error-sanitizer.js +438 -0
- package/runtime/server-components/index.js +275 -0
- package/runtime/server-components/security-csrf.js +593 -0
- package/runtime/server-components/security-errors.js +227 -0
- package/runtime/server-components/security-ratelimit.js +733 -0
- package/runtime/server-components/security-validation.js +467 -0
- package/runtime/server-components/security.js +598 -0
- package/runtime/server-components/serializer.js +617 -0
- package/runtime/server-components/server.js +382 -0
- package/runtime/server-components/types.js +383 -0
- package/runtime/server-components/utils/mutex.js +60 -0
- package/runtime/server-components/utils/path-sanitizer.js +109 -0
- package/runtime/ssr.js +2 -1
- package/runtime/store.js +19 -10
- package/runtime/utils.js +12 -128
- package/types/animation.d.ts +300 -0
- package/types/i18n.d.ts +283 -0
- package/types/persistence.d.ts +267 -0
- package/types/sse.d.ts +248 -0
- package/types/sw.d.ts +150 -0
- package/runtime/a11y.js.original +0 -1844
- package/runtime/graphql.js.original +0 -1326
- package/runtime/router.js.original +0 -1605
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server Actions - Server Runtime
|
|
3
|
+
*
|
|
4
|
+
* Server-side execution and registry for Server Actions.
|
|
5
|
+
* Provides middleware for Express/Fastify/Hono and secure action execution.
|
|
6
|
+
*
|
|
7
|
+
* @module pulse-js-framework/runtime/server-components/actions-server
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { sanitizeError, isProductionMode } from './error-sanitizer.js';
|
|
11
|
+
import { CSRFTokenStore } from './security-csrf.js';
|
|
12
|
+
import { createRateLimitMiddleware } from './security-ratelimit.js';
|
|
13
|
+
|
|
14
|
+
// ============================================================
|
|
15
|
+
// Server Action Registry
|
|
16
|
+
// ============================================================
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Server Action registry (server-side)
|
|
20
|
+
* @type {Map<string, Function>}
|
|
21
|
+
*/
|
|
22
|
+
const serverActions = new Map(); // actionId → handler function
|
|
23
|
+
|
|
24
|
+
// ============================================================
|
|
25
|
+
// Global CSRF Token Store
|
|
26
|
+
// ============================================================
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Global CSRF token store (shared across all middleware instances)
|
|
30
|
+
* Can be replaced with custom store for multi-server deployments
|
|
31
|
+
* @type {CSRFTokenStore}
|
|
32
|
+
*/
|
|
33
|
+
let globalCSRFStore = null;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get or create global CSRF token store
|
|
37
|
+
* @param {Object} [options] - Store options
|
|
38
|
+
* @returns {CSRFTokenStore} CSRF token store
|
|
39
|
+
*/
|
|
40
|
+
function getCSRFStore(options = {}) {
|
|
41
|
+
if (!globalCSRFStore) {
|
|
42
|
+
globalCSRFStore = new CSRFTokenStore({
|
|
43
|
+
secret: options.csrfSecret || process.env.CSRF_SECRET,
|
|
44
|
+
expiresIn: options.tokenExpiry || 3600000,
|
|
45
|
+
cleanupInterval: 600000 // 10 minutes
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return globalCSRFStore;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Set custom CSRF token store (for testing or custom implementations)
|
|
53
|
+
* @param {CSRFTokenStore|null} store - Custom store or null to reset
|
|
54
|
+
*/
|
|
55
|
+
export function setCSRFStore(store) {
|
|
56
|
+
// Dispose old store
|
|
57
|
+
if (globalCSRFStore && globalCSRFStore.dispose) {
|
|
58
|
+
globalCSRFStore.dispose();
|
|
59
|
+
}
|
|
60
|
+
globalCSRFStore = store;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Register a Server Action handler
|
|
65
|
+
* @param {string} actionId - Unique action identifier
|
|
66
|
+
* @param {Function} handler - Async handler function
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* registerServerAction('UserForm$createUser', async (data, context) => {
|
|
70
|
+
* const user = await db.users.create(data);
|
|
71
|
+
* return { id: user.id, name: user.name };
|
|
72
|
+
* });
|
|
73
|
+
*/
|
|
74
|
+
export function registerServerAction(actionId, handler) {
|
|
75
|
+
if (typeof handler !== 'function') {
|
|
76
|
+
throw new Error(`Server Action handler must be a function: ${actionId}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
serverActions.set(actionId, handler);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get registered action handler
|
|
84
|
+
* @param {string} actionId - Action identifier
|
|
85
|
+
* @returns {Function|null} Handler function or null
|
|
86
|
+
*/
|
|
87
|
+
export function getServerAction(actionId) {
|
|
88
|
+
return serverActions.get(actionId) || null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get all registered Server Actions
|
|
93
|
+
* @returns {Map<string, Function>} Action registry
|
|
94
|
+
*/
|
|
95
|
+
export function getServerActions() {
|
|
96
|
+
return new Map(serverActions);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Clear all Server Actions (for testing)
|
|
101
|
+
*/
|
|
102
|
+
export function clearServerActions() {
|
|
103
|
+
serverActions.clear();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ============================================================
|
|
107
|
+
// Action Execution
|
|
108
|
+
// ============================================================
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Check if value contains non-serializable data (functions, symbols, etc.)
|
|
112
|
+
* @param {any} value - Value to check
|
|
113
|
+
* @returns {boolean} True if contains non-serializable data
|
|
114
|
+
*/
|
|
115
|
+
function hasNonSerializableData(value) {
|
|
116
|
+
if (typeof value === 'function' || typeof value === 'symbol') {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (Array.isArray(value)) {
|
|
121
|
+
return value.some(item => hasNonSerializableData(item));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (value !== null && typeof value === 'object') {
|
|
125
|
+
return Object.values(value).some(v => hasNonSerializableData(v));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Execute a Server Action
|
|
133
|
+
* @param {string} actionId - Action identifier
|
|
134
|
+
* @param {Array} args - Action arguments
|
|
135
|
+
* @param {Object} context - Execution context (request, session, etc.)
|
|
136
|
+
* @returns {Promise<any>} Action result
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* const result = await executeServerAction('createUser', [{ name: 'John' }], {
|
|
140
|
+
* req, res, session, user
|
|
141
|
+
* });
|
|
142
|
+
*/
|
|
143
|
+
export async function executeServerAction(actionId, args, context = {}) {
|
|
144
|
+
const handler = serverActions.get(actionId);
|
|
145
|
+
|
|
146
|
+
if (!handler) {
|
|
147
|
+
throw new Error(`Server Action not registered: ${actionId}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Validate args are JSON-serializable (no functions, symbols, etc.)
|
|
151
|
+
if (hasNonSerializableData(args)) {
|
|
152
|
+
throw new Error('Server Action arguments must be JSON-serializable');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Execute with timeout
|
|
156
|
+
const timeout = context.timeout || 30000;
|
|
157
|
+
let timeoutId;
|
|
158
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
159
|
+
timeoutId = setTimeout(() => reject(new Error('Server Action timeout')), timeout);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const result = await Promise.race([
|
|
164
|
+
handler(...args, context),
|
|
165
|
+
timeoutPromise
|
|
166
|
+
]);
|
|
167
|
+
clearTimeout(timeoutId); // Clean up timeout
|
|
168
|
+
|
|
169
|
+
// Validate result is JSON-serializable (no functions, symbols, etc.)
|
|
170
|
+
if (hasNonSerializableData(result)) {
|
|
171
|
+
throw new Error('Server Action result must be JSON-serializable');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return result;
|
|
175
|
+
} catch (error) {
|
|
176
|
+
clearTimeout(timeoutId); // Clean up timeout on error
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ============================================================
|
|
182
|
+
// Express Middleware
|
|
183
|
+
// ============================================================
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Create Server Action middleware for Express
|
|
187
|
+
* @param {Object} options - Middleware options
|
|
188
|
+
* @param {boolean} [options.csrfValidation=true] - Enable CSRF validation
|
|
189
|
+
* @param {string} [options.csrfSecret] - CSRF secret key
|
|
190
|
+
* @param {CSRFTokenStore} [options.csrfStore] - Custom CSRF token store
|
|
191
|
+
* @param {number} [options.tokenExpiry=3600000] - Token expiration in ms (default: 1 hour)
|
|
192
|
+
* @param {boolean} [options.rotateOnUse=false] - Rotate token after validation
|
|
193
|
+
* @param {string} [options.endpoint='/_actions'] - Actions endpoint
|
|
194
|
+
* @param {Function} [options.onError] - Custom error handler
|
|
195
|
+
* @param {Object} [options.rateLimitPerAction] - Per-action rate limits
|
|
196
|
+
* @param {Object} [options.rateLimitPerUser] - Per-user rate limits
|
|
197
|
+
* @param {Object} [options.rateLimitGlobal] - Global rate limits
|
|
198
|
+
* @param {RateLimitStore} [options.rateLimitStore] - Rate limit storage backend
|
|
199
|
+
* @param {Function} [options.rateLimitIdentify] - User identifier function
|
|
200
|
+
* @param {string[]} [options.rateLimitTrustedIPs] - Bypass rate limits for these IPs
|
|
201
|
+
* @returns {Function} Express middleware
|
|
202
|
+
*
|
|
203
|
+
* @example
|
|
204
|
+
* import express from 'express';
|
|
205
|
+
* import { createServerActionMiddleware } from 'pulse-js-framework/runtime/server-components';
|
|
206
|
+
*
|
|
207
|
+
* const app = express();
|
|
208
|
+
* app.use(express.json());
|
|
209
|
+
* app.use(createServerActionMiddleware({
|
|
210
|
+
* csrfValidation: true,
|
|
211
|
+
* csrfSecret: process.env.CSRF_SECRET,
|
|
212
|
+
* rotateOnUse: false,
|
|
213
|
+
* rateLimitPerAction: {
|
|
214
|
+
* 'createUser': { maxRequests: 5, windowMs: 60000 },
|
|
215
|
+
* 'default': { maxRequests: 20, windowMs: 60000 }
|
|
216
|
+
* },
|
|
217
|
+
* rateLimitPerUser: { maxRequests: 100, windowMs: 60000 },
|
|
218
|
+
* onError: (error, req, res) => {
|
|
219
|
+
* logger.error('Action failed:', error);
|
|
220
|
+
* res.status(500).json({ error: 'Internal server error' });
|
|
221
|
+
* }
|
|
222
|
+
* }));
|
|
223
|
+
*/
|
|
224
|
+
export function createServerActionMiddleware(options = {}) {
|
|
225
|
+
const {
|
|
226
|
+
csrfValidation = true,
|
|
227
|
+
csrfSecret = null,
|
|
228
|
+
csrfStore = null,
|
|
229
|
+
tokenExpiry = 3600000,
|
|
230
|
+
rotateOnUse = false,
|
|
231
|
+
endpoint = '/_actions',
|
|
232
|
+
onError = null,
|
|
233
|
+
rateLimitPerAction = null,
|
|
234
|
+
rateLimitPerUser = null,
|
|
235
|
+
rateLimitGlobal = null,
|
|
236
|
+
rateLimitStore = null,
|
|
237
|
+
rateLimitIdentify = null,
|
|
238
|
+
rateLimitTrustedIPs = null
|
|
239
|
+
} = options;
|
|
240
|
+
|
|
241
|
+
// Use provided store or get/create global store
|
|
242
|
+
const store = csrfStore || (csrfValidation ? getCSRFStore({ csrfSecret, tokenExpiry }) : null);
|
|
243
|
+
|
|
244
|
+
// Create rate limit middleware if any rate limits configured
|
|
245
|
+
const rateLimitMiddleware = (rateLimitPerAction || rateLimitPerUser || rateLimitGlobal)
|
|
246
|
+
? createRateLimitMiddleware({
|
|
247
|
+
perAction: rateLimitPerAction,
|
|
248
|
+
perUser: rateLimitPerUser,
|
|
249
|
+
global: rateLimitGlobal,
|
|
250
|
+
store: rateLimitStore,
|
|
251
|
+
identify: rateLimitIdentify,
|
|
252
|
+
trustedIPs: rateLimitTrustedIPs
|
|
253
|
+
})
|
|
254
|
+
: null;
|
|
255
|
+
|
|
256
|
+
return async (req, res, next) => {
|
|
257
|
+
// Only handle POST requests to actions endpoint
|
|
258
|
+
if (req.method !== 'POST' || !req.path.startsWith(endpoint)) {
|
|
259
|
+
return next();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
// Enhanced CSRF validation with cryptographic tokens
|
|
264
|
+
if (csrfValidation && store) {
|
|
265
|
+
const token = req.headers['x-csrf-token'];
|
|
266
|
+
|
|
267
|
+
// Validate token using HMAC-based store
|
|
268
|
+
const validation = await store.validate(token, {
|
|
269
|
+
expiresIn: tokenExpiry,
|
|
270
|
+
rotateOnUse
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
if (!validation.valid) {
|
|
274
|
+
return res.status(403).json({
|
|
275
|
+
error: 'CSRF validation failed',
|
|
276
|
+
reason: validation.reason,
|
|
277
|
+
code: 'PSC_CSRF_INVALID'
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Rotate token if configured
|
|
282
|
+
if (rotateOnUse) {
|
|
283
|
+
const newToken = await store.generate({ expiresIn: tokenExpiry });
|
|
284
|
+
res.setHeader('X-New-CSRF-Token', newToken);
|
|
285
|
+
|
|
286
|
+
// Update cookie for double-submit pattern
|
|
287
|
+
res.cookie('csrf-token', newToken, {
|
|
288
|
+
httpOnly: false, // Client needs to read it
|
|
289
|
+
secure: process.env.NODE_ENV === 'production',
|
|
290
|
+
sameSite: 'strict',
|
|
291
|
+
maxAge: tokenExpiry
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Extract action ID and args
|
|
297
|
+
const actionId = req.headers['x-pulse-action'];
|
|
298
|
+
const { args } = req.body;
|
|
299
|
+
|
|
300
|
+
if (!actionId) {
|
|
301
|
+
return res.status(400).json({ error: 'Missing action ID' });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Rate limiting check
|
|
305
|
+
if (rateLimitMiddleware) {
|
|
306
|
+
const rateLimitResult = await rateLimitMiddleware({
|
|
307
|
+
actionId,
|
|
308
|
+
ip: req.ip || req.headers['x-forwarded-for'] || req.socket.remoteAddress,
|
|
309
|
+
userId: req.user?.id,
|
|
310
|
+
headers: req.headers
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
if (!rateLimitResult.allowed) {
|
|
314
|
+
// Set rate limit headers
|
|
315
|
+
res.setHeader('Retry-After', Math.ceil(rateLimitResult.retryAfter / 1000));
|
|
316
|
+
if (rateLimitResult.limit) {
|
|
317
|
+
res.setHeader('X-RateLimit-Limit', rateLimitResult.limit);
|
|
318
|
+
}
|
|
319
|
+
if (rateLimitResult.remaining !== undefined) {
|
|
320
|
+
res.setHeader('X-RateLimit-Remaining', rateLimitResult.remaining);
|
|
321
|
+
}
|
|
322
|
+
if (rateLimitResult.resetAt) {
|
|
323
|
+
res.setHeader('X-RateLimit-Reset', new Date(rateLimitResult.resetAt).toISOString());
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return res.status(429).json({
|
|
327
|
+
error: 'Too Many Requests',
|
|
328
|
+
reason: rateLimitResult.reason,
|
|
329
|
+
retryAfter: rateLimitResult.retryAfter,
|
|
330
|
+
code: 'PSC_RATE_LIMIT_EXCEEDED'
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Set rate limit info headers for successful requests
|
|
335
|
+
if (rateLimitResult.limit) {
|
|
336
|
+
res.setHeader('X-RateLimit-Limit', rateLimitResult.limit);
|
|
337
|
+
}
|
|
338
|
+
if (rateLimitResult.remaining !== undefined) {
|
|
339
|
+
res.setHeader('X-RateLimit-Remaining', rateLimitResult.remaining);
|
|
340
|
+
}
|
|
341
|
+
if (rateLimitResult.resetAt) {
|
|
342
|
+
res.setHeader('X-RateLimit-Reset', new Date(rateLimitResult.resetAt).toISOString());
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Execute action
|
|
347
|
+
const context = {
|
|
348
|
+
req,
|
|
349
|
+
res,
|
|
350
|
+
session: req.session,
|
|
351
|
+
user: req.user
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const result = await executeServerAction(actionId, args, context);
|
|
355
|
+
|
|
356
|
+
// Send result
|
|
357
|
+
res.json(result);
|
|
358
|
+
} catch (error) {
|
|
359
|
+
if (onError) {
|
|
360
|
+
// Custom error handler - still sanitize before passing
|
|
361
|
+
const sanitized = sanitizeError(error, {
|
|
362
|
+
includeStack: !isProductionMode(),
|
|
363
|
+
maxStackLines: 5
|
|
364
|
+
});
|
|
365
|
+
onError(sanitized, req, res);
|
|
366
|
+
} else {
|
|
367
|
+
// Default error handler - return sanitized error with backward-compatible format
|
|
368
|
+
const sanitized = sanitizeError(error, {
|
|
369
|
+
includeStack: !isProductionMode(),
|
|
370
|
+
maxStackLines: 5
|
|
371
|
+
});
|
|
372
|
+
// Include both 'error' (backward compat) and 'message' fields
|
|
373
|
+
res.status(500).json({
|
|
374
|
+
...sanitized,
|
|
375
|
+
error: sanitized.message // Backward compatibility
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ============================================================
|
|
383
|
+
// Fastify Plugin
|
|
384
|
+
// ============================================================
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Create Server Action plugin for Fastify
|
|
388
|
+
* @param {Object} fastify - Fastify instance
|
|
389
|
+
* @param {Object} options - Plugin options
|
|
390
|
+
* @param {boolean} [options.csrfValidation=true] - Enable CSRF validation
|
|
391
|
+
* @param {string} [options.csrfSecret] - CSRF secret key
|
|
392
|
+
* @param {CSRFTokenStore} [options.csrfStore] - Custom CSRF token store
|
|
393
|
+
* @param {number} [options.tokenExpiry=3600000] - Token expiration in ms
|
|
394
|
+
* @param {boolean} [options.rotateOnUse=false] - Rotate token after validation
|
|
395
|
+
* @param {string} [options.endpoint='/_actions'] - Actions endpoint
|
|
396
|
+
* @param {Object} [options.rateLimitPerAction] - Per-action rate limits
|
|
397
|
+
* @param {Object} [options.rateLimitPerUser] - Per-user rate limits
|
|
398
|
+
* @param {Object} [options.rateLimitGlobal] - Global rate limits
|
|
399
|
+
* @param {import('./security-ratelimit.js').RateLimitStore} [options.rateLimitStore] - Rate limit storage backend
|
|
400
|
+
* @param {Function} [options.rateLimitIdentify] - User identifier function
|
|
401
|
+
* @param {string[]} [options.rateLimitTrustedIPs] - Bypass rate limits for these IPs
|
|
402
|
+
*
|
|
403
|
+
* @example
|
|
404
|
+
* import Fastify from 'fastify';
|
|
405
|
+
* import { createFastifyActionPlugin } from 'pulse-js-framework/runtime/server-components';
|
|
406
|
+
*
|
|
407
|
+
* const fastify = Fastify();
|
|
408
|
+
* fastify.register(createFastifyActionPlugin, {
|
|
409
|
+
* csrfValidation: true,
|
|
410
|
+
* csrfSecret: process.env.CSRF_SECRET,
|
|
411
|
+
* rotateOnUse: false,
|
|
412
|
+
* rateLimitPerAction: {
|
|
413
|
+
* 'createUser': { maxRequests: 5, windowMs: 60000 }
|
|
414
|
+
* }
|
|
415
|
+
* });
|
|
416
|
+
*/
|
|
417
|
+
export async function createFastifyActionPlugin(fastify, options = {}) {
|
|
418
|
+
const {
|
|
419
|
+
csrfValidation = true,
|
|
420
|
+
csrfSecret = null,
|
|
421
|
+
csrfStore = null,
|
|
422
|
+
tokenExpiry = 3600000,
|
|
423
|
+
rotateOnUse = false,
|
|
424
|
+
endpoint = '/_actions',
|
|
425
|
+
rateLimitPerAction = null,
|
|
426
|
+
rateLimitPerUser = null,
|
|
427
|
+
rateLimitGlobal = null,
|
|
428
|
+
rateLimitStore = null,
|
|
429
|
+
rateLimitIdentify = null,
|
|
430
|
+
rateLimitTrustedIPs = null
|
|
431
|
+
} = options;
|
|
432
|
+
|
|
433
|
+
// Use provided store or get/create global store
|
|
434
|
+
const store = csrfStore || (csrfValidation ? getCSRFStore({ csrfSecret, tokenExpiry }) : null);
|
|
435
|
+
|
|
436
|
+
// Create rate limit middleware if any rate limits configured
|
|
437
|
+
const rateLimitMiddleware = (rateLimitPerAction || rateLimitPerUser || rateLimitGlobal)
|
|
438
|
+
? createRateLimitMiddleware({
|
|
439
|
+
perAction: rateLimitPerAction,
|
|
440
|
+
perUser: rateLimitPerUser,
|
|
441
|
+
global: rateLimitGlobal,
|
|
442
|
+
store: rateLimitStore,
|
|
443
|
+
identify: rateLimitIdentify,
|
|
444
|
+
trustedIPs: rateLimitTrustedIPs
|
|
445
|
+
})
|
|
446
|
+
: null;
|
|
447
|
+
|
|
448
|
+
fastify.post(endpoint, async (request, reply) => {
|
|
449
|
+
try {
|
|
450
|
+
// Enhanced CSRF validation
|
|
451
|
+
if (csrfValidation && store) {
|
|
452
|
+
const token = request.headers['x-csrf-token'];
|
|
453
|
+
|
|
454
|
+
const validation = await store.validate(token, {
|
|
455
|
+
expiresIn: tokenExpiry,
|
|
456
|
+
rotateOnUse
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
if (!validation.valid) {
|
|
460
|
+
return reply.code(403).send({
|
|
461
|
+
error: 'CSRF validation failed',
|
|
462
|
+
reason: validation.reason,
|
|
463
|
+
code: 'PSC_CSRF_INVALID'
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Rotate token if configured
|
|
468
|
+
if (rotateOnUse) {
|
|
469
|
+
const newToken = await store.generate({ expiresIn: tokenExpiry });
|
|
470
|
+
reply.header('X-New-CSRF-Token', newToken);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Extract action ID and args
|
|
475
|
+
const actionId = request.headers['x-pulse-action'];
|
|
476
|
+
const { args } = request.body;
|
|
477
|
+
|
|
478
|
+
if (!actionId) {
|
|
479
|
+
return reply.code(400).send({ error: 'Missing action ID' });
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Rate limiting check
|
|
483
|
+
if (rateLimitMiddleware) {
|
|
484
|
+
const rateLimitResult = await rateLimitMiddleware({
|
|
485
|
+
actionId,
|
|
486
|
+
ip: request.ip,
|
|
487
|
+
userId: request.user?.id,
|
|
488
|
+
headers: request.headers
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
if (!rateLimitResult.allowed) {
|
|
492
|
+
// Set rate limit headers
|
|
493
|
+
reply.header('Retry-After', Math.ceil(rateLimitResult.retryAfter / 1000));
|
|
494
|
+
if (rateLimitResult.limit) {
|
|
495
|
+
reply.header('X-RateLimit-Limit', rateLimitResult.limit);
|
|
496
|
+
}
|
|
497
|
+
if (rateLimitResult.remaining !== undefined) {
|
|
498
|
+
reply.header('X-RateLimit-Remaining', rateLimitResult.remaining);
|
|
499
|
+
}
|
|
500
|
+
if (rateLimitResult.resetAt) {
|
|
501
|
+
reply.header('X-RateLimit-Reset', new Date(rateLimitResult.resetAt).toISOString());
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return reply.code(429).send({
|
|
505
|
+
error: 'Too Many Requests',
|
|
506
|
+
reason: rateLimitResult.reason,
|
|
507
|
+
retryAfter: rateLimitResult.retryAfter,
|
|
508
|
+
code: 'PSC_RATE_LIMIT_EXCEEDED'
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Set rate limit info headers for successful requests
|
|
513
|
+
if (rateLimitResult.limit) {
|
|
514
|
+
reply.header('X-RateLimit-Limit', rateLimitResult.limit);
|
|
515
|
+
}
|
|
516
|
+
if (rateLimitResult.remaining !== undefined) {
|
|
517
|
+
reply.header('X-RateLimit-Remaining', rateLimitResult.remaining);
|
|
518
|
+
}
|
|
519
|
+
if (rateLimitResult.resetAt) {
|
|
520
|
+
reply.header('X-RateLimit-Reset', new Date(rateLimitResult.resetAt).toISOString());
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Execute action
|
|
525
|
+
const context = {
|
|
526
|
+
request,
|
|
527
|
+
reply,
|
|
528
|
+
session: request.session,
|
|
529
|
+
user: request.user
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
const result = await executeServerAction(actionId, args, context);
|
|
533
|
+
|
|
534
|
+
return result;
|
|
535
|
+
} catch (error) {
|
|
536
|
+
// Sanitize error before sending to client
|
|
537
|
+
const sanitized = sanitizeError(error, {
|
|
538
|
+
includeStack: !isProductionMode(),
|
|
539
|
+
maxStackLines: 5
|
|
540
|
+
});
|
|
541
|
+
return reply.code(500).send(sanitized);
|
|
542
|
+
}
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ============================================================
|
|
547
|
+
// Hono Middleware
|
|
548
|
+
// ============================================================
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Create Server Action middleware for Hono
|
|
552
|
+
* @param {Object} options - Middleware options
|
|
553
|
+
* @param {boolean} [options.csrfValidation=true] - Enable CSRF validation
|
|
554
|
+
* @param {string} [options.csrfSecret] - CSRF secret key
|
|
555
|
+
* @param {CSRFTokenStore} [options.csrfStore] - Custom CSRF token store
|
|
556
|
+
* @param {number} [options.tokenExpiry=3600000] - Token expiration in ms
|
|
557
|
+
* @param {boolean} [options.rotateOnUse=false] - Rotate token after validation
|
|
558
|
+
* @param {string} [options.endpoint='/_actions'] - Actions endpoint
|
|
559
|
+
* @param {Object} [options.rateLimitPerAction] - Per-action rate limits
|
|
560
|
+
* @param {Object} [options.rateLimitPerUser] - Per-user rate limits
|
|
561
|
+
* @param {Object} [options.rateLimitGlobal] - Global rate limits
|
|
562
|
+
* @param {import('./security-ratelimit.js').RateLimitStore} [options.rateLimitStore] - Rate limit storage backend
|
|
563
|
+
* @param {Function} [options.rateLimitIdentify] - User identifier function
|
|
564
|
+
* @param {string[]} [options.rateLimitTrustedIPs] - Bypass rate limits for these IPs
|
|
565
|
+
* @returns {Function} Hono middleware
|
|
566
|
+
*
|
|
567
|
+
* @example
|
|
568
|
+
* import { Hono } from 'hono';
|
|
569
|
+
* import { createHonoActionMiddleware } from 'pulse-js-framework/runtime/server-components';
|
|
570
|
+
*
|
|
571
|
+
* const app = new Hono();
|
|
572
|
+
* app.use('/_actions', createHonoActionMiddleware({
|
|
573
|
+
* csrfValidation: true,
|
|
574
|
+
* csrfSecret: process.env.CSRF_SECRET,
|
|
575
|
+
* rateLimitPerUser: { maxRequests: 100, windowMs: 60000 }
|
|
576
|
+
* }));
|
|
577
|
+
*/
|
|
578
|
+
export function createHonoActionMiddleware(options = {}) {
|
|
579
|
+
const {
|
|
580
|
+
csrfValidation = true,
|
|
581
|
+
csrfSecret = null,
|
|
582
|
+
csrfStore = null,
|
|
583
|
+
tokenExpiry = 3600000,
|
|
584
|
+
rotateOnUse = false,
|
|
585
|
+
endpoint = '/_actions',
|
|
586
|
+
rateLimitPerAction = null,
|
|
587
|
+
rateLimitPerUser = null,
|
|
588
|
+
rateLimitGlobal = null,
|
|
589
|
+
rateLimitStore = null,
|
|
590
|
+
rateLimitIdentify = null,
|
|
591
|
+
rateLimitTrustedIPs = null
|
|
592
|
+
} = options;
|
|
593
|
+
|
|
594
|
+
// Use provided store or get/create global store
|
|
595
|
+
const store = csrfStore || (csrfValidation ? getCSRFStore({ csrfSecret, tokenExpiry }) : null);
|
|
596
|
+
|
|
597
|
+
// Create rate limit middleware if any rate limits configured
|
|
598
|
+
const rateLimitMiddleware = (rateLimitPerAction || rateLimitPerUser || rateLimitGlobal)
|
|
599
|
+
? createRateLimitMiddleware({
|
|
600
|
+
perAction: rateLimitPerAction,
|
|
601
|
+
perUser: rateLimitPerUser,
|
|
602
|
+
global: rateLimitGlobal,
|
|
603
|
+
store: rateLimitStore,
|
|
604
|
+
identify: rateLimitIdentify,
|
|
605
|
+
trustedIPs: rateLimitTrustedIPs
|
|
606
|
+
})
|
|
607
|
+
: null;
|
|
608
|
+
|
|
609
|
+
return async (c, next) => {
|
|
610
|
+
if (c.req.method !== 'POST' || !c.req.path.startsWith(endpoint)) {
|
|
611
|
+
return next();
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
try {
|
|
615
|
+
// Enhanced CSRF validation
|
|
616
|
+
if (csrfValidation && store) {
|
|
617
|
+
const token = c.req.header('x-csrf-token');
|
|
618
|
+
|
|
619
|
+
const validation = await store.validate(token, {
|
|
620
|
+
expiresIn: tokenExpiry,
|
|
621
|
+
rotateOnUse
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
if (!validation.valid) {
|
|
625
|
+
return c.json({
|
|
626
|
+
error: 'CSRF validation failed',
|
|
627
|
+
reason: validation.reason,
|
|
628
|
+
code: 'PSC_CSRF_INVALID'
|
|
629
|
+
}, 403);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// Rotate token if configured
|
|
633
|
+
if (rotateOnUse) {
|
|
634
|
+
const newToken = await store.generate({ expiresIn: tokenExpiry });
|
|
635
|
+
c.header('X-New-CSRF-Token', newToken);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Extract action ID and args
|
|
640
|
+
const actionId = c.req.header('x-pulse-action');
|
|
641
|
+
const { args } = await c.req.json();
|
|
642
|
+
|
|
643
|
+
if (!actionId) {
|
|
644
|
+
return c.json({ error: 'Missing action ID' }, 400);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Rate limiting check
|
|
648
|
+
if (rateLimitMiddleware) {
|
|
649
|
+
const rateLimitResult = await rateLimitMiddleware({
|
|
650
|
+
actionId,
|
|
651
|
+
ip: c.req.header('x-forwarded-for') || c.req.header('x-real-ip'),
|
|
652
|
+
userId: c.get('user')?.id,
|
|
653
|
+
headers: c.req.raw.headers
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
if (!rateLimitResult.allowed) {
|
|
657
|
+
// Set rate limit headers
|
|
658
|
+
c.header('Retry-After', Math.ceil(rateLimitResult.retryAfter / 1000).toString());
|
|
659
|
+
if (rateLimitResult.limit) {
|
|
660
|
+
c.header('X-RateLimit-Limit', rateLimitResult.limit.toString());
|
|
661
|
+
}
|
|
662
|
+
if (rateLimitResult.remaining !== undefined) {
|
|
663
|
+
c.header('X-RateLimit-Remaining', rateLimitResult.remaining.toString());
|
|
664
|
+
}
|
|
665
|
+
if (rateLimitResult.resetAt) {
|
|
666
|
+
c.header('X-RateLimit-Reset', new Date(rateLimitResult.resetAt).toISOString());
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return c.json({
|
|
670
|
+
error: 'Too Many Requests',
|
|
671
|
+
reason: rateLimitResult.reason,
|
|
672
|
+
retryAfter: rateLimitResult.retryAfter,
|
|
673
|
+
code: 'PSC_RATE_LIMIT_EXCEEDED'
|
|
674
|
+
}, 429);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Set rate limit info headers for successful requests
|
|
678
|
+
if (rateLimitResult.limit) {
|
|
679
|
+
c.header('X-RateLimit-Limit', rateLimitResult.limit.toString());
|
|
680
|
+
}
|
|
681
|
+
if (rateLimitResult.remaining !== undefined) {
|
|
682
|
+
c.header('X-RateLimit-Remaining', rateLimitResult.remaining.toString());
|
|
683
|
+
}
|
|
684
|
+
if (rateLimitResult.resetAt) {
|
|
685
|
+
c.header('X-RateLimit-Reset', new Date(rateLimitResult.resetAt).toISOString());
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Execute action
|
|
690
|
+
const context = {
|
|
691
|
+
c,
|
|
692
|
+
session: c.get('session'),
|
|
693
|
+
user: c.get('user')
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
const result = await executeServerAction(actionId, args, context);
|
|
697
|
+
|
|
698
|
+
return c.json(result);
|
|
699
|
+
} catch (error) {
|
|
700
|
+
// Sanitize error before sending to client
|
|
701
|
+
const sanitized = sanitizeError(error, {
|
|
702
|
+
includeStack: !isProductionMode(),
|
|
703
|
+
maxStackLines: 5
|
|
704
|
+
});
|
|
705
|
+
return c.json(sanitized, 500);
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// ============================================================
|
|
711
|
+
// CSRF Token Helpers for SSR
|
|
712
|
+
// ============================================================
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Generate CSRF token for response (SSR)
|
|
716
|
+
*
|
|
717
|
+
* Generates a new CSRF token and sets it as a cookie and returns it
|
|
718
|
+
* for inclusion in HTML meta tag.
|
|
719
|
+
*
|
|
720
|
+
* @param {Object} res - Response object (Express/Fastify/etc.)
|
|
721
|
+
* @param {Object} [options] - Token options
|
|
722
|
+
* @param {number} [options.expiresIn=3600000] - Token expiration in ms
|
|
723
|
+
* @param {string} [options.cookieName='csrf-token'] - Cookie name
|
|
724
|
+
* @returns {Promise<string>} CSRF token
|
|
725
|
+
*
|
|
726
|
+
* @example
|
|
727
|
+
* app.get('*', async (req, res) => {
|
|
728
|
+
* const csrfToken = await generateCSRFTokenForResponse(res);
|
|
729
|
+
*
|
|
730
|
+
* const html = `
|
|
731
|
+
* <!DOCTYPE html>
|
|
732
|
+
* <html>
|
|
733
|
+
* <head>
|
|
734
|
+
* <meta name="csrf-token" content="${csrfToken}">
|
|
735
|
+
* </head>
|
|
736
|
+
* <body>...</body>
|
|
737
|
+
* </html>
|
|
738
|
+
* `;
|
|
739
|
+
* res.send(html);
|
|
740
|
+
* });
|
|
741
|
+
*/
|
|
742
|
+
export async function generateCSRFTokenForResponse(res, options = {}) {
|
|
743
|
+
const {
|
|
744
|
+
expiresIn = 3600000,
|
|
745
|
+
cookieName = 'csrf-token'
|
|
746
|
+
} = options;
|
|
747
|
+
|
|
748
|
+
const store = getCSRFStore({ tokenExpiry: expiresIn });
|
|
749
|
+
const token = await store.generate({ expiresIn });
|
|
750
|
+
|
|
751
|
+
// Set cookie (double-submit pattern)
|
|
752
|
+
if (res.cookie) {
|
|
753
|
+
// Express/Fastify
|
|
754
|
+
res.cookie(cookieName, token, {
|
|
755
|
+
httpOnly: false, // Client needs to read it
|
|
756
|
+
secure: process.env.NODE_ENV === 'production',
|
|
757
|
+
sameSite: 'strict',
|
|
758
|
+
maxAge: expiresIn
|
|
759
|
+
});
|
|
760
|
+
} else if (res.setCookie) {
|
|
761
|
+
// Hono
|
|
762
|
+
res.setCookie(cookieName, token, {
|
|
763
|
+
httpOnly: false,
|
|
764
|
+
secure: process.env.NODE_ENV === 'production',
|
|
765
|
+
sameSite: 'Strict',
|
|
766
|
+
maxAge: Math.floor(expiresIn / 1000)
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
return token;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Get CSRF token store (for advanced use cases)
|
|
775
|
+
* @returns {CSRFTokenStore} Global CSRF token store
|
|
776
|
+
*/
|
|
777
|
+
export function getGlobalCSRFStore() {
|
|
778
|
+
return getCSRFStore();
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// ============================================================
|
|
782
|
+
// Exports
|
|
783
|
+
// ============================================================
|
|
784
|
+
|
|
785
|
+
export default {
|
|
786
|
+
registerServerAction,
|
|
787
|
+
getServerAction,
|
|
788
|
+
getServerActions,
|
|
789
|
+
clearServerActions,
|
|
790
|
+
executeServerAction,
|
|
791
|
+
createServerActionMiddleware,
|
|
792
|
+
createFastifyActionPlugin,
|
|
793
|
+
createHonoActionMiddleware,
|
|
794
|
+
// CSRF helpers
|
|
795
|
+
generateCSRFTokenForResponse,
|
|
796
|
+
getGlobalCSRFStore,
|
|
797
|
+
setCSRFStore
|
|
798
|
+
};
|