veryfront 0.1.228 → 0.1.230

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 (46) hide show
  1. package/esm/deno.js +1 -1
  2. package/esm/src/agent/hosted-child-lifecycle.d.ts +41 -0
  3. package/esm/src/agent/hosted-child-lifecycle.d.ts.map +1 -0
  4. package/esm/src/agent/hosted-child-lifecycle.js +47 -0
  5. package/esm/src/agent/hosted-lifecycle.d.ts +41 -0
  6. package/esm/src/agent/hosted-lifecycle.d.ts.map +1 -0
  7. package/esm/src/agent/hosted-lifecycle.js +77 -0
  8. package/esm/src/agent/index.d.ts +2 -0
  9. package/esm/src/agent/index.d.ts.map +1 -1
  10. package/esm/src/agent/index.js +2 -0
  11. package/esm/src/agent/runtime/sse-utils.d.ts.map +1 -1
  12. package/esm/src/agent/runtime/sse-utils.js +9 -1
  13. package/esm/src/channels/control-plane.d.ts +21 -0
  14. package/esm/src/channels/control-plane.d.ts.map +1 -1
  15. package/esm/src/channels/control-plane.js +48 -0
  16. package/esm/src/channels/invoke.d.ts +1 -1
  17. package/esm/src/channels/invoke.d.ts.map +1 -1
  18. package/esm/src/channels/invoke.js +1 -1
  19. package/esm/src/server/handlers/preview/hmr.handler.d.ts.map +1 -1
  20. package/esm/src/server/handlers/preview/hmr.handler.js +21 -9
  21. package/esm/src/server/runtime-handler/adapter-factory.d.ts +5 -2
  22. package/esm/src/server/runtime-handler/adapter-factory.d.ts.map +1 -1
  23. package/esm/src/server/runtime-handler/adapter-factory.js +18 -1
  24. package/esm/src/server/runtime-handler/index.d.ts.map +1 -1
  25. package/esm/src/server/runtime-handler/index.js +5 -2
  26. package/esm/src/server/services/rsc/orchestrators/page-handler.d.ts.map +1 -1
  27. package/esm/src/server/services/rsc/orchestrators/page-handler.js +22 -1
  28. package/esm/src/server/utils/proxy-trust.d.ts +33 -0
  29. package/esm/src/server/utils/proxy-trust.d.ts.map +1 -0
  30. package/esm/src/server/utils/proxy-trust.js +41 -0
  31. package/esm/src/utils/version-constant.d.ts +1 -1
  32. package/esm/src/utils/version-constant.js +1 -1
  33. package/package.json +1 -1
  34. package/src/deno.js +1 -1
  35. package/src/src/agent/hosted-child-lifecycle.ts +121 -0
  36. package/src/src/agent/hosted-lifecycle.ts +159 -0
  37. package/src/src/agent/index.ts +15 -0
  38. package/src/src/agent/runtime/sse-utils.ts +9 -1
  39. package/src/src/channels/control-plane.ts +52 -0
  40. package/src/src/channels/invoke.ts +1 -1
  41. package/src/src/server/handlers/preview/hmr.handler.ts +32 -26
  42. package/src/src/server/runtime-handler/adapter-factory.ts +23 -3
  43. package/src/src/server/runtime-handler/index.ts +5 -2
  44. package/src/src/server/services/rsc/orchestrators/page-handler.ts +23 -1
  45. package/src/src/server/utils/proxy-trust.ts +56 -0
  46. package/src/src/utils/version-constant.ts +1 -1
@@ -1,4 +1,24 @@
1
1
  import { buildNonceAttribute } from "../../../../html/html-escape.js";
