@nexus_js/server 0.9.28 → 0.9.29

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 (80) hide show
  1. package/dist/actions.d.ts +11 -71
  2. package/dist/actions.d.ts.map +1 -1
  3. package/dist/actions.js +51 -442
  4. package/dist/actions.js.map +1 -1
  5. package/dist/context.d.ts +4 -38
  6. package/dist/context.d.ts.map +1 -1
  7. package/dist/context.js +3 -13
  8. package/dist/context.js.map +1 -1
  9. package/dist/csrf.d.ts +2 -16
  10. package/dist/csrf.d.ts.map +1 -1
  11. package/dist/csrf.js +30 -68
  12. package/dist/csrf.js.map +1 -1
  13. package/dist/dev-assets.d.ts +0 -31
  14. package/dist/dev-assets.d.ts.map +1 -1
  15. package/dist/dev-assets.js +38 -372
  16. package/dist/dev-assets.js.map +1 -1
  17. package/dist/dev-error-html.d.ts.map +1 -1
  18. package/dist/dev-error-html.js +0 -24
  19. package/dist/dev-error-html.js.map +1 -1
  20. package/dist/devradar.d.ts +1 -1
  21. package/dist/devradar.d.ts.map +1 -1
  22. package/dist/devradar.js.map +1 -1
  23. package/dist/index.d.ts +2 -97
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +47 -442
  26. package/dist/index.js.map +1 -1
  27. package/dist/load-module.d.ts +0 -6
  28. package/dist/load-module.d.ts.map +1 -1
  29. package/dist/load-module.js +53 -40
  30. package/dist/load-module.js.map +1 -1
  31. package/dist/navigate.d.ts +5 -0
  32. package/dist/navigate.d.ts.map +1 -1
  33. package/dist/navigate.js +1 -0
  34. package/dist/navigate.js.map +1 -1
  35. package/dist/rate-limit.d.ts.map +1 -1
  36. package/dist/rate-limit.js +14 -27
  37. package/dist/rate-limit.js.map +1 -1
  38. package/dist/renderer.d.ts +7 -27
  39. package/dist/renderer.d.ts.map +1 -1
  40. package/dist/renderer.js +25 -152
  41. package/dist/renderer.js.map +1 -1
  42. package/dist/streaming.d.ts +3 -3
  43. package/dist/streaming.d.ts.map +1 -1
  44. package/dist/streaming.js +13 -33
  45. package/dist/streaming.js.map +1 -1
  46. package/package.json +8 -26
  47. package/dist/build-id.d.ts +0 -14
  48. package/dist/build-id.d.ts.map +0 -1
  49. package/dist/build-id.js +0 -40
  50. package/dist/build-id.js.map +0 -1
  51. package/dist/dev-assets.test.d.ts +0 -2
  52. package/dist/dev-assets.test.d.ts.map +0 -1
  53. package/dist/head-renderer.test.d.ts +0 -2
  54. package/dist/head-renderer.test.d.ts.map +0 -1
  55. package/dist/head-renderer.test.js +0 -78
  56. package/dist/head-renderer.test.js.map +0 -1
  57. package/dist/legacy-wrapper.d.ts +0 -88
  58. package/dist/legacy-wrapper.d.ts.map +0 -1
  59. package/dist/legacy-wrapper.js +0 -104
  60. package/dist/legacy-wrapper.js.map +0 -1
  61. package/dist/lib-assets.d.ts +0 -5
  62. package/dist/lib-assets.d.ts.map +0 -1
  63. package/dist/lib-assets.js +0 -95
  64. package/dist/lib-assets.js.map +0 -1
  65. package/dist/metadata.d.ts +0 -95
  66. package/dist/metadata.d.ts.map +0 -1
  67. package/dist/metadata.js +0 -132
  68. package/dist/metadata.js.map +0 -1
  69. package/dist/renderer.test.d.ts +0 -2
  70. package/dist/renderer.test.d.ts.map +0 -1
  71. package/dist/renderer.test.js +0 -251
  72. package/dist/renderer.test.js.map +0 -1
  73. package/dist/tenancy.d.ts +0 -17
  74. package/dist/tenancy.d.ts.map +0 -1
  75. package/dist/tenancy.js +0 -132
  76. package/dist/tenancy.js.map +0 -1
  77. package/dist/tenancy.test.d.ts +0 -2
  78. package/dist/tenancy.test.d.ts.map +0 -1
  79. package/dist/tenancy.test.js +0 -38
  80. package/dist/tenancy.test.js.map +0 -1
