@nexus_js/server 0.7.5 → 0.9.0

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 (45) hide show
  1. package/dist/actions.d.ts +57 -8
  2. package/dist/actions.d.ts.map +1 -1
  3. package/dist/actions.js +318 -19
  4. package/dist/actions.js.map +1 -1
  5. package/dist/build-id.d.ts +14 -0
  6. package/dist/build-id.d.ts.map +1 -0
  7. package/dist/build-id.js +40 -0
  8. package/dist/build-id.js.map +1 -0
  9. package/dist/context.js +10 -2
  10. package/dist/context.js.map +1 -1
  11. package/dist/csrf.d.ts +16 -2
  12. package/dist/csrf.d.ts.map +1 -1
  13. package/dist/csrf.js +48 -12
  14. package/dist/csrf.js.map +1 -1
  15. package/dist/devradar.d.ts +1 -1
  16. package/dist/devradar.d.ts.map +1 -1
  17. package/dist/devradar.js.map +1 -1
  18. package/dist/index.d.ts +4 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +58 -15
  21. package/dist/index.js.map +1 -1
  22. package/dist/load-module.d.ts +6 -0
  23. package/dist/load-module.d.ts.map +1 -1
  24. package/dist/load-module.js +13 -9
  25. package/dist/load-module.js.map +1 -1
  26. package/dist/metadata.d.ts +91 -0
  27. package/dist/metadata.d.ts.map +1 -0
  28. package/dist/metadata.js +128 -0
  29. package/dist/metadata.js.map +1 -0
  30. package/dist/navigate.d.ts +0 -5
  31. package/dist/navigate.d.ts.map +1 -1
  32. package/dist/navigate.js +0 -1
  33. package/dist/navigate.js.map +1 -1
  34. package/dist/rate-limit.d.ts.map +1 -1
  35. package/dist/rate-limit.js +27 -14
  36. package/dist/rate-limit.js.map +1 -1
  37. package/dist/renderer.d.ts +23 -3
  38. package/dist/renderer.d.ts.map +1 -1
  39. package/dist/renderer.js +64 -19
  40. package/dist/renderer.js.map +1 -1
  41. package/dist/streaming.d.ts +3 -3
  42. package/dist/streaming.d.ts.map +1 -1
  43. package/dist/streaming.js +33 -13
  44. package/dist/streaming.js.map +1 -1
  45. package/package.json +7 -7
package/dist/actions.d.ts CHANGED
@@ -29,6 +29,25 @@
29
29
  */
30
30
  import type { NexusContext } from './context.js';
31
31
  import { type RateLimitConfig } from './rate-limit.js';
32
+ /**
33
+ * Zod-compatible schema interface.
34
+ * Supports `.parse()` (throws on failure) and optionally `.safeParse()` (returns structured errors).
35
+ * Works with Zod, Valibot, ArkType, Superstruct, and any schema library following this contract.
36
+ */
37
+ export interface NexusSchema<T> {
38
+ parse(data: unknown): T;
39
+ /** Optional — when present, used to extract structured field errors (Zod format). */
40
+ safeParse?: (data: unknown) => {
41
+ success: boolean;
42
+ error?: {
43
+ issues?: Array<{
44
+ path: Array<string | number>;
45
+ message: string;
46
+ }>;
47
+ };
48
+ data?: T;
49
+ };
50
+ }
32
51
  export type ActionFn<TInput = FormData, TOutput = void> = (input: TInput, ctx: NexusContext & {
33
52
  signal: AbortSignal;
34
53
  }) => Promise<TOutput>;
@@ -70,13 +89,23 @@ export interface ActionOptions {
70
89
  */
71
90
  csrf?: boolean;
72
91
  /**
73
- * A Zod-compatible schema for input validation.
74
- * If provided, the action will reject requests with invalid input before
75
- * calling the handler. Prevents SQL injection and type coercion attacks.
92
+ * Zod-compatible schema for input validation.
93
+ * The action rejects invalid input **before** calling the handler
94
+ * preventing SQL injection, type coercion attacks, and untrusted data reaching business logic.
95
+ *
96
+ * Accepts any object with a `.parse()` method (Zod, Valibot, ArkType, etc.)
97
+ * or `.safeParse()` for structured error extraction.
98
+ *
99
+ * @example
100
+ * ```ts
101
+ * import { z } from 'zod';
102
+ * export const updateUser = createAction({
103
+ * schema: z.object({ name: z.string().min(1).max(100), age: z.number().int().min(0) }),
104
+ * handler: async ({ name, age }, ctx) => { ... },
105
+ * });
106
+ * ```
76
107
  */
77
- schema?: {
78
- parse: (data: unknown) => unknown;
79
- };
108
+ schema?: NexusSchema<unknown>;
80
109
  /**
81
110
  * Maximum request body size in bytes. Default: 10 MB.
82
111
  * Lower this for actions that only receive small form payloads (e.g. login forms).
@@ -93,13 +122,18 @@ export interface ActionResult<T = unknown> {
93
122
  /** Server-side execution time in ms */
94
123
  duration?: number;
95
124
  }
