@runtypelabs/persona-proxy 3.22.0 → 3.31.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.
@@ -0,0 +1,63 @@
1
+ import type { RuntypeFlowConfig } from "../index.js";
2
+
3
+ /**
4
+ * WebMCP storefront flow for the "Switchback" trail/road running demo
5
+ * (`examples/embedded-app/webmcp-demo.html`).
6
+ *
7
+ * Unlike the other example flows, this agent owns **no** tools of its own. The
8
+ * demo page registers its tools on `document.modelContext` via WebMCP
9
+ * (`search_products`, `view_product`, `add_to_cart`, `remove_from_cart`,
10
+ * `apply_promo`); the widget snapshots them every turn and the proxy forwards
11
+ * them on the dispatch payload as `clientTools[]`. The Runtype runtime threads
12
+ * those into this prompt step's tool set, so the model calls them by name and
13
+ * the widget executes them **on the page**, posting results back via `/resume`.
14
+ *
15
+ * That means the agent definition that drives the WebMCP demo lives entirely in
16
+ * this repo — no hosted Runtype agent / client token required. The flow just
17
+ * needs a tool-capable model and a system prompt that knows how to shop the
18
+ * (page-provided) catalog.
19
+ *
20
+ * Model: `nemotron-3-ultra-550b-a55b`. WebMCP depends on the model
21
+ * emitting **native** tool calls (each surfaces as a `step_await` the widget
22
+ * resumes), so a tool-reliable model is required here. `responseFormat` is
23
+ * markdown (not JSON) so the model is free to interleave tool calls with a
24
+ * natural-language summary instead of being constrained to a JSON envelope.
25
+ */
26
+ export const WEBMCP_STOREFRONT_FLOW: RuntypeFlowConfig = {
27
+ name: "WebMCP Storefront Flow",
28
+ description:
29
+ "Switchback running-store assistant — drives page-provided WebMCP tools (clientTools[])",
30
+ steps: [
31
+ {
32
+ id: "webmcp_storefront_prompt",
33
+ name: "WebMCP Storefront Prompt",
34
+ type: "prompt",
35
+ enabled: true,
36
+ config: {
37
+ model: "nemotron-3-ultra-550b-a55b",
38
+ reasoning: false,
39
+ responseFormat: "markdown",
40
+ outputVariable: "prompt_result",
41
+ userPrompt: "{{user_message}}",
42
+ systemPrompt: `You are the shopping assistant for **Switchback**, a trail & road running store. You help shoppers find gear, inspect products, and manage their cart.
43
+
44
+ Brand voice: friendly, outdoorsy, concise. Knowledgeable about running shoes, apparel, and trail gear. No hype, no emoji. Keep replies short — a sentence or two around the actions you take.
45
+
46
+ ## Your tools come from the page
47
+
48
+ This storefront exposes its own tools to you (search the catalog, view a product, add/remove from the cart, apply a promo code). Always **use the tools** to act on the catalog and cart — never invent products, SKUs, prices, or cart contents from memory.
49
+
50
+ Rules:
51
+ - Before referencing or adding any SKU, call **search_products** (or view_product) first to confirm it exists and to get the canonical SKU, title, and price. Do not guess SKUs.
52
+ - When the shopper asks to add, remove, or change the cart, call the matching tool. The page renders the cart — after a cart change, confirm what changed and the running total from the tool's result, briefly.
53
+ - If the shopper asks to add two (or more) specific items "at the same time" / "both", emit the add_to_cart calls together in one turn so they batch.
54
+ - Only apply a promo code the shopper actually gives you; if it's rejected, say so and suggest they double-check the code.
55
+ - If a tool reports an item wasn't found or isn't in the cart, relay that plainly and offer to search.
56
+ - Tool results include product \`imageUrl\` and \`imageAlt\`. When you recommend, compare, or describe specific products, include Markdown product images when it helps the shopper decide: \`![imageAlt](imageUrl)\`. Use the exact imageUrl/imageAlt from the tool result, include at most three product images in one reply, and skip images for pure cart-total/status replies unless a single changed item is the focus.
57
+
58
+ After your tool calls resolve, summarize the outcome in plain language (what you found, what's in the cart, the total). Do not describe tools, JSON, SKUs, or the WebMCP mechanism to the shopper.`,
59
+ previousMessages: "{{messages}}"
60
+ }
61
+ }
62
+ ]
63
+ };
package/src/index.test.ts CHANGED
@@ -71,6 +71,101 @@ describe("CORS middleware", () => {
71
71
  });
