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.
Files changed (65) hide show
  1. package/README.md +11 -0
  2. package/cli/build.js +13 -3
  3. package/compiler/directives.js +356 -0
  4. package/compiler/lexer.js +18 -3
  5. package/compiler/parser/core.js +6 -0
  6. package/compiler/parser/view.js +2 -6
  7. package/compiler/preprocessor.js +43 -23
  8. package/compiler/sourcemap.js +3 -1
  9. package/compiler/transformer/actions.js +329 -0
  10. package/compiler/transformer/export.js +7 -0
  11. package/compiler/transformer/expressions.js +85 -33
  12. package/compiler/transformer/imports.js +3 -0
  13. package/compiler/transformer/index.js +2 -0
  14. package/compiler/transformer/store.js +1 -1
  15. package/compiler/transformer/style.js +45 -16
  16. package/compiler/transformer/view.js +23 -2
  17. package/loader/rollup-plugin-server-components.js +391 -0
  18. package/loader/vite-plugin-server-components.js +420 -0
  19. package/loader/webpack-loader-server-components.js +356 -0
  20. package/package.json +124 -82
  21. package/runtime/async.js +4 -0
  22. package/runtime/context.js +16 -3
  23. package/runtime/dom-adapter.js +5 -3
  24. package/runtime/dom-virtual-list.js +2 -1
  25. package/runtime/form.js +8 -3
  26. package/runtime/graphql/cache.js +1 -1
  27. package/runtime/graphql/client.js +22 -0
  28. package/runtime/graphql/hooks.js +12 -6
  29. package/runtime/graphql/subscriptions.js +2 -0
  30. package/runtime/hmr.js +6 -3
  31. package/runtime/http.js +1 -0
  32. package/runtime/i18n.js +2 -0
  33. package/runtime/lru-cache.js +3 -1
  34. package/runtime/native.js +46 -20
  35. package/runtime/pulse.js +3 -0
  36. package/runtime/router/core.js +5 -1
  37. package/runtime/router/index.js +17 -1
  38. package/runtime/router/psc-integration.js +301 -0
  39. package/runtime/security.js +58 -29
  40. package/runtime/server-components/actions-server.js +798 -0
  41. package/runtime/server-components/actions.js +389 -0
  42. package/runtime/server-components/client.js +447 -0
  43. package/runtime/server-components/error-sanitizer.js +438 -0
  44. package/runtime/server-components/index.js +275 -0
  45. package/runtime/server-components/security-csrf.js +593 -0
  46. package/runtime/server-components/security-errors.js +227 -0
  47. package/runtime/server-components/security-ratelimit.js +733 -0
  48. package/runtime/server-components/security-validation.js +467 -0
  49. package/runtime/server-components/security.js +598 -0
  50. package/runtime/server-components/serializer.js +617 -0
  51. package/runtime/server-components/server.js +382 -0
  52. package/runtime/server-components/types.js +383 -0
  53. package/runtime/server-components/utils/mutex.js +60 -0
  54. package/runtime/server-components/utils/path-sanitizer.js +109 -0
  55. package/runtime/ssr.js +2 -1
  56. package/runtime/store.js +19 -10
  57. package/runtime/utils.js +12 -128
  58. package/types/animation.d.ts +300 -0
  59. package/types/i18n.d.ts +283 -0
  60. package/types/persistence.d.ts +267 -0
  61. package/types/sse.d.ts +248 -0
  62. package/types/sw.d.ts +150 -0
  63. package/runtime/a11y.js.original +0 -1844
  64. package/runtime/graphql.js.original +0 -1326
  65. 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
+ };