125
+ /**
126
+ * Verifies an action name signature. Returns true if the signature is valid or
127
+ * if we are in dev mode (no NEXUS_SECRET set — signature is optional in dev).
128
+ */
129
+ export declare function verifyActionSig(name: string, sig: string | null): boolean;
96
130
  /**
97
131
  * Defines a Server Action with integrated security, rate limiting, and
98
132
  * race-condition management. The returned object is registered automatically
99
133
  * and ready to be called by the client.
100
134
  *
101
135
  * Security layers applied (in order):
102
- * 1. CSRF token validation (x-nexus-action-token header)
136
+ * 1. CSRF: custom header `x-nexus-action: 1` (Tier 1) + optional HMAC token (Tier 2)
103
137
  * 2. Rate limiting (sliding window, per-IP or per-user)
104
138
  * 3. Input schema validation (Zod or any .parse() compatible schema)
105
139
  * 4. AbortController (client disconnect + timeout)
@@ -126,7 +160,11 @@ export declare function getRegisteredActionNames(): ReadonlySet<string>;
126
160
  export declare class ActionError extends Error {
127
161
  readonly status: number;
128
162
  readonly code?: string | undefined;
129
- constructor(message: string, status?: number, code?: string | undefined);
163
+ /** Structured field-level validation errors (Zod-style). Key: field path, Value: message. */
164
+ readonly fieldErrors?: Record<string, string> | undefined;
165
+ constructor(message: string, status?: number, code?: string | undefined,
166
+ /** Structured field-level validation errors (Zod-style). Key: field path, Value: message. */
167
+ fieldErrors?: Record<string, string> | undefined);
130
168
  }