72
72
  });
73
73
 
74
+ describe("CORS preview origins", () => {
75
+ const savedNodeEnv = process.env.NODE_ENV;
76
+ const savedVercelEnv = process.env.VERCEL_ENV;
77
+ const savedPattern = process.env.PREVIEW_ORIGIN_PATTERN;
78
+
79
+ afterEach(() => {
80
+ process.env.NODE_ENV = savedNodeEnv;
81
+ if (savedVercelEnv === undefined) delete process.env.VERCEL_ENV;
82
+ else process.env.VERCEL_ENV = savedVercelEnv;
83
+ if (savedPattern === undefined) delete process.env.PREVIEW_ORIGIN_PATTERN;
84
+ else process.env.PREVIEW_ORIGIN_PATTERN = savedPattern;
85
+ });
86
+
87
+ const preflight = (app: ReturnType<typeof createChatProxyApp>, origin: string) =>
88
+ app.request("/api/chat/dispatch", { method: "OPTIONS", headers: { Origin: origin } });
89
+
90
+ it("reflects a *.vercel.app preview origin not in the allowlist (default pattern)", async () => {
91
+ process.env.NODE_ENV = "production";
92
+ delete process.env.VERCEL_ENV;
93
+ const app = createChatProxyApp({ allowedOrigins: ["https://good.com"] });
94
+ const res = await preflight(app, "https://persona-git-feature-x-runtype.vercel.app");
95
+ expect(res.status).toBe(204);
96
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBe(
97
+ "https://persona-git-feature-x-runtype.vercel.app"
98
+ );
99
+ });
100
+
101
+ it("does not match preview-apex look-alikes", async () => {
102
+ process.env.NODE_ENV = "production";
103
+ delete process.env.VERCEL_ENV;
104
+ const app = createChatProxyApp({ allowedOrigins: ["https://good.com"] });
105
+ // Apex spoofed as a deeper subdomain of an attacker domain.
106
+ const spoof = await preflight(app, "https://x.vercel.app.evil.com");
107
+ expect(spoof.status).toBe(403);
108
+ // Hyphen instead of dot before the apex.
109
+ const hyphen = await preflight(app, "https://evil-vercel.app");
110
+ expect(hyphen.status).toBe(403);
111
+ });
112
+
113
+ it("allows extra preview domains via PREVIEW_ORIGIN_PATTERN env", async () => {
114
+ process.env.NODE_ENV = "production";
115
+ delete process.env.VERCEL_ENV;
116
+ process.env.PREVIEW_ORIGIN_PATTERN = "^https://[a-z0-9-]+\\.preview\\.example\\.com$";
117
+ const app = createChatProxyApp({ allowedOrigins: ["https://good.com"] });
118
+ const res = await preflight(app, "https://pr-42.preview.example.com");
119
+ expect(res.status).toBe(204);
120
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBe(
121
+ "https://pr-42.preview.example.com"
122
+ );
123
+ });
124
+
125
+ it("still rejects a non-preview, non-allowlisted origin in production", async () => {
126
+ process.env.NODE_ENV = "production";
127
+ delete process.env.VERCEL_ENV;
128
+ const app = createChatProxyApp({ allowedOrigins: ["https://good.com"] });
129
+ const res = await preflight(app, "https://evil.com");
130
+ expect(res.status).toBe(403);
131
+ });
132
+
133
+ it("disables preview reflection with previewOriginPattern: false", async () => {
134
+ process.env.NODE_ENV = "production";
135
+ delete process.env.VERCEL_ENV;
136
+ const app = createChatProxyApp({
137
+ allowedOrigins: ["https://good.com"],
138
+ previewOriginPattern: false,
139
+ });
140
+ const res = await preflight(app, "https://persona-git-feature-x-runtype.vercel.app");
141
+ expect(res.status).toBe(403);
142
+ });
143
+
144
+ it("honors a custom previewOriginPattern", async () => {
145
+ process.env.NODE_ENV = "production";
146
+ delete process.env.VERCEL_ENV;
147
+ const app = createChatProxyApp({
148
+ allowedOrigins: ["https://good.com"],
149
+ previewOriginPattern: /^https:\/\/preview\.example\.com$/,
150
+ });
151
+ const ok = await preflight(app, "https://preview.example.com");
152
+ expect(ok.status).toBe(204);
153
+ expect(ok.headers.get("Access-Control-Allow-Origin")).toBe("https://preview.example.com");
154
+ // The default *.vercel.app no longer applies once a custom pattern is set.
155
+ const vercel = await preflight(app, "https://persona-git-x-runtype.vercel.app");
156
+ expect(vercel.status).toBe(403);
157
+ });
158
+
159
+ it("reflects any origin when the proxy itself is a Vercel preview runtime", async () => {
160
+ process.env.NODE_ENV = "production";
161
+ process.env.VERCEL_ENV = "preview";
162
+ const app = createChatProxyApp({ allowedOrigins: ["https://good.com"] });
163
+ const res = await preflight(app, "https://anything.example.org");
164
+ expect(res.status).toBe(204);
165
+ expect(res.headers.get("Access-Control-Allow-Origin")).toBe("https://anything.example.org");
166
+ });
167
+ });
168
+
74
169
  describe("dispatch — WebMCP clientTools forwarding", () => {
75
170
  const realFetch = globalThis.fetch;
76
171
 
package/src/index.ts CHANGED
@@ -40,6 +40,17 @@ export type ChatProxyOptions = {
40
40
  apiKey?: string;
41
41
  path?: string;
42
42
  allowedOrigins?: string[];
43
+ /**
44
+ * Reflect any request origin matching this pattern, in addition to the exact
45
+ * `allowedOrigins` list. Intended for Vercel **preview** deployments, whose
46
+ * URLs are per-branch and dynamic (`*-git-<branch>-<team>.vercel.app`) and so
47
+ * can't be enumerated. Defaults to `https://*.vercel.app`
48
+ * ({@link DEFAULT_PREVIEW_ORIGIN_PATTERN}); pass a custom `RegExp`, set the
49
+ * `PREVIEW_ORIGIN_PATTERN` env var, or pass `false` to disable. Independent of
50
+ * the `VERCEL_ENV === "preview"` runtime check, which always reflects the
51
+ * caller's origin when the proxy itself is a preview deployment.
52
+ */
53
+ previewOriginPattern?: RegExp | false;
43
54
  flowId?: string;
44
55
  flowConfig?: RuntypeFlowConfig;
45
56
  /**
@@ -74,6 +85,56 @@ const getRuntimeEnv = (): RuntimeEnv | undefined => {
74
85
  const isDevelopmentRuntime = (): boolean =>
75
86
  getRuntimeEnv()?.NODE_ENV === "development";
76
87
 
88
+ /**
89
+ * True when this proxy is itself running as a Vercel **preview** deployment
90
+ * (`VERCEL_ENV === "preview"`). Vercel sets `NODE_ENV=production` for both
91
+ * production and preview, so `isDevelopmentRuntime()` can't distinguish them —
92
+ * `VERCEL_ENV` is the only signal. Preview deployments get per-branch, dynamic
93
+ * URLs (`*-git-<branch>-<team>.vercel.app`) that can't be enumerated in a
94
+ * static `allowedOrigins` list, so for CORS we treat a preview runtime like
95
+ * development and reflect the caller's origin. Safe when `process` is missing.
96
+ */
97
+ const isVercelPreviewRuntime = (): boolean =>
98
+ getRuntimeEnv()?.VERCEL_ENV === "preview";
99
+
100
+ /**
101
+ * Default origin pattern treated as a Vercel preview/app origin: any
102
+ * `https://<sub>.vercel.app`. When a *production* proxy is called by a static
103
+ * preview site (a different, dynamic `*.vercel.app` origin), the origin won't be
104
+ * in `allowedOrigins`; matching this pattern lets the proxy reflect it so
105
+ * per-branch preview sites work without enumerating their URLs. To allow other
106
+ * preview domains, supply your own pattern via the `previewOriginPattern` option
107
+ * or the `PREVIEW_ORIGIN_PATTERN` env regex; disable with
108
+ * `previewOriginPattern: false`.
109
+ *
110
+ * The `$`-anchored apex prevents look-alikes like `https://x.vercel.app.evil.com`
111
+ * from matching.
112
+ */
113
+ const DEFAULT_PREVIEW_ORIGIN_PATTERN = /^https:\/\/[a-z0-9-]+\.vercel\.app$/i;
114
+
115
+ /**
116
+ * Resolve the preview-origin pattern from options/env. Precedence:
117
+ * explicit `options.previewOriginPattern` (a `RegExp`, or `false` to disable) →
118
+ * `PREVIEW_ORIGIN_PATTERN` env (compiled as a `RegExp`; ignored if invalid) →
119
+ * {@link DEFAULT_PREVIEW_ORIGIN_PATTERN}.
120
+ */
121
+ const resolvePreviewOriginPattern = (
122
+ option: RegExp | false | undefined
123
+ ): RegExp | null => {
124
+ if (option === false) return null;
125
+ if (option instanceof RegExp) return option;
126
+ const envPattern = getRuntimeEnv()?.PREVIEW_ORIGIN_PATTERN;
127
+ if (envPattern) {
128
+ try {
129
+ return new RegExp(envPattern);
130
+ } catch {
131
+ // Invalid env regex — fall back to the default rather than throwing at
132
+ // app-construction time.
133
+ }
134
+ }
135
+ return DEFAULT_PREVIEW_ORIGIN_PATTERN;
136
+ };
137
+
77
138
  const DEFAULT_FLOW: RuntypeFlowConfig = {
78
139
  name: "Streaming Prompt Flow",
79
140
  description: "Streaming chat generated by the widget",
@@ -84,7 +145,7 @@ const DEFAULT_FLOW: RuntypeFlowConfig = {
84
145
  type: "prompt",
85
146
  enabled: true,
86
147
  config: {
87
- model: "mercury-2",
148
+ model: "nemotron-3-ultra-550b-a55b",
88
149
  responseFormat: "markdown",
89
150
  outputVariable: "prompt_result",
90
151
  userPrompt: "{{user_message}}",
@@ -101,11 +162,20 @@ const DEFAULT_FLOW: RuntypeFlowConfig = {
101
162
  };
102
163
 
103
164
  const withCors =
104
- (allowedOrigins: string[] | undefined) =>
165
+ (allowedOrigins: string[] | undefined, previewOriginPattern: RegExp | null) =>
105
166
  async (c: Context, next: () => Promise<void>) => {
106
167
  const origin = c.req.header("origin");
107
168
  const isDevelopment = isDevelopmentRuntime();
108
-
169
+ // A request is preview-allowed when either the proxy itself is a Vercel
170
+ // preview deployment (reflect any caller, like dev) or the caller's origin
171
+ // matches the configured preview pattern (e.g. a `*.vercel.app` preview
172
+ // site calling a production proxy). Both reflect the actual origin.
173
+ const isPreviewOrigin = Boolean(
174
+ origin &&
175
+ (isVercelPreviewRuntime() ||
176
+ (previewOriginPattern !== null && previewOriginPattern.test(origin)))
177
+ );
178
+
109
179
  // Determine the CORS origin to allow
110
180
  let corsOrigin: string;
111
181
  if (!allowedOrigins || allowedOrigins.length === 0) {
@@ -118,6 +188,10 @@ const withCors =
118
188
  // In development, allow the actual origin even if not in the list
119
189
  // This helps with local development where ports might vary
120
190
  corsOrigin = origin;
191
+ } else if (isPreviewOrigin && origin) {
192
+ // Vercel preview deployment (or a configured preview origin): reflect the
193
+ // dynamic per-branch origin that can't be enumerated in allowedOrigins.
194
+ corsOrigin = origin;
121
195
  } else {
122
196
  // Production: origin not allowed - reject by not setting CORS headers
123
197
  // Return error for preflight, or continue without CORS headers
@@ -152,7 +226,10 @@ export const createChatProxyApp = (options: ChatProxyOptions = {}) => {
152
226
  const feedbackPath = options.feedbackPath ?? "/api/feedback";
153
227
  const upstream = options.upstreamUrl ?? DEFAULT_ENDPOINT;
154
228
 
155
- app.use("*", withCors(options.allowedOrigins));
229
+ const previewOriginPattern = resolvePreviewOriginPattern(
230
+ options.previewOriginPattern
231
+ );
232
+ app.use("*", withCors(options.allowedOrigins, previewOriginPattern));
156
233
 
157
234
  // Feedback endpoint for collecting upvote/downvote data
158
235
  app.post(feedbackPath, async (c) => {