2
+ /**
3
+ * Serialize a value as a JSON string literal that is safe to embed inside an
4
+ * inline HTML <script>. JSON already escapes quotes, backslashes, and control
5
+ * characters; we additionally escape:
6
+ * - `<` / `>` so `</script>`, `<!--`, and `<script` cannot appear literally,
7
+ * - `&` as defense-in-depth against reparsing contexts (e.g. HTML entity
8
+ * re-decoding in some legacy paths),
9
+ * - U+2028 / U+2029 which are valid JSON but terminate JS string literals
10
+ * in older browsers.
11
+ *
12
+ * See VULN-INJ-1 in the security audit.
13
+ */
14
+ function jsonForScript(value) {
15
+ return JSON.stringify(value)
16
+ .replace(/</g, "\\u003c")
17
+ .replace(/>/g, "\\u003e")
18
+ .replace(/&/g, "\\u0026")
19
+ .replace(/\u2028/g, "\\u2028")
20
+ .replace(/\u2029/g, "\\u2029");
21
+ }
2
22
  export class PageHandler {
3
23
  handle(pathname, searchParams, nonce) {
4
24
  const html = this.buildHtml(pathname, searchParams, nonce);
@@ -10,6 +30,7 @@ export class PageHandler {
10
30
  const queryString = searchParams.toString();
11
31
  const renderUrl = `/_veryfront/rsc/render${pathname}${queryString ? `?${queryString}` : ""}`;
12
32
  const nonceAttr = buildNonceAttribute(nonce);
33
+ const renderUrlJs = jsonForScript(renderUrl);
13
34
  return `<!DOCTYPE html>
14
35
  <html lang="en">
15
36
  <head>
@@ -57,7 +78,7 @@ export class PageHandler {
57
78
  }
58
79
 
59
80
  (async () => {
60
- const renderUrl = '${renderUrl}';
81
+ const renderUrl = ${renderUrlJs};
61
82
  const payload =
62
83
  (await fetchPayload(renderUrl)) ??
63
84
  (await fetchPayload('/_veryfront/rsc/payload')) ??
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Proxy trust boundary.
3
+ *
4
+ * Forwarded headers such as `x-forwarded-host` and `x-project-path` must only be
5
+ * honoured when the request is known to come from a trusted upstream proxy.
6
+ * Any other treatment lets an attacker reaching the runtime directly spoof the
7
+ * origin host or point project discovery at arbitrary filesystem paths.
8
+ *
9
+ * A request is considered proxy-trusted when either:
10
+ * 1. The operator has opted in via `VERYFRONT_TRUST_FORWARDED_HEADERS=1`
11
+ * (strict "1" match — "true", "yes", whitespace-padded values do NOT count
12
+ * so misconfiguration fails closed); or
13
+ * 2. The request carries a valid `x-veryfront-dispatch-jws` header that
14
+ * cryptographically verifies against the configured control-plane public
15
+ * key and whose `iat`/`exp` claims are within the allowed freshness
16
+ * window. Presence alone is NOT trusted because the proxy does not strip
17
+ * this header from untrusted inbound requests (it has to pass through to
18
+ * the channel-invoke / channel-assistants handlers unchanged), so a
19
+ * direct-access attacker could otherwise set any value and promote
20
+ * forwarded-header spoofing.
21
+ *
22
+ * @module server/utils/proxy-trust
23
+ */
24
+ export interface ProxyTrustOptions {
25
+ /**
26
+ * PEM-encoded Ed25519 public key used to verify `x-veryfront-dispatch-jws`.
27
+ * When absent, the dispatch-JWS trust signal is disabled (fails closed) and
28
+ * only the operator opt-in env var can unlock proxy trust.
29
+ */
30
+ publicKeyPem?: string;
31
+ }
32
+ export declare function isProxyTrusted(req: Request, options?: ProxyTrustOptions): Promise<boolean>;
33
+ //# sourceMappingURL=proxy-trust.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"proxy-trust.d.ts","sourceRoot":"","sources":["../../../../src/src/server/utils/proxy-trust.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAQH,MAAM,WAAW,iBAAiB;IAChC;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,wBAAsB,cAAc,CAClC,GAAG,EAAE,OAAO,EACZ,OAAO,GAAE,iBAAsB,GAC9B,OAAO,CAAC,OAAO,CAAC,CAalB"}
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Proxy trust boundary.
3
+ *
4
+ * Forwarded headers such as `x-forwarded-host` and `x-project-path` must only be
5
+ * honoured when the request is known to come from a trusted upstream proxy.
6
+ * Any other treatment lets an attacker reaching the runtime directly spoof the
7
+ * origin host or point project discovery at arbitrary filesystem paths.
8
+ *
9
+ * A request is considered proxy-trusted when either:
10
+ * 1. The operator has opted in via `VERYFRONT_TRUST_FORWARDED_HEADERS=1`
11
+ * (strict "1" match — "true", "yes", whitespace-padded values do NOT count
12
+ * so misconfiguration fails closed); or
13
+ * 2. The request carries a valid `x-veryfront-dispatch-jws` header that
14
+ * cryptographically verifies against the configured control-plane public
15
+ * key and whose `iat`/`exp` claims are within the allowed freshness
16
+ * window. Presence alone is NOT trusted because the proxy does not strip
17
+ * this header from untrusted inbound requests (it has to pass through to
18
+ * the channel-invoke / channel-assistants handlers unchanged), so a
19
+ * direct-access attacker could otherwise set any value and promote
20
+ * forwarded-header spoofing.
21
+ *
22
+ * @module server/utils/proxy-trust
23
+ */
24
+ import { verifyDispatchJwsSignature } from "../../channels/control-plane.js";
25
+ import { getHostEnv } from "../../platform/compat/process.js";
26
+ const DISPATCH_JWS_HEADER = "x-veryfront-dispatch-jws";
27
+ const MAX_DISPATCH_SIGNATURE_AGE_SECONDS = 60;
28
+ export async function isProxyTrusted(req, options = {}) {
29
+ if (getHostEnv("VERYFRONT_TRUST_FORWARDED_HEADERS") === "1")
30
+ return true;
31
+ const jws = req.headers.get(DISPATCH_JWS_HEADER);
32
+ if (!jws)
33
+ return false;
34
+ const { publicKeyPem } = options;
35
+ if (!publicKeyPem)
36
+ return false;
37
+ return verifyDispatchJwsSignature(jws, {
38
+ publicKeyPem,
39
+ maxAgeSeconds: MAX_DISPATCH_SIGNATURE_AGE_SECONDS,
40
+ });
41
+ }
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.1.228";
1
+ export declare const VERSION = "0.1.230";
2
2
  //# sourceMappingURL=version-constant.d.ts.map
@@ -1,3 +1,3 @@
1
1
  // Keep in sync with deno.json version.
2
2
  // scripts/release.ts updates this constant during releases.
3
- export const VERSION = "0.1.228";
3
+ export const VERSION = "0.1.230";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "veryfront",
3
- "version": "0.1.228",
3
+ "version": "0.1.230",
4
4
  "description": "The simplest way to build AI-powered apps",
5
5
  "keywords": [
6
6
  "react",
package/src/deno.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export default {
2
2
  "name": "veryfront",
3
- "version": "0.1.228",
3
+ "version": "0.1.230",
4
4
  "license": "Apache-2.0",
5
5
  "nodeModulesDir": "auto",
6
6
  "workspace": [
@@ -0,0 +1,121 @@
1
+ export interface HostedChildLifecycleTerminalState {
2
+ status: "completed" | "failed" | "cancelled";
3
+ usage?: {
4
+ inputTokens?: number;
5
+ outputTokens?: number;
6
+ totalTokens?: number;
7
+ };
8
+ terminalErrorCode?: string | null;
9
+ terminalErrorMessage?: string | null;
10
+ }
11
+
12
+ export interface HostedChildLifecycleCompletedState
13
+ extends Omit<HostedChildLifecycleTerminalState, "status"> {
14
+ status: "completed";
15
+ }
16
+
17
+ export interface HostedChildLifecycleAdapter {
18
+ pending?: () => Promise<void> | void;
19
+ running?: () => Promise<void> | void;
20
+ completed?: (
21
+ terminalState: HostedChildLifecycleTerminalState,
22
+ ) => Promise<void> | void;
23
+ failed?: (
24
+ terminalState: HostedChildLifecycleTerminalState,
25
+ ) => Promise<void> | void;
26
+ cancelled?: (
27
+ terminalState: HostedChildLifecycleTerminalState,
28
+ ) => Promise<void> | void;
29
+ }
30
+
31
+ export interface HostedChildLifecycleErrorState
32
+ extends Omit<HostedChildLifecycleTerminalState, "status"> {
33
+ status: "failed" | "cancelled";
34
+ }
35
+
36
+ export interface HostedChildLifecycleRunnerOptions<TResult> {
37
+ adapter: HostedChildLifecycleAdapter;
38
+ execute: () => Promise<TResult> | TResult;
39
+ resolveCompletedState?: (
40
+ result: TResult,
41
+ ) =>
42
+ | Promise<HostedChildLifecycleCompletedState>
43
+ | HostedChildLifecycleCompletedState;
44
+ resolveErrorState: (
45
+ error: unknown,
46
+ ) =>
47
+ | Promise<HostedChildLifecycleErrorState>
48
+ | HostedChildLifecycleErrorState;
49
+ onLifecycleError?: (error: unknown) => Promise<void> | void;
50
+ }
51
+
52
+ export type HostedChildLifecycleRunResult<TResult> =
53
+ | {
54
+ status: "completed";
55
+ result: TResult;
56
+ terminalState: HostedChildLifecycleTerminalState;
57
+ }
58
+ | {
59
+ status: "failed" | "cancelled";
60
+ error: unknown;
61
+ terminalState: HostedChildLifecycleTerminalState;
62
+ };
63
+
64
+ async function dispatchTerminalState(
65
+ adapter: HostedChildLifecycleAdapter,
66
+ terminalState: HostedChildLifecycleTerminalState,
67
+ ): Promise<void> {
68
+ if (terminalState.status === "cancelled") {
69
+ await adapter.cancelled?.(terminalState);
70
+ return;
71
+ }
72
+
73
+ if (terminalState.status === "failed") {
74
+ await adapter.failed?.(terminalState);
75
+ return;
76
+ }
77
+
78
+ await adapter.completed?.(terminalState);
79
+ }
80
+
81
+ export async function runHostedChildLifecycle<TResult>(
82
+ options: HostedChildLifecycleRunnerOptions<TResult>,
83
+ ): Promise<HostedChildLifecycleRunResult<TResult>> {
84
+ await options.adapter.pending?.();
85
+ await options.adapter.running?.();
86
+
87
+ let result: TResult;
88
+ try {
89
+ result = await options.execute();
90
+ } catch (error) {
91
+ const terminalState = await options.resolveErrorState(error);
92
+
93
+ try {
94
+ await dispatchTerminalState(options.adapter, terminalState);
95
+ } catch (lifecycleError) {
96
+ if (options.onLifecycleError) {
97
+ await options.onLifecycleError(lifecycleError);
98
+ } else {
99
+ throw lifecycleError;
100
+ }
101
+ }
102
+
103
+ return {
104
+ status: terminalState.status,
105
+ error,
106
+ terminalState,
107
+ };
108
+ }
109
+
110
+ const terminalState = options.resolveCompletedState
111
+ ? await options.resolveCompletedState(result)
112
+ : { status: "completed" as const };
113
+
114
+ await dispatchTerminalState(options.adapter, terminalState);
115
+
116
+ return {
117
+ status: "completed",
118
+ result,
119
+ terminalState,
120
+ };
121
+ }
@@ -0,0 +1,159 @@
1
+ export interface HostedLifecycleTerminalState {
2
+ status: "completed" | "failed" | "cancelled";
3
+ metadata?: {
4
+ modelId?: string;
5
+ usage?: {
6
+ inputTokens?: number;
7
+ outputTokens?: number;
8
+ cachedInputTokens?: number;
9
+ };
10
+ };
11
+ terminalErrorCode?: string | null;
12
+ terminalErrorMessage?: string | null;
13
+ }
14
+
15
+ export interface HostedLifecycleExecution<TChunk> {
16
+ stream: AsyncIterable<TChunk>;
17
+ waitForFinish: () => Promise<void>;
18
+ }
19
+
20
+ export interface HostedLifecycleAdapter<TRun, TChunk> {
21
+ startRun: (input: { abortSignal: AbortSignal }) => Promise<TRun> | TRun;
22
+ appendEvents?: (run: TRun, chunk: TChunk) => Promise<void> | void;
23
+ persistTranscriptChunk?: (run: TRun, chunk: TChunk) => Promise<void> | void;
24
+ persistTranscriptTerminalState?: (
25
+ run: TRun,
26
+ terminalState: HostedLifecycleTerminalState,
27
+ ) => Promise<void> | void;
28
+ onTerminalState?: (
29
+ run: TRun,
30
+ terminalState: HostedLifecycleTerminalState,
31
+ ) => Promise<void> | void;
32
+ finalizeRun?: (run: TRun, terminalState: HostedLifecycleTerminalState) => Promise<void> | void;
33
+ cancelRun?: (run: TRun, terminalState: HostedLifecycleTerminalState) => Promise<void> | void;
34
+ }
35
+
36
+ export interface HostedLifecycleRunnerOptions<TRun, TChunk> {
37
+ abortSignal: AbortSignal;
38
+ execution: HostedLifecycleExecution<TChunk>;
39
+ adapter: HostedLifecycleAdapter<TRun, TChunk>;
40
+ resolveTerminalState: () => Promise<HostedLifecycleTerminalState> | HostedLifecycleTerminalState;
41
+ resolveErrorTerminalState?: (
42
+ error: unknown,
43
+ ) => Promise<HostedLifecycleTerminalState> | HostedLifecycleTerminalState;
44
+ }
45
+
46
+ export interface HostedLifecycleRunResult<TRun> {
47
+ run: TRun;
48
+ terminalState: HostedLifecycleTerminalState;
49
+ }
50
+
51
+ function getTerminalErrorMessage(error: unknown): string {
52
+ return error instanceof Error ? error.message : String(error);
53
+ }
54
+
55
+ function defaultErrorTerminalState(
56
+ abortSignal: AbortSignal,
57
+ error: unknown,
58
+ ): HostedLifecycleTerminalState {
59
+ if (abortSignal.aborted) {
60
+ return {
61
+ status: "cancelled",
62
+ terminalErrorCode: "ABORTED",
63
+ terminalErrorMessage: getTerminalErrorMessage(error),
64
+ };
65
+ }
66
+
67
+ return {
68
+ status: "failed",
69
+ terminalErrorCode: "STREAM_ERROR",
70
+ terminalErrorMessage: getTerminalErrorMessage(error),
71
+ };
72
+ }
73
+
74
+ async function captureHookError(
75
+ callback: (() => Promise<void> | void) | undefined,
76
+ ): Promise<unknown | null> {
77
+ if (!callback) {
78
+ return null;
79
+ }
80
+
81
+ try {
82
+ await callback();
83
+ return null;
84
+ } catch (error) {
85
+ return error;
86
+ }
87
+ }
88
+
89
+ async function runTerminalHooks<TRun, TChunk>(input: {
90
+ run: TRun;
91
+ terminalState: HostedLifecycleTerminalState;
92
+ adapter: HostedLifecycleAdapter<TRun, TChunk>;
93
+ }): Promise<void> {
94
+ let firstHookError: unknown | null = null;
95
+
96
+ const persistError = await captureHookError(() =>
97
+ input.adapter.persistTranscriptTerminalState?.(input.run, input.terminalState)
98
+ );
99
+ if (persistError) {
100
+ firstHookError = persistError;
101
+ }
102
+
103
+ const terminalObserverError = await captureHookError(() =>
104
+ input.adapter.onTerminalState?.(input.run, input.terminalState)
105
+ );
106
+ if (!firstHookError && terminalObserverError) {
107
+ firstHookError = terminalObserverError;
108
+ }
109
+
110
+ const terminalControlError = await captureHookError(() =>
111
+ input.terminalState.status === "cancelled"
112
+ ? input.adapter.cancelRun?.(input.run, input.terminalState)
113
+ : input.adapter.finalizeRun?.(input.run, input.terminalState)
114
+ );
115
+
116
+ if (firstHookError) {
117
+ throw firstHookError;
118
+ }
119
+
120
+ if (terminalControlError) {
121
+ throw terminalControlError;
122
+ }
123
+ }
124
+
125
+ export async function runHostedLifecycle<TRun, TChunk>(
126
+ options: HostedLifecycleRunnerOptions<TRun, TChunk>,
127
+ ): Promise<HostedLifecycleRunResult<TRun>> {
128
+ const run = await options.adapter.startRun({ abortSignal: options.abortSignal });
129
+
130
+ try {
131
+ for await (const chunk of options.execution.stream) {
132
+ await options.adapter.appendEvents?.(run, chunk);
133
+ await options.adapter.persistTranscriptChunk?.(run, chunk);
134
+ }
135
+
136
+ await options.execution.waitForFinish();
137
+ } catch (error) {
138
+ const terminalState = options.resolveErrorTerminalState
139
+ ? await options.resolveErrorTerminalState(error)
140
+ : defaultErrorTerminalState(options.abortSignal, error);
141
+
142
+ await runTerminalHooks({
143
+ run,
144
+ terminalState,
145
+ adapter: options.adapter,
146
+ }).catch(() => undefined);
147
+
148
+ throw error;
149
+ }
150
+
151
+ const terminalState = await options.resolveTerminalState();
152
+ await runTerminalHooks({
153
+ run,
154
+ terminalState,
155
+ adapter: options.adapter,
156
+ });
157
+
158
+ return { run, terminalState };
159
+ }
@@ -180,6 +180,21 @@ export {
180
180
  createAgUiBrowserResponseStream,
181
181
  type CreateAgUiBrowserResponseStreamInput,
182
182
  } from "./ag-ui-browser-response-stream.js";
183
+ export {
184
+ type HostedChildLifecycleAdapter,
185
+ type HostedChildLifecycleRunnerOptions,
186
+ type HostedChildLifecycleRunResult,
187
+ type HostedChildLifecycleTerminalState,
188
+ runHostedChildLifecycle,
189
+ } from "./hosted-child-lifecycle.js";
190
+ export {
191
+ type HostedLifecycleAdapter,
192
+ type HostedLifecycleExecution,
193
+ type HostedLifecycleRunnerOptions,
194
+ type HostedLifecycleRunResult,
195
+ type HostedLifecycleTerminalState,
196
+ runHostedLifecycle,
197
+ } from "./hosted-lifecycle.js";
183
198
  export {
184
199
  mergeToolCallInput,
185
200
  mergeToolInputDelta,
@@ -20,7 +20,15 @@ export function sendSSE(
20
20
  encoder: TextEncoder,
21
21
  event: Record<string, unknown>,
22
22
  ): void {
23
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
23
+ try {
24
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
25
+ } catch (error) {
26
+ if (isClosedStreamControllerError(error)) {
27
+ return;
28
+ }
29
+
30
+ throw error;
31
+ }
24
32
  }
25
33
 
26
34
  export function closeSSEStream(controller: ReadableStreamDefaultController): void {
@@ -294,6 +294,58 @@ export async function listRuntimeAgents(
294
294
  return RuntimeAgentListResponseSchema.parse({ agents });
295
295
  }
296
296
 
297
+ /**
298
+ * Verify the Ed25519 signature of a dispatch JWS and the recency of its
299
+ * timestamps, without binding to a particular request body or audience.
300
+ *
301
+ * This is intentionally weaker than {@link verifyDispatchJws}: it answers
302
+ * "was this JWS minted by a holder of the control-plane private key and is it
303
+ * still fresh?" and is used as a trust signal in code paths (proxy-trust,
304
+ * adapter selection) that don't yet have access to the authoritative request
305
+ * body or project audience. Callers that consume request payloads MUST still
306
+ * call {@link verifyDispatchJws} / {@link verifyControlPlaneJws} to bind the
307
+ * signature to the body and project.
308
+ *
309
+ * Returns true iff the signature verifies and `iat`/`exp` are within the
310
+ * allowed skew and max-age window. All other failures (including parsing
311
+ * errors) resolve to false so callers can treat the signal as present-but-not-
312
+ * proven without raising.
313
+ */
314
+ export async function verifyDispatchJwsSignature(
315
+ jws: string,
316
+ options: {
317
+ publicKeyPem: string;
318
+ maxAgeSeconds: number;
319
+ },
320
+ ): Promise<boolean> {
321
+ try {
322
+ const parts = jws.split(".");
323
+ if (parts.length !== 3) return false;
324
+ const [encodedHeader, encodedPayload, encodedSignature] = parts;
325
+ if (!encodedHeader || !encodedPayload || !encodedSignature) return false;
326
+
327
+ compactJwsHeaderSchema.parse(parseCompactJwsPart(encodedHeader));
328
+ const claims = dispatchClaimsSchema.parse(parseCompactJwsPart(encodedPayload));
329
+
330
+ const signingInput = new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`);
331
+ const signature = base64urlDecodeToBytes(encodedSignature);
332
+ const publicKey = await importEd25519PublicKey(options.publicKeyPem);
333
+ const verified = await dntShim.crypto.subtle.verify("Ed25519", publicKey, signature, signingInput);
334
+ if (!verified) return false;
335
+
336
+ if (claims.iss !== "veryfront-api") return false;
337
+
338
+ const now = Math.floor(Date.now() / 1000);
339
+ if (claims.exp <= now) return false;
340
+ if (claims.iat > now + SIGNATURE_SKEW_SECONDS) return false;
341
+ if (now - claims.iat > options.maxAgeSeconds) return false;
342
+
343
+ return true;
344
+ } catch {
345
+ return false;
346
+ }
347
+ }
348
+
297
349
  export async function verifyDispatchJws(
298
350
  jws: string,
299
351
  body: string,
@@ -153,7 +153,7 @@ export async function listChannelAssistants(
153
153
 
154
154
  return ChannelAssistantsResponseSchema.parse({ assistants });
155
155
  }
156
- export { verifyDispatchJws } from "./control-plane.js";
156
+ export { verifyDispatchJws, verifyDispatchJwsSignature } from "./control-plane.js";
157
157
 
158
158
  function normalizeConversationPart(
159
159
  part: z.infer<typeof rawHistoryPartSchema>,
@@ -29,6 +29,8 @@ import {
29
29
  import { getPingIntervalMs, startPingInterval, stopPingInterval } from "./hmr-ping-keepalive.js";
30
30
  import { broadcastUpdate, getMetrics } from "./hmr-message-router.js";
31
31
  import { getEffectiveRequestHost } from "../../utils/request-host.js";
32
+ import { isProxyTrusted } from "../../utils/proxy-trust.js";
33
+ import { getHostEnv } from "../../../platform/compat/process.js";
32
34
 
33
35
  const logger = serverLogger.component("hmr-handler");
34
36
 
@@ -89,14 +91,24 @@ export class HMRHandler extends BaseHandler {
89
91
  });
90
92
  }
91
93
 
92
- handle(req: Request, ctx: HandlerContext): Promise<HandlerResult> {
93
- if (!this.shouldHandle(req, ctx)) return Promise.resolve(this.continue());
94
+ async handle(req: Request, ctx: HandlerContext): Promise<HandlerResult> {
95
+ if (!this.shouldHandle(req, ctx)) return this.continue();
94
96
 
95
97
  const url = new URL(req.url);
96
98
  const queryEnv = url.searchParams.get("x-environment");
97
99
  const isPreviewMode = ctx.requestContext?.mode === "preview" || queryEnv === "preview";
98
100
  const isLocal = !!ctx.isLocalProject;
99
- const host = getEffectiveRequestHost(req, url);
101
+ // SECURITY: x-forwarded-host is client-controlled unless we trust the upstream proxy.
102
+ // Honouring it unconditionally lets any remote client present `x-forwarded-host: localhost`
103
+ // and unlock the localhost short-circuit that opens HMR (VULN-SRV-4). Only consult
104
+ // forwarded headers when the request is proxy-trusted; otherwise use Host / url.host.
105
+ // Proxy trust requires a verifiable dispatch JWS (or operator opt-in) — mere header
106
+ // presence is not enough, since `x-veryfront-dispatch-jws` is not stripped on ingress.
107
+ const publicKeyPem = ctx.adapter?.env?.get("CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY") ??
108
+ getHostEnv("CHANNEL_DISPATCH_SIGNING_PUBLIC_KEY");
109
+ const host = (await isProxyTrusted(req, { publicKeyPem }))
110
+ ? getEffectiveRequestHost(req, url)
111
+ : (req.headers.get("host") ?? url.host);
100
112
  const isLocalhost = isLocalDevHost(host);
101
113
 
102
114
  if (!isPreviewMode && !isLocal && !isLocalhost) {
@@ -109,7 +121,7 @@ export class HMRHandler extends BaseHandler {
109
121
  isLocal,
110
122
  isLocalhost,
111
123
  });
112
- return Promise.resolve(this.continue());
124
+ return this.continue();
113
125
  }
114
126
 
115
127
  HMRHandler.initialize();
@@ -127,29 +139,25 @@ export class HMRHandler extends BaseHandler {
127
139
  }
128
140
 
129
141
  if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") {
130
- return Promise.resolve(
131
- this.respond(
132
- new Response(
133
- JSON.stringify({
134
- status: "ok",
135
- clients: getClientCount(),
136
- clientDetails: getClientDetails(),
137
- metrics: {
138
- ...getMetrics(),
139
- reloadNotifierMetrics: ReloadNotifier.getMetrics(),
140
- },
141
- message: "HMR WebSocket endpoint - connect via WebSocket",
142
- }),
143
- { headers: { "content-type": "application/json" } },
144
- ),
142
+ return this.respond(
143
+ new Response(
144
+ JSON.stringify({
145
+ status: "ok",
146
+ clients: getClientCount(),
147
+ clientDetails: getClientDetails(),
148
+ metrics: {
149
+ ...getMetrics(),
150
+ reloadNotifierMetrics: ReloadNotifier.getMetrics(),
151
+ },
152
+ message: "HMR WebSocket endpoint - connect via WebSocket",
153
+ }),
154
+ { headers: { "content-type": "application/json" } },
145
155
  ),
146
156
  );
147
157
  }
148
158
 
149
159
  if (!ctx.adapter?.server) {
150
- return Promise.resolve(
151
- this.respond(new Response("WebSocket not supported", { status: 501 })),
152
- );
160
+ return this.respond(new Response("WebSocket not supported", { status: 501 }));
153
161
  }
154
162
 
155
163
  try {
@@ -234,12 +242,10 @@ export class HMRHandler extends BaseHandler {
234
242
  totalClients: getClientCount(),
235
243
  });
236
244
 
237
- return Promise.resolve(this.respond(response));
245
+ return this.respond(response);
238
246
  } catch (error) {
239
247
  logger.error("WebSocket upgrade failed", { error });
240
- return Promise.resolve(
241
- this.respond(new Response("WebSocket upgrade failed", { status: 500 })),
242
- );
248
+ return this.respond(new Response("WebSocket upgrade failed", { status: 500 }));
243
249
  }
244
250
  }
245
251