131
169
  export declare class ActionAbortedError extends ActionError {
132
170
  constructor();
@@ -148,6 +186,17 @@ export declare function validateRequest(ctx: NexusContext): Promise<void>;
148
186
  export { generateActionToken, validateActionToken, extractSessionId, generateSessionId } from './csrf.js';
149
187
  export { createRateLimiter, RateLimitError, parseWindow } from './rate-limit.js';
150
188
  export type { RateLimitConfig, RateLimitResult, RateLimiter } from './rate-limit.js';
189
+ /**
190
+ * Returns `true` when `url` is a safe **public** `http:` / `https:` target for
191
+ * server-side `fetch` (not loopback, RFC1918, link-local, metadata IPs, etc.).
192
+ * Use before `fetch(userUrl)` to reduce blind SSRF risk.
193
+ */
194
+ export declare function isSafeUrl(url: string): boolean;
195
+ /**
196
+ * Returns `true` when a URL resolves to a private, loopback, or link-local
197
+ * address. Inverse of {@link isSafeUrl} for `http:` / `https:`.
198
+ */
199
+ export declare function isInternalUrl(url: string): boolean;
151
200
  /**
152
201
  * Client-side AbortController factory.
153
202
  * Use this in island code to cancel in-flight action fetches
@@ -1 +1 @@
1
- {"version":3,"file":"actions.d.ts","sourceRoot":"","sources":["../src/actions.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"actions.d.ts","sourceRoot":"","sources":["../src/actions.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AAGH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAOjD,OAAO,EAKL,KAAK,eAAe,EACrB,MAAM,iBAAiB,CAAC;AAKzB;;;;GAIG;AACH,MAAM,WAAW,WAAW,CAAC,CAAC;IAC5B,KAAK,CAAC,IAAI,EAAE,OAAO,GAAG,CAAC,CAAC;IACxB,qFAAqF;IACrF,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK;QAC7B,OAAO,EAAE,OAAO,CAAC;QACjB,KAAK,CAAC,EAAE;YAAE,MAAM,CAAC,EAAE,KAAK,CAAC;gBAAE,IAAI,EAAE,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC;gBAAC,OAAO,EAAE,MAAM,CAAA;aAAE,CAAC,CAAA;SAAE,CAAC;QAC9E,IAAI,CAAC,EAAE,CAAC,CAAC;KACV,CAAC;CACH;AAED,MAAM,MAAM,QAAQ,CAAC,MAAM,GAAG,QAAQ,EAAE,OAAO,GAAG,IAAI,IAAI,CACxD,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,YAAY,GAAG;IAAE,MAAM,EAAE,WAAW,CAAA;CAAE,KACxC,OAAO,CAAC,OAAO,CAAC,CAAC;AAEtB,MAAM,MAAM,YAAY,GAAG,QAAQ,GAAG,OAAO,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAEpE,MAAM,WAAW,aAAa;IAC5B;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB;;;OAGG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;OAIG;IACH,SAAS,CAAC,EAAE,eAAe,CAAC;IAC5B;;;;OAIG;IACH,IAAI,CAAC,EAAE,OAAO,CAAC;IACf;;;;;;;;;;;;;;;;OAgBG;IACH,MAAM,CAAC,EAAE,WAAW,CAAC,OAAO,CAAC,CAAC;IAC9B;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,YAAY,CAAC,CAAC,GAAG,OAAO;IACvC,IAAI,CAAC,EAAE,CAAC,CAAC;IACT,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,8CAA8C;IAC9C,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,uCAAuC;IACvC,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAyCD;;;GAGG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAUzE;AAiCD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,YAAY,CAAC,MAAM,GAAG,QAAQ,EAAE,OAAO,GAAG,IAAI,EAC5D,QAAQ,EACJ,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC,GACzB,CAAC,aAAa,GAAG;IAAE,OAAO,EAAE,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAAC,EAC5D,UAAU,GAAE,aAAkB,GAC7B,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC,CA2D3B;AAED,wBAAgB,cAAc,CAC5B,IAAI,EAAE,MAAM,EACZ,EAAE,EAAE,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC,EAC9B,IAAI,GAAE,aAAkB,GACvB,IAAI,CAKN;AAED,8FAA8F;AAC9F,wBAAgB,wBAAwB,IAAI,WAAW,CAAC,MAAM,CAAC,CAE9D;AAED,qBAAa,WAAY,SAAQ,KAAK;aAGlB,MAAM,EAAE,MAAM;aACd,IAAI,CAAC,EAAE,MAAM;IAC7B,6FAA6F;aAC7E,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;gBAJpD,OAAO,EAAE,MAAM,EACC,MAAM,GAAE,MAAY,EACpB,IAAI,CAAC,EAAE,MAAM,YAAA;IAC7B,6FAA6F;IAC7E,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,YAAA;CAKvD;AAED,qBAAa,kBAAmB,SAAQ,WAAW;;CAIlD;AA2CD;;;GAGG;AACH,wBAAsB,mBAAmB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CA2b7E;AAED;;;;;;;GAOG;AACH,wBAAsB,eAAe,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAsCtE;AAGD,OAAO,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAC1G,OAAO,EAAE,iBAAiB,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AACjF,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAErF;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAQ9C;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAkClD;AAID;;;;;;;;;;;;GAYG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,MAAM,EACZ,QAAQ,GAAE,YAAuB,GAChC;IACD,GAAG,EAAE,MAAM,WAAW,CAAC;IACvB,KAAK,EAAE,MAAM,IAAI,CAAC;IAClB,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,EAAE,OAAO,CAAC;CAClB,CAyBA"}
package/dist/actions.js CHANGED
@@ -31,7 +31,9 @@ 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
33
  import { createRateLimiter, registerLimiter, getLimiter, RateLimitError, } from './rate-limit.js';
34
+ import { randomUUID, createHmac, timingSafeEqual } from 'node:crypto';
34
35
  import { emitDevRadar, newTraceId, sanitizeTelemetryValue } from './devradar.js';
36
+ import { getExpectedNexusBuildId } from './build-id.js';
35
37
  const ACTION_PREFIX = '/_nexus/action/';
36
38
  const idempotencyCache = new Map();
37
39
  const IDEMPOTENCY_TTL = 30_000; // 30 seconds
@@ -40,14 +42,47 @@ const IDEMPOTENCY_TTL = 30_000; // 30 seconds
40
42
  const inFlightActions = new Map();
41
43
  const actionQueues = new Map();
42
44
  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 (no NEXUS_SECRET required in dev mode).
50
+ */
51
+ function signActionName(name) {
52
+ const secret = process.env['NEXUS_SECRET'];
53
+ if (!secret || secret === 'nexus-dev-secret-change-me')
54
+ return null;
55
+ return createHmac('sha256', secret).update(`action:${name}`).digest('base64url').slice(0, 16);
56
+ }
57
+ /**
58
+ * Verifies an action name signature. Returns true if the signature is valid or
59
+ * if we are in dev mode (no NEXUS_SECRET set — signature is optional in dev).
60
+ */
61
+ export function verifyActionSig(name, sig) {
62
+ const expected = signActionName(name);
63
+ if (expected === null)
64
+ return true; // dev mode — skip verification
65
+ if (!sig)
66
+ return false;
67
+ // Constant-time comparison (sig is 16 base64url chars = 96 bits)
68
+ try {
69
+ return timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ }
43
75
  /**
44
76
  * SSR and islands coerce `action={myAction}` with `String(myAction)`. The value is the
45
77
  * wrapper from `createAction`, whose default `Function#toString()` is the entire wrapper
46
78
  * source (CSRF, rate limit, …) — that string was being used as the form URL. After
47
79
  * registration we pin `toString` / `@@toPrimitive` to the real POST path.
80
+ * In production (NEXUS_SECRET is set), the URL carries an HMAC query param
81
+ * (`?__sig=…`) so the server can reject calls with unsigned / forged action URLs.
48
82
  */
49
83
  function patchRegisteredActionStringCoercion(fn, name) {
50
- const url = ACTION_PREFIX + name;
84
+ const sig = signActionName(name);
85
+ const url = sig ? `${ACTION_PREFIX}${name}?__sig=${sig}` : `${ACTION_PREFIX}${name}`;
51
86
  Object.defineProperty(fn, 'toString', {
52
87
  value: function nexusActionUrlString() {
53
88
  return url;
@@ -73,7 +108,7 @@ function patchRegisteredActionStringCoercion(fn, name) {
73
108
  * and ready to be called by the client.
74
109
  *
75
110
  * Security layers applied (in order):
76
- * 1. CSRF token validation (x-nexus-action-token header)
111
+ * 1. CSRF: custom header `x-nexus-action: 1` (Tier 1) + optional HMAC token (Tier 2)
77
112
  * 2. Rate limiting (sliding window, per-IP or per-user)
78
113
  * 3. Input schema validation (Zod or any .parse() compatible schema)
79
114
  * 4. AbortController (client disconnect + timeout)
@@ -109,14 +144,33 @@ export function createAction(optsOrFn, legacyOpts = {}) {
109
144
  throw new RateLimitError(result);
110
145
  }
111
146
  }
112
- // 3. Schema validation
147
+ // 3. Schema validation — fail-fast before any business logic or DB access
113
148
  if (opts.schema) {
114
- try {
115
- input = opts.schema.parse(input);
149
+ if (typeof opts.schema.safeParse === 'function') {
150
+ // Structured path: extract field-level errors for the client
151
+ const result = opts.schema.safeParse(input);
152
+ if (!result.success) {
153
+ const issues = result.error?.issues ?? [];
154
+ const fieldErrors = {};
155
+ for (const issue of issues) {
156
+ const path = issue.path.join('.') || '_root';
157
+ fieldErrors[path] = issue.message;
158
+ }
159
+ throw new ActionError(issues.length > 0
160
+ ? `Validation failed: ${issues.map((i) => `${i.path.join('.') || 'input'} — ${i.message}`).join('; ')}`
161
+ : 'Input validation failed', 400, 'VALIDATION_ERROR', fieldErrors);
162
+ }
163
+ if (result.data !== undefined)
164
+ input = result.data;
116
165
  }
117
- catch (err) {
118
- const msg = err instanceof Error ? err.message : 'Input validation failed';
119
- throw new ActionError(`Invalid input: ${msg}`, 400, 'VALIDATION_ERROR');
166
+ else {
167
+ try {
168
+ input = opts.schema.parse(input);
169
+ }
170
+ catch (err) {
171
+ const msg = err instanceof Error ? err.message : 'Input validation failed';
172
+ throw new ActionError(`Invalid input: ${msg}`, 400, 'VALIDATION_ERROR');
173
+ }
120
174
  }
121
175
  }
122
176
  return fn(input, ctx);
@@ -136,10 +190,14 @@ export function getRegisteredActionNames() {
136
190
  export class ActionError extends Error {
137
191
  status;
138
192
  code;
139
- constructor(message, status = 400, code) {
193
+ fieldErrors;
194
+ constructor(message, status = 400, code,
195
+ /** Structured field-level validation errors (Zod-style). Key: field path, Value: message. */
196
+ fieldErrors) {
140
197
  super(message);
141
198
  this.status = status;
142
199
  this.code = code;
200
+ this.fieldErrors = fieldErrors;
143
201
  this.name = 'ActionError';
144
202
  }
145
203
  }
@@ -148,6 +206,41 @@ export class ActionAbortedError extends ActionError {
148
206
  super('Action was superseded by a newer request', 409, 'ABORTED');
149
207
  }
150
208
  }
209
+ /**
210
+ * Detects whether a request originates from a plain HTML form (no JS).
211
+ *
212
+ * A "native form" request:
213
+ * - Has `Content-Type: application/x-www-form-urlencoded`
214
+ * - Does NOT have the `x-nexus-action` custom header (added by the JS runtime)
215
+ * - Does NOT explicitly request `application/json` in Accept
216
+ *
217
+ * When true, the action handler responds with 303 redirects instead of JSON
218
+ * (Progressive Enhancement — the action works even when JS is disabled).
219
+ */
220
+ function isNativeFormRequest(request) {
221
+ const ct = request.headers.get('content-type') ?? '';
222
+ const hasNexusHeader = request.headers.has('x-nexus-action');
223
+ const acceptsJson = (request.headers.get('accept') ?? '').includes('application/json');
224
+ return (ct.includes('application/x-www-form-urlencoded') &&
225
+ !hasNexusHeader &&
226
+ !acceptsJson);
227
+ }
228
+ /**
229
+ * Builds a 303 See Other redirect for progressive-enhancement form submissions.
230
+ * Follows the Post/Redirect/Get pattern to prevent duplicate submissions on refresh.
231
+ *
232
+ * Redirect target priority:
233
+ * 1. `__redirectTo` hidden field in the form
234
+ * 2. `Referer` header (the page that submitted the form)
235
+ * 3. `/` as final fallback
236
+ */
237
+ function formRedirect(request, formData, status = 303) {
238
+ const redirectTo = formData.get('__redirectTo') ??
239
+ request.headers.get('referer') ??
240
+ '/';
241
+ const headers = new Headers({ location: redirectTo });
242
+ return new Response(null, { status, headers });
243
+ }
151
244
  /**
152
245
  * Main HTTP handler for /_nexus/action/:name
153
246
  * This is where all the race-condition logic runs.
@@ -163,6 +256,7 @@ export async function handleActionRequest(request) {
163
256
  headers: { allow: 'POST' },
164
257
  });
165
258
  }
259
+ const nativeForm = isNativeFormRequest(request);
166
260
  // ── Guard: opaque (null) Origin ────────────────────────────────────────────
167
261
  // Sandboxed iframes (<iframe sandbox> without allow-same-origin) and data:
168
262
  // URIs send the string "null" as the Origin header. This is never a
@@ -189,6 +283,46 @@ export async function handleActionRequest(request) {
189
283
  if (!registered) {
190
284
  return jsonResponse({ error: `Action "${actionName}" not found`, status: 404 }, 404);
191
285
  }
286
+ // ── Action URL signature verification (production only) ───────────────────
287
+ // In production (NEXUS_SECRET is set), action URLs are HMAC-signed during SSR.
288
+ // A request without a valid signature means the URL was manually crafted or
289
+ // the action name was enumerated — not server-rendered.
290
+ // In dev mode (no secret), signature check is skipped so hot-reload works.
291
+ const actionSig = url.searchParams.get('__sig');
292
+ if (!verifyActionSig(actionName, actionSig)) {
293
+ emitDevRadar({
294
+ type: 'security:audit',
295
+ payload: {
296
+ kind: 'csrf_blocked',
297
+ message: 'Action URL signature missing or invalid — possible action enumeration',
298
+ action: actionName,
299
+ },
300
+ });
301
+ return jsonResponse({ error: 'Invalid action URL', status: 403, code: 'INVALID_ACTION_SIG' }, 403);
302
+ }
303
+ // ── Build ID (client–server contract) ─────────────────────────────────────
304
+ // When `.nexus/build-id.json` exists (after `nexus build`), every action call
305
+ // must carry `x-nexus-build-id` matching that file. Stale tabs from a prior
306
+ // deploy get 412 so the runtime can reload and pick up new action signatures.
307
+ const expectedBuildId = getExpectedNexusBuildId();
308
+ if (expectedBuildId !== null) {
309
+ const sent = request.headers.get('x-nexus-build-id') ?? '';
310
+ if (sent !== expectedBuildId) {
311
+ emitDevRadar({
312
+ type: 'security:audit',
313
+ payload: {
314
+ kind: 'build_mismatch',
315
+ message: 'x-nexus-build-id does not match deployed build',
316
+ action: actionName,
317
+ },
318
+ });
319
+ return jsonResponse({
320
+ error: 'Application was updated. Please reload the page.',
321
+ status: 412,
322
+ code: 'BUILD_MISMATCH',
323
+ }, 412);
324
+ }
325
+ }
192
326
  const { fn, opts } = registered;
193
327
  const race = opts.race ?? 'cancel';
194
328
  const timeout = opts.timeout ?? 30_000;
@@ -196,7 +330,7 @@ export async function handleActionRequest(request) {
196
330
  //
197
331
  // Tier 1 — Custom header (default): Browsers cannot add arbitrary headers to
198
332
  // cross-origin requests without a CORS preflight the server will reject.
199
- // Requiring `x-nexus-action: 1` blocks all form-based CSRF attacks from
333
+ // Requiring a non-empty `x-nexus-action` header blocks form-based CSRF from
200
334
  // foreign origins without needing token generation on the server.
201
335
  //
202
336
  // Tier 2 — HMAC token (opt-in): When `x-nexus-action-token` is present the
@@ -213,7 +347,13 @@ export async function handleActionRequest(request) {
213
347
  const nexusHeader = request.headers.get('x-nexus-action');
214
348
  if (token) {
215
349
  // Tier 2: full HMAC validation
216
- const secret = process.env['NEXUS_SECRET'] ?? 'nexus-dev-secret-change-me';
350
+ const envSecret = process.env['NEXUS_SECRET'];
351
+ if (!envSecret && process.env['NODE_ENV'] === 'production') {
352
+ // listen() already blocks start without NEXUS_SECRET; this catches
353
+ // edge-runtimes / tests that bypass createNexusServer.
354
+ return jsonResponse({ error: 'Server misconfiguration: NEXUS_SECRET not set', status: 500, code: 'INSECURE_SECRET' }, 500);
355
+ }
356
+ const secret = envSecret ?? 'nexus-dev-secret-change-me';
217
357
  const sessionId = extractSessionId(request);
218
358
  const validation = validateActionToken(token, sessionId, actionName, secret);
219
359
  if (!validation.valid) {
@@ -393,6 +533,11 @@ export async function handleActionRequest(request) {
393
533
  // Cleanup old entries periodically
394
534
  cleanIdempotencyCache();
395
535
  }
536
+ // Progressive Enhancement: native HTML form → Post/Redirect/Get pattern
537
+ if (nativeForm) {
538
+ const fd = input instanceof FormData ? input : new FormData();
539
+ return formRedirect(request, fd);
540
+ }
396
541
  // Serialize response with Nexus transport
397
542
  const serialized = serialize({ data: result, status: 200, duration, idempotencyKey });
398
543
  return new Response(serialized, {
@@ -442,19 +587,50 @@ export async function handleActionRequest(request) {
442
587
  ...(err.code !== undefined ? { code: err.code } : {}),
443
588
  },
444
589
  });
445
- return jsonResponse({ error: err.message, status: err.status, code: err.code }, err.status);
590
+ // Progressive Enhancement: redirect back with error encoded in query string
591
+ if (nativeForm) {
592
+ const referer = request.headers.get('referer') ?? '/';
593
+ const target = new URL(referer, request.url);
594
+ target.searchParams.set('__nexus_error', err.message);
595
+ if (err.code)
596
+ target.searchParams.set('__nexus_code', err.code);
597
+ return new Response(null, {
598
+ status: 303,
599
+ headers: { location: target.toString() },
600
+ });
601
+ }
602
+ return jsonResponse({
603
+ error: err.message,
604
+ status: err.status,
605
+ code: err.code,
606
+ ...(err.fieldErrors ? { fieldErrors: err.fieldErrors } : {}),
607
+ }, err.status);
446
608
  }
609
+ const errorId = randomUUID();
610
+ const mask = process.env['NEXUS_EXPOSE_ERRORS'] !== 'true' &&
611
+ process.env['NODE_ENV'] === 'production';
612
+ console.error(`[Nexus Action ${errorId}] "${actionName}" failed:`, err);
447
613
  emitDevRadar({
448
614
  type: 'action:error',
449
615
  payload: {
450
616
  id: traceId,
451
617
  name: actionName,
452
- error: err instanceof Error ? err.message : String(err),
618
+ error: mask ? 'Internal Server Error' : (err instanceof Error ? err.message : String(err)),
453
619
  duration: Date.now() - startTime,
620
+ ...(mask ? { errorId } : {}),
454
621
  },
455
622
  });
456
- console.error(`[Nexus Action] "${actionName}" failed:`, err);
457
- return jsonResponse({ error: 'Internal Server Error', status: 500 }, 500);
623
+ if (mask) {
624
+ return jsonResponse({
625
+ error: 'Internal Server Error',
626
+ status: 500,
627
+ errorId,
628
+ }, 500);
629
+ }
630
+ return jsonResponse({
631
+ error: err instanceof Error ? err.message : String(err),
632
+ status: 500,
633
+ }, 500);
458
634
  }
459
635
  finally {
460
636
  clearTimeout(timeoutId);
@@ -479,16 +655,89 @@ export async function validateRequest(ctx) {
479
655
  // Same-origin and same-site fetch() calls either omit Origin or match the host.
480
656
  const origin = ctx.request.headers.get('origin');
481
657
  const referer = ctx.request.headers.get('referer');
482
- const host = ctx.request.headers.get('host') ?? '';
483
- const suspectOrigin = origin && !origin.includes(host.split(':')[0] ?? host);
484
- const suspectReferer = !origin && referer && !referer.includes(host.split(':')[0] ?? host);
658
+ const rawHost = ctx.request.headers.get('host') ?? '';
659
+ // Derive scheme from X-Forwarded-Proto or default to http (covered by HTTPS terminator upstream)
660
+ const proto = ctx.request.headers.get('x-forwarded-proto') ?? 'http';
661
+ const expectedOrigin = `${proto}://${rawHost}`;
662
+ function isSameOrigin(headerValue) {
663
+ try {
664
+ const parsed = new URL(headerValue);
665
+ const expected = new URL(expectedOrigin);
666
+ return parsed.protocol === expected.protocol
667
+ && parsed.hostname === expected.hostname
668
+ && parsed.port === expected.port;
669
+ }
670
+ catch {
671
+ return false;
672
+ }
673
+ }
674
+ const suspectOrigin = origin && !isSameOrigin(origin);
675
+ const suspectReferer = !origin && referer && !isSameOrigin(referer);
485
676
  if (suspectOrigin || suspectReferer) {
486
- throw new ActionError(`Cross-origin action request blocked (host: ${host})`, 403, 'CROSS_ORIGIN_BLOCKED');
677
+ throw new ActionError(`Cross-origin action request blocked (host: ${rawHost})`, 403, 'CROSS_ORIGIN_BLOCKED');
487
678
  }
488
679
  }
489
680
  // Re-export security primitives for use in app code
490
681
  export { generateActionToken, validateActionToken, extractSessionId, generateSessionId } from './csrf.js';
491
682
  export { createRateLimiter, RateLimitError, parseWindow } from './rate-limit.js';
683
+ /**
684
+ * Returns `true` when `url` is a safe **public** `http:` / `https:` target for
685
+ * server-side `fetch` (not loopback, RFC1918, link-local, metadata IPs, etc.).
686
+ * Use before `fetch(userUrl)` to reduce blind SSRF risk.
687
+ */
688
+ export function isSafeUrl(url) {
689
+ try {
690
+ const u = new URL(url);
691
+ if (u.protocol !== 'http:' && u.protocol !== 'https:')
692
+ return false;
693
+ }
694
+ catch {
695
+ return false;
696
+ }
697
+ return !isInternalUrl(url);
698
+ }
699
+ /**
700
+ * Returns `true` when a URL resolves to a private, loopback, or link-local
701
+ * address. Inverse of {@link isSafeUrl} for `http:` / `https:`.
702
+ */
703
+ export function isInternalUrl(url) {
704
+ let parsed;
705
+ try {
706
+ parsed = new URL(url);
707
+ }
708
+ catch {
709
+ return false; // unparseable URLs are not our SSRF concern — let the caller decide
710
+ }
711
+ // Only http/https are fetched in typical web actions; block everything else
712
+ // (file://, ftp://, etc.) as they access local resources.
713
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
714
+ return true;
715
+ const h = parsed.hostname.toLowerCase();
716
+ // Loopback
717
+ if (h === 'localhost' || h === '127.0.0.1' || h === '::1')
718
+ return true;
719
+ if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h))
720
+ return true;
721
+ // Link-local (AWS/GCP/Azure metadata service lives at 169.254.169.254)
722
+ if (h.startsWith('169.254.'))
723
+ return true;
724
+ if (h === 'fe80::1' || h.startsWith('fe80:'))
725
+ return true;
726
+ // RFC 1918 private ranges
727
+ if (h.startsWith('10.'))
728
+ return true;
729
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(h))
730
+ return true;
731
+ if (h.startsWith('192.168.'))
732
+ return true;
733
+ // Unique local IPv6
734
+ if (/^fd[0-9a-f]{2}:/i.test(h) || h.startsWith('fc'))
735
+ return true;
736
+ // Unspecified / broadcast
737
+ if (h === '0.0.0.0' || h === '255.255.255.255')
738
+ return true;
739
+ return false;
740
+ }
492
741
  // ── Client-side race guard ────────────────────────────────────────────────────
493
742
  /**
494
743
  * Client-side AbortController factory.
@@ -531,6 +780,53 @@ export function createActionGuard(name, strategy = 'cancel') {
531
780
  // ── Helpers ───────────────────────────────────────────────────────────────────
532
781
  /** Maximum request body for a single server action (10 MB). Override per-action via opts.maxBodyBytes. */
533
782
  const MAX_ACTION_BODY_BYTES = 10 * 1_024 * 1_024;
783
+ /** Maximum JSON object nesting depth. Prevents CPU DoS via pathological `{"a":{"b":{…}}}` inputs. */
784
+ const MAX_JSON_DEPTH = 10;
785
+ /** Maximum number of JSON object keys across all nesting levels. Prevents key-explosion attacks. */
786
+ const MAX_JSON_KEYS = 1_000;
787
+ /**
788
+ * Walks the raw JSON text character-by-character (O(n)) tracking nesting depth and
789
+ * key count WITHOUT running the full parser. Throws ActionError before `JSON.parse`
790
+ * touches the data — prevents CPU DoS from deeply nested or key-explosion payloads
791
+ * that fit within MAX_ACTION_BODY_BYTES but would block the event loop for seconds.
792
+ */
793
+ function assertJsonComplexity(text) {
794
+ let depth = 0;
795
+ let keys = 0;
796
+ let inStr = false;
797
+ let esc = false;
798
+ for (let i = 0; i < text.length; i++) {
799
+ const ch = text[i];
800
+ if (esc) {
801
+ esc = false;
802
+ continue;
803
+ }
804
+ if (ch === '\\' && inStr) {
805
+ esc = true;
806
+ continue;
807
+ }
808
+ if (ch === '"') {
809
+ inStr = !inStr;
810
+ continue;
811
+ }
812
+ if (inStr) {
813
+ continue;
814
+ }
815
+ if (ch === '{' || ch === '[') {
816
+ if (++depth > MAX_JSON_DEPTH) {
817
+ throw new ActionError(`JSON nesting too deep (limit ${MAX_JSON_DEPTH} levels)`, 400, 'JSON_TOO_DEEP');
818
+ }
819
+ }
820
+ else if (ch === '}' || ch === ']') {
821
+ depth--;
822
+ }
823
+ else if (ch === ':') {
824
+ if (++keys > MAX_JSON_KEYS) {
825
+ throw new ActionError(`JSON too complex (limit ${MAX_JSON_KEYS} keys)`, 400, 'JSON_TOO_COMPLEX');
826
+ }
827
+ }
828
+ }
829
+ }
534
830
  async function deserializeInput(request, maxBytes = MAX_ACTION_BODY_BYTES) {
535
831
  // DoS protection: reject oversized bodies before reading any bytes.
536
832
  // Prevents memory exhaustion from clients that stream arbitrarily large payloads.
@@ -544,6 +840,9 @@ async function deserializeInput(request, maxBytes = MAX_ACTION_BODY_BYTES) {
544
840
  if (text.length > maxBytes) {
545
841
  throw new ActionError('Request body too large', 413, 'PAYLOAD_TOO_LARGE');
546
842
  }
843
+ // Complexity guard: run BEFORE the parser so pathological payloads (deeply
844
+ // nested or key-explosion) are rejected without blocking the event loop.
845
+ assertJsonComplexity(text);
547
846
  try {
548
847
  return deserialize(text);
549
848
  }