@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.
- package/dist/index.cjs +120 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +58 -1
- package/dist/index.d.ts +58 -1
- package/dist/index.js +118 -11
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/flows/bakery-assistant.ts +1 -1
- package/src/flows/components.ts +1 -1
- package/src/flows/conversational.ts +1 -1
- package/src/flows/index.ts +2 -0
- package/src/flows/page-context.ts +1 -1
- package/src/flows/scheduling.ts +1 -1
- package/src/flows/shopping-assistant.ts +2 -2
- package/src/flows/storefront-assistant.ts +1 -1
- package/src/flows/webmcp-calendar.ts +60 -0
- package/src/flows/webmcp-storefront.ts +63 -0
- package/src/index.test.ts +95 -0
- package/src/index.ts +81 -4
|
@@ -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: \`\`. 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: "
|
|
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
|
-
|
|
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) => {
|