@runtypelabs/persona-proxy 3.22.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/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) => {