package/dist/actions.js CHANGED
@@ -30,10 +30,8 @@
30
30
  import { createContext, NotFoundSignal, RedirectSignal } from './context.js';
31
31
  import { serialize, deserialize } from '@nexus_js/serialize';
32
32
  import { validateActionToken, extractSessionId, ACTION_TOKEN_HEADER, } from './csrf.js';
33
- import { createRateLimiter, registerLimiter, getLimiter, RateLimitError, } from './rate-limit.js';
34
- import { randomUUID, createHmac, timingSafeEqual } from 'node:crypto';
33
+ import { createRateLimiter, registerLimiter, RateLimitError, } from './rate-limit.js';
35
34
  import { emitDevRadar, newTraceId, sanitizeTelemetryValue } from './devradar.js';
36
- import { getExpectedNexusBuildId } from './build-id.js';
37
35
  const ACTION_PREFIX = '/_nexus/action/';
38
36
  const idempotencyCache = new Map();
39
37
  const IDEMPOTENCY_TTL = 30_000; // 30 seconds
@@ -42,49 +40,14 @@ const IDEMPOTENCY_TTL = 30_000; // 30 seconds
42
40
  const inFlightActions = new Map();
43
41
  const actionQueues = new Map();
44
42
  const actionRegistry = new Map();
45
- /**
46
- * Generates an HMAC-SHA256 signature for an action name.
47
- * Used to sign action URLs at SSR time so the server can verify they originated
48
- * from a server-rendered page — not from an attacker enumerating action names.
49
- * Skipped in dev mode (NODE_ENV !== 'production').
50
- */
51
- function signActionName(name) {
52
- if (process.env['NODE_ENV'] !== 'production')
53
- return null; // dev mode — no signing
54
- const secret = process.env['NEXUS_SECRET'];
55
- if (!secret)
56
- return null; // no secret — skip signing
57
- return createHmac('sha256', secret).update(`action:${name}`).digest('base64url').slice(0, 16);
58
- }
59
- /**
60
- * Verifies an action name signature. Returns true if the signature is valid or
61
- * if we are in dev mode (NODE_ENV !== 'production' — signature is optional in dev).
62
- */
63
- export function verifyActionSig(name, sig) {
64
- const expected = signActionName(name);
65
- if (expected === null)
66
- return true; // dev mode — skip verification
67
- if (!sig)
68
- return false;
69
- // Constant-time comparison (sig is 16 base64url chars = 96 bits)
70
- try {
71
- return timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
72
- }
73
- catch {
74
- return false;
75
- }
76
- }
77
43
  /**
78
44
  * SSR and islands coerce `action={myAction}` with `String(myAction)`. The value is the
79
45
  * wrapper from `createAction`, whose default `Function#toString()` is the entire wrapper
80
46
  * source (CSRF, rate limit, …) — that string was being used as the form URL. After
81
47
  * registration we pin `toString` / `@@toPrimitive` to the real POST path.
82
- * In production (NEXUS_SECRET is set), the URL carries an HMAC query param
83
- * (`?__sig=…`) so the server can reject calls with unsigned / forged action URLs.
84
48
  */
