@runtypelabs/persona-proxy 3.19.0 → 3.26.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 +128 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +59 -1
- package/dist/index.d.ts +59 -1
- package/dist/index.js +126 -10
- 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 +68 -0
- 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-storefront.ts +62 -0
- package/src/index.test.ts +173 -1
- package/src/index.ts +95 -4
package/src/index.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, afterEach } from "vitest";
|
|
1
|
+
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
2
2
|
import { createChatProxyApp } from "./index";
|
|
3
3
|
|
|
4
4
|
describe("CORS middleware", () => {
|
|
@@ -70,3 +70,175 @@ describe("CORS middleware", () => {
|
|
|
70
70
|
expect(res.headers.get("Vary")).toBe("Origin");
|
|
71
71
|
});
|
|
72
72
|
});
|
|
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
|
+
|
|
169
|
+
describe("dispatch — WebMCP clientTools forwarding", () => {
|
|
170
|
+
const realFetch = globalThis.fetch;
|
|
171
|
+
|
|
172
|
+
afterEach(() => {
|
|
173
|
+
globalThis.fetch = realFetch;
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// Capture whatever body the proxy POSTs upstream, and return a minimal
|
|
177
|
+
// streaming Response so the handler completes.
|
|
178
|
+
const captureUpstream = () => {
|
|
179
|
+
const calls: Array<{ url: string; body: Record<string, unknown> | null }> = [];
|
|
180
|
+
globalThis.fetch = vi.fn(async (url: unknown, init?: { body?: unknown }) => {
|
|
181
|
+
calls.push({
|
|
182
|
+
url: String(url),
|
|
183
|
+
body:
|
|
184
|
+
typeof init?.body === "string"
|
|
185
|
+
? (JSON.parse(init.body) as Record<string, unknown>)
|
|
186
|
+
: null,
|
|
187
|
+
});
|
|
188
|
+
return new Response("data: {}\n\n", {
|
|
189
|
+
status: 200,
|
|
190
|
+
headers: { "content-type": "text/event-stream" },
|
|
191
|
+
});
|
|
192
|
+
}) as unknown as typeof fetch;
|
|
193
|
+
return calls;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const dispatch = (
|
|
197
|
+
app: ReturnType<typeof createChatProxyApp>,
|
|
198
|
+
body: Record<string, unknown>,
|
|
199
|
+
) =>
|
|
200
|
+
app.request("/api/chat/dispatch", {
|
|
201
|
+
method: "POST",
|
|
202
|
+
headers: { "Content-Type": "application/json", Origin: "https://example.com" },
|
|
203
|
+
body: JSON.stringify(body),
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const clientTools = [
|
|
207
|
+
{
|
|
208
|
+
name: "search_products",
|
|
209
|
+
description: "Search the catalog.",
|
|
210
|
+
parametersSchema: { type: "object", properties: { query: { type: "string" } } },
|
|
211
|
+
origin: "webmcp",
|
|
212
|
+
pageOrigin: "https://example.com",
|
|
213
|
+
},
|
|
214
|
+
];
|
|
215
|
+
|
|
216
|
+
it("forwards clientTools[] to the upstream in flow-dispatch mode", async () => {
|
|
217
|
+
const calls = captureUpstream();
|
|
218
|
+
const app = createChatProxyApp({ apiKey: "test-key" });
|
|
219
|
+
const res = await dispatch(app, {
|
|
220
|
+
messages: [{ role: "user", content: "search for shoes" }],
|
|
221
|
+
clientTools,
|
|
222
|
+
});
|
|
223
|
+
expect(res.status).toBe(200);
|
|
224
|
+
expect(calls).toHaveLength(1);
|
|
225
|
+
expect(calls[0]!.body?.clientTools).toEqual(clientTools);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("omits clientTools from the upstream payload when none are provided", async () => {
|
|
229
|
+
const calls = captureUpstream();
|
|
230
|
+
const app = createChatProxyApp({ apiKey: "test-key" });
|
|
231
|
+
await dispatch(app, { messages: [{ role: "user", content: "hi" }] });
|
|
232
|
+
expect(calls[0]!.body).not.toHaveProperty("clientTools");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("omits clientTools when an empty array is sent", async () => {
|
|
236
|
+
const calls = captureUpstream();
|
|
237
|
+
const app = createChatProxyApp({ apiKey: "test-key" });
|
|
238
|
+
await dispatch(app, {
|
|
239
|
+
messages: [{ role: "user", content: "hi" }],
|
|
240
|
+
clientTools: [],
|
|
241
|
+
});
|
|
242
|
+
expect(calls[0]!.body).not.toHaveProperty("clientTools");
|
|
243
|
+
});
|
|
244
|
+
});
|
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) => {
|
|
@@ -280,6 +357,20 @@ export const createChatProxyApp = (options: ChatProxyOptions = {}) => {
|
|
|
280
357
|
} else {
|
|
281
358
|
runtypePayload.flow = flowConfig;
|
|
282
359
|
}
|
|
360
|
+
|
|
361
|
+
// WebMCP: forward page-discovered tools so the upstream flow's agent step
|
|
362
|
+
// can call them. The widget snapshots `document.modelContext` per turn and
|
|
363
|
+
// ships them as `clientTools[]`; the flow-dispatch payload is rebuilt from
|
|
364
|
+
// scratch above, so without this they'd be silently dropped and the agent
|
|
365
|
+
// would never see the page tools. (Agent mode forwards the payload as-is,
|
|
366
|
+
// so it already carries `clientTools`.) The matching results come back via
|
|
367
|
+
// the `${path}/resume` endpoint below.
|
|
368
|
+
if (
|
|
369
|
+
Array.isArray(clientPayload.clientTools) &&
|
|
370
|
+
clientPayload.clientTools.length > 0
|
|
371
|
+
) {
|
|
372
|
+
runtypePayload.clientTools = clientPayload.clientTools;
|
|
373
|
+
}
|
|
283
374
|
}
|
|
284
375
|
|
|
285
376
|
// Development only: do not log key material or full bodies in production.
|