85
49
  function patchRegisteredActionStringCoercion(fn, name) {
86
- const sig = signActionName(name);
87
- const url = sig ? `${ACTION_PREFIX}${name}?__sig=${sig}` : `${ACTION_PREFIX}${name}`;
50
+ const url = ACTION_PREFIX + name;
88
51
  Object.defineProperty(fn, 'toString', {
89
52
  value: function nexusActionUrlString() {
90
53
  return url;
@@ -110,7 +73,7 @@ function patchRegisteredActionStringCoercion(fn, name) {
110
73
  * and ready to be called by the client.
111
74
  *
112
75
  * Security layers applied (in order):
113
- * 1. CSRF: custom header `x-nexus-action: 1` (Tier 1) + optional HMAC token (Tier 2)
76
+ * 1. CSRF token validation (x-nexus-action-token header)
114
77
  * 2. Rate limiting (sliding window, per-IP or per-user)
115
78
  * 3. Input schema validation (Zod or any .parse() compatible schema)
116
79
  * 4. AbortController (client disconnect + timeout)
@@ -146,33 +109,14 @@ export function createAction(optsOrFn, legacyOpts = {}) {
146
109
  throw new RateLimitError(result);
147
110
  }
148
111
  }
149
- // 3. Schema validation — fail-fast before any business logic or DB access
112
+ // 3. Schema validation
150
113
  if (opts.schema) {
151
- if (typeof opts.schema.safeParse === 'function') {
152
- // Structured path: extract field-level errors for the client
153
- const result = opts.schema.safeParse(input);
154
- if (!result.success) {
155
- const issues = result.error?.issues ?? [];
156
- const fieldErrors = {};
157
- for (const issue of issues) {
158
- const path = issue.path.join('.') || '_root';
159
- fieldErrors[path] = issue.message;
160
- }
161
- throw new ActionError(issues.length > 0
162
- ? `Validation failed: ${issues.map((i) => `${i.path.join('.') || 'input'} — ${i.message}`).join('; ')}`
163
- : 'Input validation failed', 400, 'VALIDATION_ERROR', fieldErrors);
164
- }
165
- if (result.data !== undefined)
166
- input = result.data;
114
+ try {
115
+ input = opts.schema.parse(input);
167
116
  }
168
- else {
169
- try {
170
- input = opts.schema.parse(input);
171
- }
172
- catch (err) {
173
- const msg = err instanceof Error ? err.message : 'Input validation failed';
174
- throw new ActionError(`Invalid input: ${msg}`, 400, 'VALIDATION_ERROR');
175
- }
117
+ catch (err) {
118
+ const msg = err instanceof Error ? err.message : 'Input validation failed';
119
+ throw new ActionError(`Invalid input: ${msg}`, 400, 'VALIDATION_ERROR');
176
120
  }
177
121
  }
178
122
  return fn(input, ctx);
@@ -192,25 +136,11 @@ export function getRegisteredActionNames() {
192
136
  export class ActionError extends Error {
193
137
  status;
194
138
  code;
195
- fieldErrors;
196
- constructor(message, optionsOrStatus, code, fieldErrors) {
139
+ constructor(message, status = 400, code) {
197
140
  super(message);
141
+ this.status = status;
142
+ this.code = code;
198
143
  this.name = 'ActionError';
199
- // Support both positional and options object formats
200
- if (typeof optionsOrStatus === 'object') {
201
- this.status = optionsOrStatus.status ?? 400;
202
- if (optionsOrStatus.code !== undefined)
203
- this.code = optionsOrStatus.code;
204
- if (optionsOrStatus.fieldErrors !== undefined)
205
- this.fieldErrors = optionsOrStatus.fieldErrors;
206
- }
207
- else {
208
- this.status = optionsOrStatus ?? 400;
209
- if (code !== undefined)
210
- this.code = code;
211
- if (fieldErrors !== undefined)
212
- this.fieldErrors = fieldErrors;
213
- }
214
144
  }
215
145
  }
216
146
  export class ActionAbortedError extends ActionError {
@@ -218,41 +148,6 @@ export class ActionAbortedError extends ActionError {
218
148
  super('Action was superseded by a newer request', 409, 'ABORTED');
219
149
  }
220
150
  }
221
- /**
222
- * Detects whether a request originates from a plain HTML form (no JS).
223
- *
224
- * A "native form" request:
225
- * - Has `Content-Type: application/x-www-form-urlencoded`
226
- * - Does NOT have the `x-nexus-action` custom header (added by the JS runtime)
227
- * - Does NOT explicitly request `application/json` in Accept
228
- *
229
- * When true, the action handler responds with 303 redirects instead of JSON
230
- * (Progressive Enhancement — the action works even when JS is disabled).
231
- */
232
- function isNativeFormRequest(request) {
233
- const ct = request.headers.get('content-type') ?? '';
234
- const hasNexusHeader = request.headers.has('x-nexus-action');
235
- const acceptsJson = (request.headers.get('accept') ?? '').includes('application/json');
236
- return (ct.includes('application/x-www-form-urlencoded') &&
237
- !hasNexusHeader &&
238
- !acceptsJson);
239
- }
240
- /**
241
- * Builds a 303 See Other redirect for progressive-enhancement form submissions.
242
- * Follows the Post/Redirect/Get pattern to prevent duplicate submissions on refresh.
243
- *
244
- * Redirect target priority:
245
- * 1. `__redirectTo` hidden field in the form
246
- * 2. `Referer` header (the page that submitted the form)
247
- * 3. `/` as final fallback
248
- */
249
- function formRedirect(request, formData, status = 303) {
250
- const redirectTo = formData.get('__redirectTo') ??
251
- request.headers.get('referer') ??
252
- '/';
253
- const headers = new Headers({ location: redirectTo });
254
- return new Response(null, { status, headers });
255
- }
256
151
  /**
257
152
  * Main HTTP handler for /_nexus/action/:name
258
153
  * This is where all the race-condition logic runs.
@@ -268,146 +163,54 @@ export async function handleActionRequest(request) {
268
163
  headers: { allow: 'POST' },
269
164
  });
270
165
  }
271
- const nativeForm = isNativeFormRequest(request);
272
- // ── Guard: opaque (null) Origin ────────────────────────────────────────────
273
- // Sandboxed iframes (<iframe sandbox> without allow-same-origin) and data:
274
- // URIs send the string "null" as the Origin header. This is never a
275
- // legitimate same-origin action call and must be rejected before any CSRF
276
- // header check — otherwise the custom-header tier can be bypassed.
277
- const rawOrigin = request.headers.get('origin');
278
- if (rawOrigin === 'null') {
279
- emitDevRadar({
280
- type: 'security:audit',
281
- payload: { kind: 'csrf_blocked', message: 'Opaque (null) origin rejected', action: '?' },
282
- });
283
- return jsonResponse({ error: 'Forbidden: opaque origin', status: 403, code: 'OPAQUE_ORIGIN' }, 403);
284
- }
285
- // ── Guard: action name validation ──────────────────────────────────────────
286
- // Ensure the action name extracted from the URL only contains characters that
287
- // can appear in a valid registered action name. Rejects path-traversal
288
- // sequences (../../ etc.) and injection attempts before any registry lookup.
289
- const rawActionName = url.pathname.slice(ACTION_PREFIX.length);
290
- if (!/^[\w][\w.-]*$/.test(rawActionName) || rawActionName.includes('..')) {
291
- return jsonResponse({ error: 'Invalid action name', status: 400, code: 'INVALID_ACTION_NAME' }, 400);
292
- }
293
- const actionName = rawActionName;
166
+ const actionName = url.pathname.slice(ACTION_PREFIX.length);
294
167
  const registered = actionRegistry.get(actionName);
295
168
  if (!registered) {
296
169
  return jsonResponse({ error: `Action "${actionName}" not found`, status: 404 }, 404);
297
170
  }
298
- // ── Action URL signature verification (production only) ───────────────────
299
- // In production (NEXUS_SECRET is set), action URLs are HMAC-signed during SSR.
300
- // A request without a valid signature means the URL was manually crafted or
301
- // the action name was enumerated — not server-rendered.
302
- // In dev mode (no secret), signature check is skipped so hot-reload works.
303
- const actionSig = url.searchParams.get('__sig');
304
- if (!verifyActionSig(actionName, actionSig)) {
305
- emitDevRadar({
306
- type: 'security:audit',
307
- payload: {
308
- kind: 'csrf_blocked',
309
- message: 'Action URL signature missing or invalid — possible action enumeration',
310
- action: actionName,
311
- },
312
- });
313
- return jsonResponse({ error: 'Invalid action URL', status: 403, code: 'INVALID_ACTION_SIG' }, 403);
314
- }
315
- // ── Build ID (client–server contract) ─────────────────────────────────────
316
- // When `.nexus/build-id.json` exists (after `nexus build`), every action call
317
- // must carry `x-nexus-build-id` matching that file. Stale tabs from a prior
318
- // deploy get 412 so the runtime can reload and pick up new action signatures.
319
- const expectedBuildId = getExpectedNexusBuildId();
320
- if (expectedBuildId !== null) {
321
- const sent = request.headers.get('x-nexus-build-id') ?? '';
322
- if (sent !== expectedBuildId) {
171
+ const { fn, opts } = registered;
172
+ const race = opts.race ?? 'cancel';
173
+ const timeout = opts.timeout ?? 30_000;
174
+ // ── CSRF token validation ──────────────────────────────────────────────────
175
+ if (opts.csrf !== false) {
176
+ const token = request.headers.get(ACTION_TOKEN_HEADER);
177
+ const secret = process.env['NEXUS_SECRET'] ?? 'nexus-dev-secret-change-me';
178
+ const sessionId = extractSessionId(request);
179
+ if (!token) {
323
180
  emitDevRadar({
324
181
  type: 'security:audit',
325
182
  payload: {
326
- kind: 'build_mismatch',
327
- message: 'x-nexus-build-id does not match deployed build',
183
+ kind: 'csrf_blocked',
184
+ message: 'Missing action token',
328
185
  action: actionName,
329
186
  },
330
187
  });
331
188
  return jsonResponse({
332
- error: 'Application was updated. Please reload the page.',
333
- status: 412,
334
- code: 'BUILD_MISMATCH',
335
- }, 412);
336
- }
337
- }
338
- const { fn, opts } = registered;
339
- const race = opts.race ?? 'cancel';
340
- const timeout = opts.timeout ?? 30_000;
341
- // ── CSRF protection (dual-tier) ────────────────────────────────────────────
342
- //
343
- // Tier 1 — Custom header (default): Browsers cannot add arbitrary headers to
344
- // cross-origin requests without a CORS preflight the server will reject.
345
- // Requiring a non-empty `x-nexus-action` header blocks form-based CSRF from
346
- // foreign origins without needing token generation on the server.
347
- //
348
- // Tier 2 — HMAC token (opt-in): When `x-nexus-action-token` is present the
349
- // server also validates the full HMAC-SHA256 signed, single-use, session-
350
- // bound token (generated via `generateActionToken`). This additionally
351
- // protects against same-origin XSS token theft.
352
- //
353
- // Flow:
354
- // token present → Tier 2 full validation (strongest)
355
- // header present, no token → Tier 1 header check (standard)
356
- // neither present → 403 CSRF block
357
- if (opts.csrf !== false) {
358
- const token = request.headers.get(ACTION_TOKEN_HEADER);
359
- const nexusHeader = request.headers.get('x-nexus-action');
360
- if (token) {
361
- // Tier 2: full HMAC validation
362
- const envSecret = process.env['NEXUS_SECRET'];
363
- if (!envSecret && process.env['NODE_ENV'] === 'production') {
364
- // listen() already blocks start without NEXUS_SECRET; this catches
365
- // edge-runtimes / tests that bypass createNexusServer.
366
- return jsonResponse({ error: 'Server misconfiguration: NEXUS_SECRET not set', status: 500, code: 'INSECURE_SECRET' }, 500);
367
- }
368
- const secret = envSecret ?? 'nexus-dev-secret-change-me';
369
- const sessionId = extractSessionId(request);
370
- const validation = validateActionToken(token, sessionId, actionName, secret);
371
- if (!validation.valid) {
372
- emitDevRadar({
373
- type: 'security:audit',
374
- payload: {
375
- kind: validation.replayed ? 'replay' : 'csrf_blocked',
376
- message: validation.reason ?? 'Invalid action token',
377
- action: actionName,
378
- },
379
- });
380
- return jsonResponse({
381
- error: validation.reason ?? 'Invalid action token',
382
- status: 403,
383
- code: validation.replayed ? 'REPLAY_ATTACK' : 'INVALID_CSRF_TOKEN',
384
- }, 403);
385
- }
189
+ error: 'Missing action token possible CSRF attack',
190
+ status: 403,
191
+ code: 'MISSING_CSRF_TOKEN',
192
+ }, 403);
386
193
  }
387
- else if (!nexusHeader) {
388
- // Neither token nor custom header → CSRF attack or missing client header
194
+ const validation = validateActionToken(token, sessionId, actionName, secret);
195
+ if (!validation.valid) {
389
196
  emitDevRadar({
390
197
  type: 'security:audit',
391
198
  payload: {
392
- kind: 'csrf_blocked',
393
- message: 'Missing x-nexus-action header — possible CSRF attack',
199
+ kind: validation.replayed ? 'replay' : 'csrf_blocked',
200
+ message: validation.reason ?? 'Invalid action token',
394
201
  action: actionName,
395
202
  },
396
203
  });
397
204
  return jsonResponse({
398
- error: 'Missing x-nexus-action header — possible CSRF attack',
205
+ error: validation.reason ?? 'Invalid action token',
399
206
  status: 403,
400
- code: 'MISSING_CSRF_HEADER',
207
+ code: validation.replayed ? 'REPLAY_ATTACK' : 'INVALID_CSRF_TOKEN',
401
208
  }, 403);
402
209
  }
403
- // Tier 1 satisfied: nexusHeader present, no token → header-based CSRF protection
404
210
  }
405
211
  // ── Rate limiting ──────────────────────────────────────────────────────────
406
- // Use the pre-registered limiter (created once at registerAction time).
407
- // Previously, a new limiter instance was created per-request, which reset
408
- // the sliding-window state and made rate limiting completely ineffective.
409
- const limiter = getLimiter(actionName);
410
- if (limiter) {
212
+ if (opts.rateLimit) {
213
+ const limiter = createRateLimiter(opts.rateLimit);
411
214
  const result = limiter.check(request);
412
215
  if (!result.allowed) {
413
216
  emitDevRadar({
@@ -491,7 +294,7 @@ export async function handleActionRequest(request) {
491
294
  const startTime = Date.now();
492
295
  try {
493
296
  // Deserialize input using Nexus transport (preserves Date, Map, Set, etc.)
494
- const input = await deserializeInput(request, opts.maxBodyBytes ?? MAX_ACTION_BODY_BYTES);
297
+ const input = await deserializeInput(request);
495
298
  emitDevRadar({
496
299
  type: 'action:call',
497
300
  payload: {
@@ -545,26 +348,15 @@ export async function handleActionRequest(request) {
545
348
  // Cleanup old entries periodically
546
349
  cleanIdempotencyCache();
547
350
  }
548
- // Progressive Enhancement: native HTML form → Post/Redirect/Get pattern
549
- if (nativeForm) {
550
- const fd = input instanceof FormData ? input : new FormData();
551
- return formRedirect(request, fd);
552
- }
553
351
  // Serialize response with Nexus transport
554
352
  const serialized = serialize({ data: result, status: 200, duration, idempotencyKey });
555
- // Merge response headers from context (includes Set-Cookie from ctx.setCookie)
556
- const responseHeaders = new Headers({
557
- 'content-type': 'application/json',
558
- 'x-nexus-duration': String(duration),
559
- ...(idempotencyKey ? { 'x-nexus-idempotency': idempotencyKey } : {}),
560
- });
561
- // Copy headers from context (cookies, custom headers, etc.)
562
- ctxWithSignal._responseHeaders.forEach((value, key) => {
563
- responseHeaders.append(key, value);
564
- });
565
353
  return new Response(serialized, {
566
354
  status: 200,
567
- headers: responseHeaders,
355
+ headers: {
356
+ 'content-type': 'application/json',
357
+ 'x-nexus-duration': String(duration),
358
+ ...(idempotencyKey ? { 'x-nexus-idempotency': idempotencyKey } : {}),
359
+ },
568
360
  });
569
361
  }
570
362
  catch (err) {
@@ -605,50 +397,19 @@ export async function handleActionRequest(request) {
605
397
  ...(err.code !== undefined ? { code: err.code } : {}),
606
398
  },
607
399
  });
608
- // Progressive Enhancement: redirect back with error encoded in query string
609
- if (nativeForm) {
610
- const referer = request.headers.get('referer') ?? '/';
611
- const target = new URL(referer, request.url);
612
- target.searchParams.set('__nexus_error', err.message);
613
- if (err.code)
614
- target.searchParams.set('__nexus_code', err.code);
615
- return new Response(null, {
616
- status: 303,
617
- headers: { location: target.toString() },
618
- });
619
- }
620
- return jsonResponse({
621
- error: err.message,
622
- status: err.status,
623
- code: err.code,
624
- ...(err.fieldErrors ? { fieldErrors: err.fieldErrors } : {}),
625
- }, err.status);
400
+ return jsonResponse({ error: err.message, status: err.status, code: err.code }, err.status);
626
401
  }
627
- const errorId = randomUUID();
628
- const mask = process.env['NEXUS_EXPOSE_ERRORS'] !== 'true' &&
629
- process.env['NODE_ENV'] === 'production';
630
- console.error(`[Nexus Action ${errorId}] "${actionName}" failed:`, err);
631
402
  emitDevRadar({
632
403
  type: 'action:error',
633
404
  payload: {
634
405
  id: traceId,
635
406
  name: actionName,
636
- error: mask ? 'Internal Server Error' : (err instanceof Error ? err.message : String(err)),
407
+ error: err instanceof Error ? err.message : String(err),
637
408
  duration: Date.now() - startTime,
638
- ...(mask ? { errorId } : {}),
639
409
  },
640
410
  });
641
- if (mask) {
642
- return jsonResponse({
643
- error: 'Internal Server Error',
644
- status: 500,
645
- errorId,
646
- }, 500);
647
- }
648
- return jsonResponse({
649
- error: err instanceof Error ? err.message : String(err),
650
- status: 500,
651
- }, 500);
411
+ console.error(`[Nexus Action] "${actionName}" failed:`, err);
412
+ return jsonResponse({ error: 'Internal Server Error', status: 500 }, 500);
652
413
  }
653
414
  finally {
654
415
  clearTimeout(timeoutId);
@@ -657,105 +418,18 @@ export async function handleActionRequest(request) {
657
418
  }
658
419
  }
659
420
  /**
660
- * Validates that a request comes from a trusted Nexus client (inner CSRF check
661
- * used by `createAction` wrappers). Verifies:
662
- * 1. `x-nexus-action` custom header — cross-origin requests cannot add this
663
- * without a CORS preflight the server will reject.
664
- * 2. `Origin` / `Referer` header sanity check — additional signal against
665
- * misconfigured CORS or non-standard clients.
421
+ * Validates that a request comes from a trusted Nexus client.
422
+ * Checks x-nexus-action header and CSRF token.
666
423
  */
667
424
  export async function validateRequest(ctx) {
668
425
  const nexusHeader = ctx.request.headers.get('x-nexus-action');
669
426
  if (!nexusHeader) {
670
- throw new ActionError('Missing x-nexus-action header', 403, 'MISSING_HEADER');
671
- }
672
- // Origin / Referer check: block requests that carry an explicit foreign origin.
673
- // Same-origin and same-site fetch() calls either omit Origin or match the host.
674
- const origin = ctx.request.headers.get('origin');
675
- const referer = ctx.request.headers.get('referer');
676
- const rawHost = ctx.request.headers.get('host') ?? '';
677
- // Derive scheme from X-Forwarded-Proto or default to http (covered by HTTPS terminator upstream)
678
- const proto = ctx.request.headers.get('x-forwarded-proto') ?? 'http';
679
- const expectedOrigin = `${proto}://${rawHost}`;
680
- function isSameOrigin(headerValue) {
681
- try {
682
- const parsed = new URL(headerValue);
683
- const expected = new URL(expectedOrigin);
684
- return parsed.protocol === expected.protocol
685
- && parsed.hostname === expected.hostname
686
- && parsed.port === expected.port;
687
- }
688
- catch {
689
- return false;
690
- }
691
- }
692
- const suspectOrigin = origin && !isSameOrigin(origin);
693
- const suspectReferer = !origin && referer && !isSameOrigin(referer);
694
- if (suspectOrigin || suspectReferer) {
695
- throw new ActionError(`Cross-origin action request blocked (host: ${rawHost})`, 403, 'CROSS_ORIGIN_BLOCKED');
427
+ throw new ActionError('Missing Nexus action header', 403, 'MISSING_HEADER');
696
428
  }
697
429
  }
698
430
  // Re-export security primitives for use in app code
699
431
  export { generateActionToken, validateActionToken, extractSessionId, generateSessionId } from './csrf.js';
700
432
  export { createRateLimiter, RateLimitError, parseWindow } from './rate-limit.js';
701
- /**
702
- * Returns `true` when `url` is a safe **public** `http:` / `https:` target for
703
- * server-side `fetch` (not loopback, RFC1918, link-local, metadata IPs, etc.).
704
- * Use before `fetch(userUrl)` to reduce blind SSRF risk.
705
- */
706
- export function isSafeUrl(url) {
707
- try {
708
- const u = new URL(url);
709
- if (u.protocol !== 'http:' && u.protocol !== 'https:')
710
- return false;
711
- }
712
- catch {
713
- return false;
714
- }
715
- return !isInternalUrl(url);
716
- }
717
- /**
718
- * Returns `true` when a URL resolves to a private, loopback, or link-local
719
- * address. Inverse of {@link isSafeUrl} for `http:` / `https:`.
720
- */
721
- export function isInternalUrl(url) {
722
- let parsed;
723
- try {
724
- parsed = new URL(url);
725
- }
726
- catch {
727
- return false; // unparseable URLs are not our SSRF concern — let the caller decide
728
- }
729
- // Only http/https are fetched in typical web actions; block everything else
730
- // (file://, ftp://, etc.) as they access local resources.
731
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
732
- return true;
733
- const h = parsed.hostname.toLowerCase();
734
- // Loopback
735
- if (h === 'localhost' || h === '127.0.0.1' || h === '::1')
736
- return true;
737
- if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h))
738
- return true;
739
- // Link-local (AWS/GCP/Azure metadata service lives at 169.254.169.254)
740
- if (h.startsWith('169.254.'))
741
- return true;
742
- if (h === 'fe80::1' || h.startsWith('fe80:'))
743
- return true;
744
- // RFC 1918 private ranges
745
- if (h.startsWith('10.'))
746
- return true;
747
- if (/^172\.(1[6-9]|2\d|3[01])\./.test(h))
748
- return true;
749
- if (h.startsWith('192.168.'))
750
- return true;
751
- // Unique local IPv6
752
- if (/^fd[0-9a-f]{2}:/i.test(h) || h.startsWith('fc'))
753
- return true;
754
- // Unspecified / broadcast
755
- if (h === '0.0.0.0' || h === '255.255.255.255')
756
- return true;
757
- return false;
758
- }
759
433
  // ── Client-side race guard ────────────────────────────────────────────────────
760
434
  /**
761
435
  * Client-side AbortController factory.
@@ -796,71 +470,10 @@ export function createActionGuard(name, strategy = 'cancel') {
796
470
  };
797
471
  }
798
472
  // ── Helpers ───────────────────────────────────────────────────────────────────
799
- /** Maximum request body for a single server action (10 MB). Override per-action via opts.maxBodyBytes. */
800
- const MAX_ACTION_BODY_BYTES = 10 * 1_024 * 1_024;
801
- /** Maximum JSON object nesting depth. Prevents CPU DoS via pathological `{"a":{"b":{…}}}` inputs. */
802
- const MAX_JSON_DEPTH = 10;
803
- /** Maximum number of JSON object keys across all nesting levels. Prevents key-explosion attacks. */
804
- const MAX_JSON_KEYS = 1_000;
805
- /**
806
- * Walks the raw JSON text character-by-character (O(n)) tracking nesting depth and
807
- * key count WITHOUT running the full parser. Throws ActionError before `JSON.parse`
808
- * touches the data — prevents CPU DoS from deeply nested or key-explosion payloads
809
- * that fit within MAX_ACTION_BODY_BYTES but would block the event loop for seconds.
810
- */
811
- function assertJsonComplexity(text) {
812
- let depth = 0;
813
- let keys = 0;
814
- let inStr = false;
815
- let esc = false;
816
- for (let i = 0; i < text.length; i++) {
817
- const ch = text[i];
818
- if (esc) {
819
- esc = false;
820
- continue;
821
- }
822
- if (ch === '\\' && inStr) {
823
- esc = true;
824
- continue;
825
- }
826
- if (ch === '"') {
827
- inStr = !inStr;
828
- continue;
829
- }
830
- if (inStr) {
831
- continue;
832
- }
833
- if (ch === '{' || ch === '[') {
834
- if (++depth > MAX_JSON_DEPTH) {
835
- throw new ActionError(`JSON nesting too deep (limit ${MAX_JSON_DEPTH} levels)`, 400, 'JSON_TOO_DEEP');
836
- }
837
- }
838
- else if (ch === '}' || ch === ']') {
839
- depth--;
840
- }
841
- else if (ch === ':') {
842
- if (++keys > MAX_JSON_KEYS) {
843
- throw new ActionError(`JSON too complex (limit ${MAX_JSON_KEYS} keys)`, 400, 'JSON_TOO_COMPLEX');
844
- }
845
- }
846
- }
847
- }
848
- async function deserializeInput(request, maxBytes = MAX_ACTION_BODY_BYTES) {
849
- // DoS protection: reject oversized bodies before reading any bytes.
850
- // Prevents memory exhaustion from clients that stream arbitrarily large payloads.
851
- const cl = parseInt(request.headers.get('content-length') ?? '0', 10);
852
- if (Number.isFinite(cl) && cl > maxBytes) {
853
- throw new ActionError(`Request body too large (${cl} bytes, limit ${maxBytes})`, 413, 'PAYLOAD_TOO_LARGE');
854
- }
473
+ async function deserializeInput(request) {
855
474
  const contentType = request.headers.get('content-type') ?? '';
856
475
  if (contentType.includes('application/json')) {
857
476
  const text = await request.text();
858
- if (text.length > maxBytes) {
859
- throw new ActionError('Request body too large', 413, 'PAYLOAD_TOO_LARGE');
860
- }
861
- // Complexity guard: run BEFORE the parser so pathological payloads (deeply
862
- // nested or key-explosion) are rejected without blocking the event loop.
863
- assertJsonComplexity(text);
864
477
  try {
865
478
  return deserialize(text);
866
479
  }
@@ -872,11 +485,7 @@ async function deserializeInput(request, maxBytes = MAX_ACTION_BODY_BYTES) {
872
485
  contentType.includes('application/x-www-form-urlencoded')) {
873
486
  return request.formData();
874
487
  }
875
- const text = await request.text();
876
- if (text.length > maxBytes) {
877
- throw new ActionError('Request body too large', 413, 'PAYLOAD_TOO_LARGE');
878
- }
879
- return text;
488
+ return request.text();
880
489
  }
881
490
  function jsonResponse(body, status) {
882
491
  return new Response(JSON.stringify(body), {