pi-freeflow 1.26.0 → 1.26.1

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/deploy.ts CHANGED
@@ -1,576 +1,655 @@
1
- /**
2
- * Automated Vercel Edge Relay deployer for pi-freeflow
3
- *
4
- * Deploys a private 3-file Vercel edge proxy with strict target domain whitelisting.
5
- * The provided API token is held in-memory only and never persisted to disk or logs.
6
- */
7
-
8
- import { VERCEL_API } from "./config.ts";
9
- import { log, logError } from "./logger.ts";
10
- import { randomBytes } from "node:crypto";
11
-
12
- /**
13
- * Canonical guarded request pipeline shared by every platform relay template.
14
- * The platform wrappers below only adapt the entry point (Vercel fetch
15
- * handler / Cloudflare module worker / Deno.serve) around this core, so the
16
- * whitelist, SSRF guard, path resolver, header denylist, auth gate and
17
- * streaming forward live exactly once.
18
- *
19
- * @param relayAuth Per-deployment shared secret. Empty string disables the
20
- * auth gate (legacy/manual deploys); built-in deploys always embed one.
21
- */
22
- function buildRelayWorkerCore(relayAuth: string): string {
23
- return `// Only the 2 upstreams pi-freeflow talks to. Anything else = open proxy abuse.
24
- const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
25
- const RELAY_AUTH = ${JSON.stringify(relayAuth)};
26
- const resolveRelayTarget = function(target, relayPath) {
27
- let targetUrl;
28
- try { targetUrl = new URL(target); } catch { return { ok: false, status: 400, reason: "invalid x-relay-target" }; }
29
- if (typeof relayPath !== "string" || relayPath.indexOf("@") !== -1 || relayPath.indexOf("\\") !== -1 || relayPath.charAt(0) !== "/") {
30
- return { ok: false, status: 403, reason: "forbidden x-relay-path" };
31
- }
32
- let finalUrl;
33
- try { finalUrl = new URL(relayPath, targetUrl); } catch { return { ok: false, status: 403, reason: "forbidden x-relay-path" }; }
34
- if (finalUrl.hostname !== targetUrl.hostname || finalUrl.protocol !== targetUrl.protocol || finalUrl.port !== targetUrl.port || finalUrl.username || finalUrl.password) {
35
- return { ok: false, status: 403, reason: "forbidden x-relay-path (host mismatch)" };
36
- }
37
- return { ok: true, url: finalUrl.toString() };
38
- };
39
- const isPrivateHostname = function(h) {
40
- if (!h) return true
41
- let host = String(h).trim().toLowerCase().replace(/^\[|\]$/g, "")
42
- if (host.length > 1 && host.endsWith(".")) host = host.slice(0, -1)
43
- if (!host) return true
44
- if (host === "localhost" || host === "0.0.0.0" || host === "127.0.0.1" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal")) return true
45
- if (host.startsWith("::")) return true
46
- const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
47
- if (v4) {
48
- const a = Number(v4[1])
49
- const b = Number(v4[2])
50
- if (a === 0 || a === 10 || a === 127) return true
51
- if (a === 169 && b === 254) return true
52
- if (a === 192 && b === 168) return true
53
- if (a === 172 && b >= 16 && b <= 31) return true
54
- if (a === 100 && b >= 64 && b <= 127) return true
55
- return false
56
- }
57
- if (host.includes(":")) {
58
- if (host.startsWith("fc") || host.startsWith("fd")) return true
59
- if (/^fe[89ab]/.test(host)) return true
60
- return false
61
- }
62
- return false
63
- };
64
- // Constant-time string comparison (no timingSafeEqual in every edge runtime).
65
- const timingSafeEqualStr = function(a, b) {
66
- if (typeof a !== "string" || typeof b !== "string") return false;
67
- let diff = a.length ^ b.length;
68
- const len = Math.max(a.length, b.length);
69
- for (let i = 0; i < len; i++) {
70
- diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
71
- }
72
- return diff === 0;
73
- };
74
- // x-relay-auth is read by the handler below, so it is stripped explicitly
75
- // (never forwarded upstream) instead of living in the denylist.
76
- const DENY_HEADERS = ["host", "connection", "content-length", "keep-alive", "proxy-connection", "proxy-authenticate", "proxy-authorization", "transfer-encoding", "te", "trailer", "upgrade", "x-relay-target", "x-relay-path"];
77
- async function relayHandler(req) {
78
- if (!["GET", "POST", "HEAD", "OPTIONS"].includes(req.method)) return new Response("Method Not Allowed", { status: 405 });
79
- if (Number(req.headers.get("content-length") || 0) > 32 * 1024 * 1024) return new Response("Payload Too Large", { status: 413 });
80
- const target = req.headers.get("x-relay-target");
81
- if (!target) return new Response(JSON.stringify({ error: "Missing x-relay-target header" }), { status: 400, headers: { "content-type": "application/json" } });
82
- if (RELAY_AUTH && !timingSafeEqualStr(req.headers.get("x-relay-auth") || "", RELAY_AUTH)) {
83
- return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401, headers: { "content-type": "application/json" } });
84
- }
85
- let targetUrl;
86
- try { targetUrl = new URL(target); } catch { return new Response(JSON.stringify({ error: "invalid x-relay-target" }), { status: 400, headers: { "content-type": "application/json" } }); }
87
- if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") return new Response(JSON.stringify({ error: "forbidden x-relay-target protocol" }), { status: 403, headers: { "content-type": "application/json" } });
88
- if (targetUrl.username || targetUrl.password) return new Response(JSON.stringify({ error: "forbidden x-relay-target (embedded credentials)" }), { status: 403, headers: { "content-type": "application/json" } });
89
- if (isPrivateHostname(targetUrl.hostname)) return new Response(JSON.stringify({ error: "forbidden x-relay-target (private/loopback host)" }), { status: 403, headers: { "content-type": "application/json" } });
90
- const cleanTarget = target.replace(/\/$/, "");
91
- if (!ALLOWED_TARGETS.includes(cleanTarget)) return new Response(JSON.stringify({ error: "Forbidden target" }), { status: 403, headers: { "content-type": "application/json" } });
92
- const relayPath = req.headers.get("x-relay-path") || "/";
93
- const resolved = resolveRelayTarget(target, relayPath);
94
- if (!resolved.ok) return new Response(JSON.stringify({ error: resolved.reason }), { status: resolved.status, headers: { "content-type": "application/json" } });
95
- const headers = new Headers(req.headers);
96
- DENY_HEADERS.forEach((h) => headers.delete(h));
97
- headers.delete("x-relay-auth");
98
- try {
99
- const init = { method: req.method, headers };
100
- if (req.method !== "GET" && req.method !== "HEAD") { init.body = req.body; init.duplex = "half"; }
101
- const response = await fetch(resolved.url, init);
102
- return new Response(response.body, { status: response.status, headers: response.headers });
103
- } catch (error) {
104
- return new Response(JSON.stringify({ error: String(error) }), { status: 502, headers: { "content-type": "application/json" } });
105
- }
106
- }`;
107
- }
108
-
109
- /**
110
- * Vercel Edge Function relay template: canonical core wrapped in the edge-runtime module handler.
111
- */
112
- export function buildVercelRelayWorker(relayAuth: string): string {
113
- return `${buildRelayWorkerCore(relayAuth)}
114
- export const config = { runtime: "edge" };
115
- export default async function handler(req) {
116
- return relayHandler(req);
117
- }`;
118
- }
119
-
120
- /**
121
- * Cloudflare Workers module relay template: canonical core wrapped in the module fetch handler.
122
- */
123
- export function buildCloudflareRelayWorker(relayAuth: string): string {
124
- return `${buildRelayWorkerCore(relayAuth)}
125
- export default {
126
- async fetch(request) {
127
- return relayHandler(request);
128
- },
129
- };`;
130
- }
131
-
132
- /**
133
- * Deno Deploy relay script template: canonical core wrapped in Deno.serve.
134
- */
135
- export function buildDenoRelayScript(relayAuth: string): string {
136
- return `${buildRelayWorkerCore(relayAuth)}
137
- Deno.serve(async (request) => {
138
- return relayHandler(request);
139
- });`;
140
- }
141
-
142
- /**
143
- * Public template constants (tests/README): the canonical core without a
144
- * secret, so the auth gate is disabled. Deployed relays always embed their
145
- * own per-deployment secret.
146
- */
147
- export const VERCEL_RELAY_WORKER = buildVercelRelayWorker("");
148
- export const CLOUDFLARE_RELAY_WORKER = buildCloudflareRelayWorker("");
149
- export const DENO_RELAY_SCRIPT = buildDenoRelayScript("");
150
-
151
- /**
152
- * Deploy a fresh Vercel Edge Relay project in-memory.
153
- *
154
- * @param token Vercel personal access token (used in-memory only)
155
- * @param name Unique project/deployment name (e.g. pi-freeflow-relay-abc123)
156
- * @param onProgress Optional callback for user-facing progress updates
157
- * @returns Deployed { url, auth } — url is the public relay URL, auth the embedded shared secret
158
- */
159
- export async function deployVercelRelay(
160
- token: string,
161
- name: string,
162
- onProgress?: (msg: string) => void,
163
- authSecret: string = "",
164
- ): Promise<{ url: string; auth: string }> {
165
- name = baseRelayName(name) || "relay-worker";
166
- const auth = {
167
- Authorization: `Bearer ${token}`,
168
- "Content-Type": "application/json",
169
- };
170
- // 1. Create deployment (3 inline files, no git repository required)
171
- onProgress?.("Uploading relay files to Vercel…");
172
- log("info", `Starting Vercel deployment: ${name}`);
173
- // Public by default for easy migration with 9router and other proxy tools.
174
- // When authSecret is provided, embeds the shared secret for private auth.
175
- const relayAuth = authSecret || "";
176
-
177
- const dep = await fetch(`${VERCEL_API}/v13/deployments`, {
178
- method: "POST",
179
- headers: auth,
180
- body: JSON.stringify({
181
- name,
182
- files: [
183
- { file: "api/relay.js", data: buildVercelRelayWorker(relayAuth) },
184
- {
185
- file: "package.json",
186
- data: JSON.stringify({ name, version: "1.0.0" }),
187
- },
188
- {
189
- file: "vercel.json",
190
- data: JSON.stringify({
191
- rewrites: [{ source: "/(.*)", destination: "/api/relay" }],
192
- }),
193
- },
194
- ],
195
- projectSettings: { framework: null },
196
- target: "production",
197
- }),
198
- });
199
-
200
- if (!dep.ok) {
201
- const e = (await dep
202
- .json()
203
- .catch(() => ({}))) as { error?: { message?: string } };
204
- const errMsg = e?.error?.message || `Vercel deploy failed (HTTP ${dep.status})`;
205
- logError(`Vercel deployment failed to create: ${errMsg}`);
206
- throw new Error(errMsg);
207
- }
208
-
209
- const depJson = (await dep.json()) as { id?: string; uid?: string; projectId?: string };
210
- const depId = depJson.id || depJson.uid;
211
- const projectId = depJson.projectId || name;
212
-
213
- // 2. Make the deployment public (disable SSO protection if enabled on team)
214
- try {
215
- await fetch(`${VERCEL_API}/v9/projects/${projectId}`, {
216
- method: "PATCH",
217
- headers: auth,
218
- body: JSON.stringify({ ssoProtection: null }),
219
- });
220
- } catch {}
221
-
222
- // 3. Poll until READY state (3s interval, 120s maximum timeout)
223
- onProgress?.("Waiting for Edge deployment to go live…");
224
- const deadline = Date.now() + 120_000;
225
-
226
- while (Date.now() < deadline) {
227
- let s: Response | null = null;
228
- try {
229
- s = await fetch(`${VERCEL_API}/v13/deployments/${depId}`, {
230
- headers: { Authorization: `Bearer ${token}` },
231
- });
232
- } catch (err) {
233
- log(
234
- "warn",
235
- `Vercel deployment status poll failed, retrying: ${(err as Error).message}`,
236
- );
237
- }
238
- if (s?.ok) {
239
- const j = (await s.json()) as { readyState?: string; url?: string };
240
- if (j.readyState === "READY" && j.url) {
241
- const deployedUrl = `https://${j.url}`;
242
- log("info", `Vercel relay successfully deployed: ${deployedUrl}`);
243
- return { url: deployedUrl, auth: relayAuth };
244
- }
245
- if (j.readyState === "ERROR" || j.readyState === "CANCELED") {
246
- const err = `Deployment failed with state: ${j.readyState}`;
247
- logError(err);
248
- throw new Error(err);
249
- }
250
- }
251
- await new Promise<void>((r) => setTimeout(r, 3000));
252
- }
253
-
254
- const timeoutErr = "Deployment timed out (120s)";
255
- logError(timeoutErr);
256
- throw new Error(timeoutErr);
257
- }
258
-
259
- // ── Multi-platform deployment (Cloudflare Workers / Deno Deploy) ────
260
-
261
- const CLOUDFLARE_API = "https://api.cloudflare.com/client/v4";
262
- const DENO_API = "https://api.deno.com/v2";
263
-
264
- export type DeployPlatform = "vercel" | "cloudflare" | "deno";
265
-
266
- function baseRelayName(name: string): string {
267
- return name
268
- .toLowerCase()
269
- .replace(/[^a-z0-9-]+/g, "-")
270
- .replace(/-{2,}/g, "-")
271
- .replace(/^-+|-+$/g, "");
272
- }
273
-
274
- /** Cloudflare Worker script names: [a-z0-9-], max 58 chars. */
275
- function cloudflareScriptName(name: string): string {
276
- const clean = baseRelayName(name)
277
- .slice(0, 58)
278
- .replace(/-+$/g, "");
279
- return clean || "relay-worker";
280
- }
281
-
282
- /** Deno Deploy app slugs: [a-z0-9-], 3-32 chars, no edge/consecutive hyphens. */
283
- function denoProjectName(name: string): string {
284
- const clean = baseRelayName(name)
285
- .slice(0, 32)
286
- .replace(/-+$/, "");
287
- if (!clean) return "relay-app";
288
- return clean.length < 3 ? `${clean}-relay` : clean;
289
- }
290
-
291
- async function cloudflareError(action: string, res: Response): Promise<Error> {
292
- const body = (await res
293
- .json()
294
- .catch(() => ({}))) as { errors?: Array<{ message?: string }> };
295
- const detail = body.errors?.[0]?.message || `HTTP ${res.status}`;
296
- let err: Error;
297
- if (res.status === 401 || res.status === 403) {
298
- err = /quota|limit|exceeded/i.test(detail)
299
- ? new Error(`Cloudflare plan or usage limit hit while trying to ${action}: ${detail}. Check your Workers plan limits.`)
300
- : new Error(`Cloudflare authentication failed while trying to ${action}: ${detail}. Check that your API token is valid and has Workers permissions.`);
301
- } else {
302
- err = new Error(`Failed to ${action} (HTTP ${res.status}): ${detail}`);
303
- }
304
- logError(err.message);
305
- return err;
306
- }
307
-
308
- async function denoError(action: string, res: Response, override?: string): Promise<Error> {
309
- if (override) {
310
- logError(override);
311
- return new Error(override);
312
- }
313
- const raw = await res.text().catch(() => "");
314
- let detail = raw;
315
- try {
316
- const parsed = JSON.parse(raw) as { error?: { message?: string } };
317
- detail = parsed.error?.message || raw;
318
- } catch {}
319
- let err: Error;
320
- if (res.status === 401 || res.status === 403) {
321
- err = /quota|limit|exceeded/i.test(detail)
322
- ? new Error(`Deno Deploy plan or usage limit hit while trying to ${action}: ${detail}. Check your organization's limits.`)
323
- : new Error(`Deno Deploy authentication failed while trying to ${action}: ${detail}. Check that your access token is valid.`);
324
- } else {
325
- err = new Error(`Failed to ${action} (HTTP ${res.status}): ${detail}`);
326
- }
327
- logError(err.message);
328
- return err;
329
- }
330
-
331
- /**
332
- * Deploy a fresh Cloudflare Workers relay (module worker) in-memory.
333
- *
334
- * @param token Cloudflare API token (used in-memory only)
335
- * @param name Unique worker/script name (sanitized to [a-z0-9-])
336
- * @param onProgress Optional callback for user-facing progress updates
337
- * @returns Public { url, auth } — relay URL plus the embedded shared secret
338
- */
339
- export async function deployCloudflareWorker(
340
- token: string,
341
- name: string,
342
- onProgress?: (msg: string) => void,
343
- authSecret: string = "",
344
- ): Promise<{ url: string; auth: string }> {
345
- const auth = { Authorization: `Bearer ${token}` };
346
- const scriptName = cloudflareScriptName(name);
347
-
348
- // 1. Resolve the account scoped to this token
349
- onProgress?.("Resolving Cloudflare account…");
350
- log("info", `Starting Cloudflare Worker deployment: ${scriptName}`);
351
- const accRes = await fetch(`${CLOUDFLARE_API}/accounts`, { headers: auth });
352
- if (!accRes.ok) throw await cloudflareError("resolve Cloudflare account", accRes);
353
- const accJson = (await accRes.json()) as { result?: Array<{ id?: string }> };
354
- const accountId = accJson.result?.[0]?.id;
355
- if (!accountId) {
356
- const err = "No Cloudflare account is accessible with this API token";
357
- logError(err);
358
- throw new Error(err);
359
- }
360
-
361
- // 2. Upload the module worker script (multipart: main module + metadata)
362
- onProgress?.("Uploading relay worker to Cloudflare…");
363
- // Public by default for easy migration with 9router and other proxy tools.
364
- // When authSecret is provided, embeds the shared secret for private auth.
365
- const relayAuth = authSecret || "";
366
- const formData = new FormData();
367
- formData.append(
368
- "index.js",
369
- new Blob([buildCloudflareRelayWorker(relayAuth)], { type: "application/javascript+module" }),
370
- "index.js",
371
- );
372
- formData.append(
373
- "metadata",
374
- new Blob(
375
- [
376
- JSON.stringify({
377
- main_module: "index.js",
378
- compatibility_date: "2024-03-20",
379
- observability: { enabled: true },
380
- }),
381
- ],
382
- { type: "application/json" },
383
- ),
384
- "metadata.json",
385
- );
386
- const uploadRes = await fetch(
387
- `${CLOUDFLARE_API}/accounts/${accountId}/workers/scripts/${scriptName}`,
388
- { method: "PUT", headers: auth, body: formData },
389
- );
390
- if (!uploadRes.ok) throw await cloudflareError("upload Worker to Cloudflare", uploadRes);
391
-
392
- // 3. Enable workers.dev routing for the script (non-fatal if it fails)
393
- try {
394
- await fetch(`${CLOUDFLARE_API}/accounts/${accountId}/workers/scripts/${scriptName}/subdomain`, {
395
- method: "POST",
396
- headers: { ...auth, "Content-Type": "application/json" },
397
- body: JSON.stringify({ enabled: true }),
398
- });
399
- } catch {}
400
-
401
- // 4. Read the account-level workers.dev subdomain to assemble the public URL
402
- onProgress?.("Reading workers.dev routing…");
403
- const subRes = await fetch(`${CLOUDFLARE_API}/accounts/${accountId}/workers/subdomain`, { headers: auth });
404
- if (!subRes.ok) throw await cloudflareError("retrieve workers.dev subdomain", subRes);
405
- const subJson = (await subRes.json()) as { result?: { subdomain?: string } };
406
- const subdomain = subJson.result?.subdomain;
407
- if (!subdomain) {
408
- const err = "Worker deployed but workers.dev subdomain is unavailable. Enable a workers.dev subdomain for your account in the Cloudflare dashboard.";
409
- logError(err);
410
- throw new Error(err);
411
- }
412
- const url = `https://${scriptName}.${subdomain}.workers.dev`;
413
- log("info", `Cloudflare relay successfully deployed: ${url}`);
414
- return { url, auth: relayAuth };
415
- }
416
-
417
- type DenoRevision = {
418
- status?: string;
419
- failure_reason?: string | null;
420
- timelines?: Array<{ slug?: string; domains?: Array<{ domain?: string }> }>;
421
- };
422
-
423
- function resolveRoutedDomain(revision: DenoRevision): string | null {
424
- const timelines = revision.timelines ?? [];
425
- const production = timelines.find((t) => t.slug === "production") ?? timelines[0];
426
- const host = (production?.domains ?? [])
427
- .map((d) => d.domain ?? "")
428
- .find((h) => h.length > 0);
429
- return host ?? null;
430
- }
431
-
432
- async function firstManagedDenoDomain(token: string): Promise<string | null> {
433
- const res = await fetch(`${DENO_API}/domains`, {
434
- headers: { Authorization: `Bearer ${token}` },
435
- });
436
- if (!res.ok) return null;
437
- const list = (await res.json().catch(() => [])) as Array<{ domain?: string }>;
438
- const managed = (Array.isArray(list) ? list : [])
439
- .map((d) => d.domain ?? "")
440
- .find((h) => h.endsWith(".deno.net"));
441
- return managed ? managed.replace(/^\*\./, "") : null;
442
- }
443
-
444
- /**
445
- * Deploy a fresh Deno Deploy relay (Deno.serve script) in-memory.
446
- * The v2 API is scoped to the token's organization, so no org input is needed.
447
- *
448
- * @param token Deno Deploy organization access token (used in-memory only)
449
- * @param name Unique app/project name (sanitized to a valid slug)
450
- * @param onProgress Optional callback for user-facing progress updates
451
- * @returns Public { url, auth } — relay URL plus the embedded shared secret
452
- */
453
- export async function deployDenoRelay(
454
- token: string,
455
- name: string,
456
- onProgress?: (msg: string) => void,
457
- authSecret: string = "",
458
- ): Promise<{ url: string; auth: string }> {
459
- const slug = denoProjectName(name);
460
- const auth = { Authorization: `Bearer ${token}` };
461
- const jsonHeaders = { ...auth, "Content-Type": "application/json" };
462
- onProgress?.("Creating Deno Deploy app…");
463
- log("info", `Starting Deno Deploy deployment: ${slug}`);
464
- const createRes = await fetch(`${DENO_API}/apps`, {
465
- method: "POST",
466
- headers: jsonHeaders,
467
- body: JSON.stringify({
468
- slug,
469
- labels: { "custom.kind": "relay" },
470
- config: {
471
- install: "deno install",
472
- runtime: { type: "dynamic", entrypoint: "main.ts" },
473
- },
474
- }),
475
- });
476
- if (!createRes.ok) {
477
- throw await denoError(
478
- `create Deno Deploy app "${slug}"`,
479
- createRes,
480
- createRes.status === 409
481
- ? `An app named "${slug}" already exists on Deno Deploy — choose a different name.`
482
- : undefined,
483
- );
484
- }
485
- const app = (await createRes.json()) as { id?: string };
486
- const appId = app.id;
487
- if (!appId) {
488
- const err = "Deno Deploy did not return an app id";
489
- logError(err);
490
- throw new Error(err);
491
- }
492
- const deleteApp = (): Promise<void> =>
493
- fetch(`${DENO_API}/apps/${appId}`, { method: "DELETE", headers: auth })
494
- .then(() => undefined)
495
- .catch(() => {});
496
-
497
- // 2. Push the relay source as a single-file revision
498
- onProgress?.("Uploading relay script to Deno Deploy…");
499
- // Public by default for easy migration with 9router and other proxy tools.
500
- // When authSecret is provided, embeds the shared secret for private auth.
501
- const relayAuth = authSecret || "";
502
- const deployRes = await fetch(`${DENO_API}/apps/${appId}/deploy`, {
503
- method: "POST",
504
- headers: jsonHeaders,
505
- body: JSON.stringify({
506
- assets: {
507
- "main.ts": { kind: "file", content: buildDenoRelayScript(relayAuth), encoding: "utf-8" },
508
- },
509
- }),
510
- });
511
- if (!deployRes.ok) {
512
- await deleteApp();
513
- throw await denoError("upload relay script", deployRes);
514
- }
515
- const revision = (await deployRes.json()) as { id?: string };
516
- const revisionId = revision.id;
517
- if (!revisionId) {
518
- await deleteApp();
519
- const err = "Deno Deploy did not return a revision id";
520
- logError(err);
521
- throw new Error(err);
522
- }
523
-
524
- // 3. Poll until the revision succeeds (2s interval, 120s maximum timeout)
525
- onProgress?.("Waiting for Deno Deploy build to finish…");
526
- const deadline = Date.now() + 120_000;
527
- let info: DenoRevision | undefined;
528
-
529
- while (Date.now() < deadline) {
530
- await new Promise<void>((r) => setTimeout(r, 2000));
531
- let s: Response | null = null;
532
- try {
533
- s = await fetch(`${DENO_API}/revisions/${revisionId}`, { headers: auth });
534
- } catch (err) {
535
- log(
536
- "warn",
537
- `Deno Deploy revision status poll failed, retrying: ${(err as Error).message}`,
538
- );
539
- }
540
- if (!s?.ok) continue;
541
- info = (await s.json()) as DenoRevision;
542
- if (info.status === "succeeded") break;
543
- if (info.status === "failed" || info.status === "skipped") {
544
- await deleteApp();
545
- const reason = info.failure_reason ? ` (${info.failure_reason})` : "";
546
- const err = `Deno Deploy build failed${reason}`;
547
- logError(err);
548
- throw new Error(err);
549
- }
550
- }
551
- if (info?.status !== "succeeded") {
552
- await deleteApp();
553
- const timeoutErr = "Deployment timed out (120s)";
554
- logError(timeoutErr);
555
- throw new Error(timeoutErr);
556
- }
557
-
558
- // 4. Resolve the public URL: prefer the hostname routed to this revision,
559
- // falling back to the org's managed *.deno.net wildcard domain.
560
- onProgress?.("Resolving public URL…");
561
- const routed = resolveRoutedDomain(info);
562
- if (routed) {
563
- const url = `https://${routed}`;
564
- log("info", `Deno Deploy relay successfully deployed: ${url}`);
565
- return { url, auth: relayAuth };
566
- }
567
- const managed = await firstManagedDenoDomain(token);
568
- if (!managed) {
569
- const err = `Deployed but could not determine the public URL for "${slug}". Check the app's domain in the Deno Deploy dashboard.`;
570
- logError(err);
571
- throw new Error(err);
572
- }
573
- const url = `https://${slug}.${managed}`;
574
- log("info", `Deno Deploy relay successfully deployed: ${url}`);
575
- return { url, auth: relayAuth };
576
- }
1
+ /**
2
+ * Automated Vercel Edge Relay deployer for pi-freeflow
3
+ *
4
+ * Deploys a private 3-file Vercel edge proxy with strict target domain whitelisting.
5
+ * The provided API token is held in-memory only and never persisted to disk or logs.
6
+ */
7
+
8
+ import { VERCEL_API } from "./config.ts";
9
+ import { log, logError } from "./logger.ts";
10
+ import { randomBytes } from "node:crypto";
11
+
12
+ /**
13
+ * Canonical guarded request pipeline shared by every platform relay template.
14
+ * The platform wrappers below only adapt the entry point (Vercel fetch
15
+ * handler / Cloudflare module worker / Deno.serve) around this core, so the
16
+ * whitelist, SSRF guard, path resolver, header denylist, auth gate and
17
+ * streaming forward live exactly once.
18
+ *
19
+ * @param relayAuth Per-deployment shared secret. Empty string disables the
20
+ * auth gate (legacy/manual deploys); built-in deploys always embed one.
21
+ */
22
+ function buildRelayWorkerCore(relayAuth: string): string {
23
+ return `// Only the 2 upstreams pi-freeflow talks to. Anything else = open proxy abuse.
24
+ const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
25
+ const RELAY_AUTH = ${JSON.stringify(relayAuth)};
26
+ const resolveRelayTarget = function(target, relayPath) {
27
+ let targetUrl;
28
+ try { targetUrl = new URL(target); } catch { return { ok: false, status: 400, reason: "invalid x-relay-target" }; }
29
+ if (typeof relayPath !== "string" || relayPath.indexOf("@") !== -1 || relayPath.indexOf("\\\\") !== -1 || relayPath.charAt(0) !== "/") {
30
+ return { ok: false, status: 403, reason: "forbidden x-relay-path" };
31
+ }
32
+ let finalUrl;
33
+ try { finalUrl = new URL(relayPath, targetUrl); } catch { return { ok: false, status: 403, reason: "forbidden x-relay-path" }; }
34
+ if (finalUrl.hostname !== targetUrl.hostname || finalUrl.protocol !== targetUrl.protocol || finalUrl.port !== targetUrl.port || finalUrl.username || finalUrl.password) {
35
+ return { ok: false, status: 403, reason: "forbidden x-relay-path (host mismatch)" };
36
+ }
37
+ return { ok: true, url: finalUrl.toString() };
38
+ };
39
+ const isPrivateHostname = function(h) {
40
+ if (!h) return true
41
+ let host = String(h).trim().toLowerCase().replace(/^\\[|\\]$/g, "")
42
+ if (host.length > 1 && host.endsWith(".")) host = host.slice(0, -1)
43
+ if (!host) return true
44
+ // Numeric IP literals outside dotted-decimal (decimal 2130706433, short 127.1,
45
+ // hex 0x7f.0.0.1, octal 0177.0.0.1) skip the dotted-quad check below but
46
+ // inet_aton still routes them. Normalize to dotted-decimal so the range
47
+ // checks see the real address. DNS names fall through untouched.
48
+ const segs = host.split(".");
49
+ const numVal = function(p) {
50
+ if (/^0x[0-9a-f]+$/i.test(p)) return parseInt(p, 16);
51
+ if (/^0[0-7]+$/.test(p)) return parseInt(p, 8);
52
+ if (/^[0-9]+$/.test(p)) return parseInt(p, 10);
53
+ return NaN;
54
+ };
55
+ const allNumeric = segs.length >= 1 && segs.length <= 4 && segs.every(function(p) { return p.length > 0 && !isNaN(numVal(p)); });
56
+ const canonicalV4 = segs.length === 4 && segs.every(function(p) { return /^[0-9]+$/.test(p) && (p.length === 1 || p.charAt(0) !== "0") && Number(p) <= 255; });
57
+ if (allNumeric && !canonicalV4) {
58
+ const vals = segs.map(numVal);
59
+ let n = NaN;
60
+ if (segs.length === 1 && vals[0] <= 4294967295) n = vals[0];
61
+ else if (segs.length === 2 && vals[0] <= 255 && vals[1] <= 16777215) n = vals[0] * 16777216 + vals[1];
62
+ else if (segs.length === 3 && vals[0] <= 255 && vals[1] <= 255 && vals[2] <= 65535) n = (vals[0] * 256 + vals[1]) * 65536 + vals[2];
63
+ else if (vals.every(function(v) { return v <= 255; })) n = ((vals[0] * 256 + vals[1]) * 256 + vals[2]) * 256 + vals[3];
64
+ if (!isNaN(n)) host = ((n >>> 24) & 255) + "." + ((n >>> 16) & 255) + "." + ((n >>> 8) & 255) + "." + (n & 255);
65
+ }
66
+ if (host === "localhost" || host === "0.0.0.0" || host === "127.0.0.1" || host.endsWith(".localhost") || host.endsWith(".local") || host.endsWith(".internal")) return true
67
+ if (host.startsWith("::")) return true
68
+ const v4 = host.match(/^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$/)
69
+ if (v4) {
70
+ const a = Number(v4[1])
71
+ const b = Number(v4[2])
72
+ if (a === 0 || a === 10 || a === 127) return true
73
+ if (a === 169 && b === 254) return true
74
+ if (a === 192 && b === 168) return true
75
+ if (a === 172 && b >= 16 && b <= 31) return true
76
+ if (a === 100 && b >= 64 && b <= 127) return true
77
+ return false
78
+ }
79
+ if (host.includes(":")) {
80
+ if (host.startsWith("fc") || host.startsWith("fd")) return true
81
+ if (/^fe[89ab]/.test(host)) return true
82
+ return false
83
+ }
84
+ return false
85
+ };
86
+ // Constant-time string comparison (no timingSafeEqual in every edge runtime).
87
+ const timingSafeEqualStr = function(a, b) {
88
+ if (typeof a !== "string" || typeof b !== "string") return false;
89
+ let diff = a.length ^ b.length;
90
+ const len = Math.max(a.length, b.length);
91
+ for (let i = 0; i < len; i++) {
92
+ diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
93
+ }
94
+ return diff === 0;
95
+ };
96
+ // x-relay-auth is read by the handler below, so it is stripped explicitly
97
+ // (never forwarded upstream) instead of living in the denylist.
98
+ const DENY_HEADERS = ["host", "connection", "content-length", "keep-alive", "proxy-connection", "proxy-authenticate", "proxy-authorization", "transfer-encoding", "te", "trailer", "upgrade", "x-relay-target", "x-relay-path"];
99
+ async function relayHandler(req) {
100
+ if (!["GET", "POST", "HEAD", "OPTIONS"].includes(req.method)) return new Response("Method Not Allowed", { status: 405 });
101
+ if (Number(req.headers.get("content-length") || 0) > 32 * 1024 * 1024) return new Response("Payload Too Large", { status: 413 });
102
+ const target = req.headers.get("x-relay-target");
103
+ if (!target) return new Response(JSON.stringify({ error: "Missing x-relay-target header" }), { status: 400, headers: { "content-type": "application/json" } });
104
+ if (RELAY_AUTH && !timingSafeEqualStr(req.headers.get("x-relay-auth") || "", RELAY_AUTH)) {
105
+ return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401, headers: { "content-type": "application/json" } });
106
+ }
107
+ let targetUrl;
108
+ try { targetUrl = new URL(target); } catch { return new Response(JSON.stringify({ error: "invalid x-relay-target" }), { status: 400, headers: { "content-type": "application/json" } }); }
109
+ if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") return new Response(JSON.stringify({ error: "forbidden x-relay-target protocol" }), { status: 403, headers: { "content-type": "application/json" } });
110
+ if (targetUrl.username || targetUrl.password) return new Response(JSON.stringify({ error: "forbidden x-relay-target (embedded credentials)" }), { status: 403, headers: { "content-type": "application/json" } });
111
+ if (isPrivateHostname(targetUrl.hostname)) return new Response(JSON.stringify({ error: "forbidden x-relay-target (private/loopback host)" }), { status: 403, headers: { "content-type": "application/json" } });
112
+ const cleanTarget = target.replace(/\\/$/, "");
113
+ if (!ALLOWED_TARGETS.includes(cleanTarget)) return new Response(JSON.stringify({ error: "Forbidden target" }), { status: 403, headers: { "content-type": "application/json" } });
114
+ const relayPath = req.headers.get("x-relay-path") || "/";
115
+ const resolved = resolveRelayTarget(target, relayPath);
116
+ if (!resolved.ok) return new Response(JSON.stringify({ error: resolved.reason }), { status: resolved.status, headers: { "content-type": "application/json" } });
117
+ const headers = new Headers(req.headers);
118
+ DENY_HEADERS.forEach((h) => headers.delete(h));
119
+ headers.delete("x-relay-auth");
120
+ try {
121
+ const init = { method: req.method, headers };
122
+ if (req.method !== "GET" && req.method !== "HEAD") { init.body = req.body; init.duplex = "half"; }
123
+ const response = await fetch(resolved.url, init);
124
+ return new Response(response.body, { status: response.status, headers: response.headers });
125
+ } catch (error) {
126
+ return new Response(JSON.stringify({ error: String(error) }), { status: 502, headers: { "content-type": "application/json" } });
127
+ }
128
+ }`;
129
+ }
130
+
131
+ /**
132
+ * Vercel Edge Function relay template: canonical core wrapped in the edge-runtime module handler.
133
+ */
134
+ export function buildVercelRelayWorker(relayAuth: string): string {
135
+ return `${buildRelayWorkerCore(relayAuth)}
136
+ export const config = { runtime: "edge" };
137
+ export default async function handler(req) {
138
+ return relayHandler(req);
139
+ }`;
140
+ }
141
+
142
+ /**
143
+ * Cloudflare Workers module relay template: canonical core wrapped in the module fetch handler.
144
+ */
145
+ export function buildCloudflareRelayWorker(relayAuth: string): string {
146
+ return `${buildRelayWorkerCore(relayAuth)}
147
+ export default {
148
+ async fetch(request) {
149
+ return relayHandler(request);
150
+ },
151
+ };`;
152
+ }
153
+
154
+ /**
155
+ * Deno Deploy relay script template: canonical core wrapped in Deno.serve.
156
+ */
157
+ export function buildDenoRelayScript(relayAuth: string): string {
158
+ return `${buildRelayWorkerCore(relayAuth)}
159
+ Deno.serve(async (request) => {
160
+ return relayHandler(request);
161
+ });`;
162
+ }
163
+
164
+ /**
165
+ * Public template constants (tests/README): the canonical core without a
166
+ * secret, so the auth gate is disabled. Deployed relays always embed their
167
+ * own per-deployment secret.
168
+ */
169
+ export const VERCEL_RELAY_WORKER = buildVercelRelayWorker("");
170
+ export const CLOUDFLARE_RELAY_WORKER = buildCloudflareRelayWorker("");
171
+ export const DENO_RELAY_SCRIPT = buildDenoRelayScript("");
172
+
173
+ async function vercelError(action: string, res: Response): Promise<Error> {
174
+ const e = (await res
175
+ .json()
176
+ .catch(() => ({}))) as { error?: { message?: string } };
177
+ const detail = e?.error?.message || `HTTP ${res.status}`;
178
+ let err: Error;
179
+ if (res.status === 401 || res.status === 403) {
180
+ err = new Error(`Vercel authentication failed while trying to ${action}: ${detail}. Check that your API token is valid.`);
181
+ } else {
182
+ err = new Error(`Failed to ${action} (HTTP ${res.status}): ${detail}`);
183
+ }
184
+ logError(err.message);
185
+ return err;
186
+ }
187
+
188
+ /**
189
+ * Deploy a fresh Vercel Edge Relay project in-memory.
190
+ *
191
+ * @param token Vercel personal access token (used in-memory only)
192
+ * @param name Unique project/deployment name (e.g. pi-freeflow-relay-abc123)
193
+ * @param onProgress Optional callback for user-facing progress updates
194
+ * @returns Deployed { url, auth } — url is the relay URL, auth the embedded shared secret (minted per deployment unless authSecret is passed)
195
+ */
196
+ export async function deployVercelRelay(
197
+ token: string,
198
+ name: string,
199
+ onProgress?: (msg: string) => void,
200
+ authSecret: string = "",
201
+ ): Promise<{ url: string; auth: string }> {
202
+ name = baseRelayName(name) || "relay-worker";
203
+ const auth = {
204
+ Authorization: `Bearer ${token}`,
205
+ "Content-Type": "application/json",
206
+ };
207
+ // 1. Create deployment (3 inline files, no git repository required)
208
+ onProgress?.("Uploading relay files to Vercel…");
209
+ log("info", `Starting Vercel deployment: ${name}`);
210
+ // Private by default: mint a per-deployment secret unless the caller passes
211
+ // one explicitly (e.g. rotation). The secret is embedded in the worker and
212
+ // returned so the pool can send it back as x-relay-auth.
213
+ const relayAuth = authSecret || randomBytes(32).toString("hex");
214
+
215
+ const dep = await fetch(`${VERCEL_API}/v13/deployments`, {
216
+ method: "POST",
217
+ headers: auth,
218
+ body: JSON.stringify({
219
+ name,
220
+ files: [
221
+ { file: "api/relay.js", data: buildVercelRelayWorker(relayAuth) },
222
+ {
223
+ file: "package.json",
224
+ data: JSON.stringify({ name, version: "1.0.0" }),
225
+ },
226
+ {
227
+ file: "vercel.json",
228
+ data: JSON.stringify({
229
+ rewrites: [{ source: "/(.*)", destination: "/api/relay" }],
230
+ }),
231
+ },
232
+ ],
233
+ projectSettings: { framework: null },
234
+ target: "production",
235
+ }),
236
+ });
237
+
238
+ if (!dep.ok) throw await vercelError("create Vercel deployment", dep);
239
+
240
+ const depJson = (await dep.json()) as { id?: string; uid?: string; projectId?: string };
241
+ const depId = depJson.id || depJson.uid;
242
+ const projectId = depJson.projectId || name;
243
+
244
+ // 2. Make the deployment public (disable SSO protection if enabled on team)
245
+ try {
246
+ await fetch(`${VERCEL_API}/v9/projects/${projectId}`, {
247
+ method: "PATCH",
248
+ headers: auth,
249
+ body: JSON.stringify({ ssoProtection: null }),
250
+ });
251
+ } catch { }
252
+
253
+ // 3. Poll until READY state (3s interval, 120s maximum timeout).
254
+ // A failed/timed-out run removes its own deployment so no orphan (with the
255
+ // embedded secret) lingers. The project shell is left alone: the name may
256
+ // collide with a pre-existing project the token owner already uses.
257
+ onProgress?.("Waiting for Edge deployment to go live…");
258
+ const deadline = Date.now() + 120_000;
259
+ const deleteDeployment = (): Promise<void> =>
260
+ fetch(`${VERCEL_API}/v13/deployments/${depId}`, {
261
+ method: "DELETE",
262
+ headers: { Authorization: `Bearer ${token}` },
263
+ })
264
+ .then(() => undefined)
265
+ .catch(() => { });
266
+
267
+ while (Date.now() < deadline) {
268
+ let s: Response | null = null;
269
+ try {
270
+ s = await fetch(`${VERCEL_API}/v13/deployments/${depId}`, {
271
+ headers: { Authorization: `Bearer ${token}` },
272
+ });
273
+ } catch (err) {
274
+ log(
275
+ "warn",
276
+ `Vercel deployment status poll failed, retrying: ${(err as Error).message}`,
277
+ );
278
+ }
279
+ if (s && !s.ok) {
280
+ if (s.status === 401 || s.status === 403) throw await vercelError("poll Vercel deployment status", s);
281
+ if (s.status === 404) {
282
+ const gone = `Deployment ${depId ?? name} disappeared while waiting (deleted externally?)`;
283
+ logError(gone);
284
+ throw new Error(gone);
285
+ }
286
+ }
287
+ if (s?.ok) {
288
+ const j = (await s.json()) as { readyState?: string; url?: string };
289
+ if (j.readyState === "READY" && j.url) {
290
+ const deployedUrl = `https://${j.url}`;
291
+ log("info", `Vercel relay successfully deployed: ${deployedUrl}`);
292
+ return { url: deployedUrl, auth: relayAuth };
293
+ }
294
+ if (j.readyState === "ERROR" || j.readyState === "CANCELED") {
295
+ await deleteDeployment();
296
+ const err = `Deployment failed with state: ${j.readyState}`;
297
+ logError(err);
298
+ throw new Error(err);
299
+ }
300
+ }
301
+ await new Promise<void>((r) => setTimeout(r, 3000));
302
+ }
303
+
304
+ await deleteDeployment();
305
+ const timeoutErr = "Deployment timed out (120s)";
306
+ logError(timeoutErr);
307
+ throw new Error(timeoutErr);
308
+ }
309
+
310
+ // ── Multi-platform deployment (Cloudflare Workers / Deno Deploy) ────
311
+
312
+ const CLOUDFLARE_API = "https://api.cloudflare.com/client/v4";
313
+ const DENO_API = "https://api.deno.com/v2";
314
+
315
+ export type DeployPlatform = "vercel" | "cloudflare" | "deno";
316
+
317
+ export function baseRelayName(name: string): string {
318
+ return name
319
+ .toLowerCase()
320
+ .replace(/[^a-z0-9-]+/g, "-")
321
+ .replace(/-{2,}/g, "-")
322
+ .replace(/^-+|-+$/g, "");
323
+ }
324
+
325
+ /** Cloudflare Worker script names: [a-z0-9-], max 58 chars. */
326
+ export function cloudflareScriptName(name: string): string {
327
+ const clean = baseRelayName(name)
328
+ .slice(0, 58)
329
+ .replace(/-+$/g, "");
330
+ return clean || "relay-worker";
331
+ }
332
+
333
+ /** Deno Deploy app slugs: [a-z0-9-], 3-32 chars, no edge/consecutive hyphens. */
334
+ export function denoProjectName(name: string): string {
335
+ const clean = baseRelayName(name)
336
+ .slice(0, 32)
337
+ .replace(/-+$/, "");
338
+ if (!clean) return "relay-app";
339
+ return clean.length < 3 ? `${clean}-relay` : clean;
340
+ }
341
+
342
+ async function cloudflareError(action: string, res: Response): Promise<Error> {
343
+ const body = (await res
344
+ .json()
345
+ .catch(() => ({}))) as { errors?: Array<{ message?: string }> };
346
+ const detail = body.errors?.[0]?.message || `HTTP ${res.status}`;
347
+ let err: Error;
348
+ if (res.status === 401 || res.status === 403) {
349
+ err = /quota|limit|exceeded/i.test(detail)
350
+ ? new Error(`Cloudflare plan or usage limit hit while trying to ${action}: ${detail}. Check your Workers plan limits.`)
351
+ : new Error(`Cloudflare authentication failed while trying to ${action}: ${detail}. Check that your API token is valid and has Workers permissions.`);
352
+ } else {
353
+ err = new Error(`Failed to ${action} (HTTP ${res.status}): ${detail}`);
354
+ }
355
+ logError(err.message);
356
+ return err;
357
+ }
358
+
359
+ async function denoError(action: string, res: Response, override?: string): Promise<Error> {
360
+ if (override) {
361
+ logError(override);
362
+ return new Error(override);
363
+ }
364
+ const raw = await res.text().catch(() => "");
365
+ let detail = raw;
366
+ try {
367
+ const parsed = JSON.parse(raw) as { error?: { message?: string } };
368
+ detail = parsed.error?.message || raw;
369
+ } catch { }
370
+ let err: Error;
371
+ if (res.status === 401 || res.status === 403) {
372
+ err = /quota|limit|exceeded/i.test(detail)
373
+ ? new Error(`Deno Deploy plan or usage limit hit while trying to ${action}: ${detail}. Check your organization's limits.`)
374
+ : new Error(`Deno Deploy authentication failed while trying to ${action}: ${detail}. Check that your access token is valid.`);
375
+ } else {
376
+ err = new Error(`Failed to ${action} (HTTP ${res.status}): ${detail}`);
377
+ }
378
+ logError(err.message);
379
+ return err;
380
+ }
381
+
382
+ /**
383
+ * Deploy a fresh Cloudflare Workers relay (module worker) in-memory.
384
+ *
385
+ * @param token Cloudflare API token (used in-memory only)
386
+ * @param name Unique worker/script name (sanitized to [a-z0-9-])
387
+ * @param onProgress Optional callback for user-facing progress updates
388
+ * @returns Deployed { url, auth } — url is the relay URL, auth the embedded shared secret (minted per deployment unless authSecret is passed)
389
+ */
390
+ export async function deployCloudflareWorker(
391
+ token: string,
392
+ name: string,
393
+ onProgress?: (msg: string) => void,
394
+ authSecret: string = "",
395
+ ): Promise<{ url: string; auth: string }> {
396
+ const auth = { Authorization: `Bearer ${token}` };
397
+ const scriptName = cloudflareScriptName(name);
398
+
399
+ // 1. Resolve the account scoped to this token
400
+ onProgress?.("Resolving Cloudflare account…");
401
+ log("info", `Starting Cloudflare Worker deployment: ${scriptName}`);
402
+ const accRes = await fetch(`${CLOUDFLARE_API}/accounts`, { headers: auth });
403
+ if (!accRes.ok) throw await cloudflareError("resolve Cloudflare account", accRes);
404
+ const accJson = (await accRes.json()) as { result?: Array<{ id?: string }> };
405
+ const accountId = accJson.result?.[0]?.id;
406
+ if (!accountId) {
407
+ const err = "No Cloudflare account is accessible with this API token";
408
+ logError(err);
409
+ throw new Error(err);
410
+ }
411
+
412
+ // 2. Upload the module worker script (multipart: main module + metadata)
413
+ onProgress?.("Uploading relay worker to Cloudflare…");
414
+ // Private by default: mint a per-deployment secret unless the caller passes
415
+ // one explicitly (e.g. rotation). The secret is embedded in the worker and
416
+ // returned so the pool can send it back as x-relay-auth.
417
+ const relayAuth = authSecret || randomBytes(32).toString("hex");
418
+ const formData = new FormData();
419
+ formData.append(
420
+ "index.js",
421
+ new Blob([buildCloudflareRelayWorker(relayAuth)], { type: "application/javascript+module" }),
422
+ "index.js",
423
+ );
424
+ formData.append(
425
+ "metadata",
426
+ new Blob(
427
+ [
428
+ JSON.stringify({
429
+ main_module: "index.js",
430
+ compatibility_date: "2024-03-20",
431
+ observability: { enabled: true },
432
+ }),
433
+ ],
434
+ { type: "application/json" },
435
+ ),
436
+ "metadata.json",
437
+ );
438
+ const uploadRes = await fetch(
439
+ `${CLOUDFLARE_API}/accounts/${accountId}/workers/scripts/${scriptName}`,
440
+ { method: "PUT", headers: auth, body: formData },
441
+ );
442
+ if (!uploadRes.ok) throw await cloudflareError("upload Worker to Cloudflare", uploadRes);
443
+
444
+ // 3. Enable workers.dev routing for the script (non-fatal if it fails)
445
+ try {
446
+ await fetch(`${CLOUDFLARE_API}/accounts/${accountId}/workers/scripts/${scriptName}/subdomain`, {
447
+ method: "POST",
448
+ headers: { ...auth, "Content-Type": "application/json" },
449
+ body: JSON.stringify({ enabled: true }),
450
+ });
451
+ } catch { }
452
+
453
+ // 4. Read the account-level workers.dev subdomain to assemble the public URL.
454
+ // The script is already uploaded here, so every failure below removes it
455
+ // again — otherwise an unreachable worker (with the embedded secret) lingers.
456
+ onProgress?.("Reading workers.dev routing…");
457
+ const deleteScript = (): Promise<void> =>
458
+ fetch(`${CLOUDFLARE_API}/accounts/${accountId}/workers/scripts/${scriptName}`, {
459
+ method: "DELETE",
460
+ headers: auth,
461
+ })
462
+ .then(() => undefined)
463
+ .catch(() => { });
464
+ let subRes: Response;
465
+ try {
466
+ subRes = await fetch(`${CLOUDFLARE_API}/accounts/${accountId}/workers/subdomain`, { headers: auth });
467
+ } catch (err) {
468
+ await deleteScript();
469
+ throw err;
470
+ }
471
+ if (!subRes.ok) {
472
+ await deleteScript();
473
+ throw await cloudflareError("retrieve workers.dev subdomain", subRes);
474
+ }
475
+ const subJson = (await subRes.json()) as { result?: { subdomain?: string } };
476
+ const subdomain = subJson.result?.subdomain;
477
+ if (!subdomain) {
478
+ await deleteScript();
479
+ const err = "Worker deployed but workers.dev subdomain is unavailable. Enable a workers.dev subdomain for your account in the Cloudflare dashboard.";
480
+ logError(err);
481
+ throw new Error(err);
482
+ }
483
+ const url = `https://${scriptName}.${subdomain}.workers.dev`;
484
+ log("info", `Cloudflare relay successfully deployed: ${url}`);
485
+ return { url, auth: relayAuth };
486
+ }
487
+
488
+ type DenoRevision = {
489
+ status?: string;
490
+ failure_reason?: string | null;
491
+ timelines?: Array<{ slug?: string; domains?: Array<{ domain?: string }> }>;
492
+ };
493
+
494
+ function resolveRoutedDomain(revision: DenoRevision): string | null {
495
+ const timelines = revision.timelines ?? [];
496
+ const production = timelines.find((t) => t.slug === "production") ?? timelines[0];
497
+ const host = (production?.domains ?? [])
498
+ .map((d) => d.domain ?? "")
499
+ .find((h) => h.length > 0);
500
+ return host ?? null;
501
+ }
502
+
503
+ async function firstManagedDenoDomain(token: string): Promise<string | null> {
504
+ const res = await fetch(`${DENO_API}/domains`, {
505
+ headers: { Authorization: `Bearer ${token}` },
506
+ });
507
+ if (!res.ok) return null;
508
+ const list = (await res.json().catch(() => [])) as Array<{ domain?: string }>;
509
+ const managed = (Array.isArray(list) ? list : [])
510
+ .map((d) => d.domain ?? "")
511
+ .find((h) => h.endsWith(".deno.net"));
512
+ return managed ? managed.replace(/^\*\./, "") : null;
513
+ }
514
+
515
+ /**
516
+ * Deploy a fresh Deno Deploy relay (Deno.serve script) in-memory.
517
+ * The v2 API is scoped to the token's organization, so no org input is needed.
518
+ *
519
+ * @param token Deno Deploy organization access token (used in-memory only)
520
+ * @param name Unique app/project name (sanitized to a valid slug)
521
+ * @param onProgress Optional callback for user-facing progress updates
522
+ * @returns Deployed { url, auth } — url is the relay URL, auth the embedded shared secret (minted per deployment unless authSecret is passed)
523
+ */
524
+ export async function deployDenoRelay(
525
+ token: string,
526
+ name: string,
527
+ onProgress?: (msg: string) => void,
528
+ authSecret: string = "",
529
+ ): Promise<{ url: string; auth: string }> {
530
+ const slug = denoProjectName(name);
531
+ const auth = { Authorization: `Bearer ${token}` };
532
+ const jsonHeaders = { ...auth, "Content-Type": "application/json" };
533
+ onProgress?.("Creating Deno Deploy app…");
534
+ log("info", `Starting Deno Deploy deployment: ${slug}`);
535
+ const createRes = await fetch(`${DENO_API}/apps`, {
536
+ method: "POST",
537
+ headers: jsonHeaders,
538
+ body: JSON.stringify({
539
+ slug,
540
+ labels: { "custom.kind": "relay" },
541
+ config: {
542
+ install: "deno install",
543
+ runtime: { type: "dynamic", entrypoint: "main.ts" },
544
+ },
545
+ }),
546
+ });
547
+ if (!createRes.ok) {
548
+ throw await denoError(
549
+ `create Deno Deploy app "${slug}"`,
550
+ createRes,
551
+ createRes.status === 409
552
+ ? `An app named "${slug}" already exists on Deno Deploy — choose a different name.`
553
+ : undefined,
554
+ );
555
+ }
556
+ const app = (await createRes.json()) as { id?: string };
557
+ const appId = app.id;
558
+ if (!appId) {
559
+ const err = "Deno Deploy did not return an app id";
560
+ logError(err);
561
+ throw new Error(err);
562
+ }
563
+ const deleteApp = (): Promise<void> =>
564
+ fetch(`${DENO_API}/apps/${appId}`, { method: "DELETE", headers: auth })
565
+ .then(() => undefined)
566
+ .catch(() => { });
567
+
568
+ // 2. Push the relay source as a single-file revision
569
+ onProgress?.("Uploading relay script to Deno Deploy…");
570
+ // Private by default: mint a per-deployment secret unless the caller passes
571
+ // one explicitly (e.g. rotation). The secret is embedded in the script and
572
+ // returned so the pool can send it back as x-relay-auth.
573
+ const relayAuth = authSecret || randomBytes(32).toString("hex");
574
+ let deployRes: Response;
575
+ try {
576
+ deployRes = await fetch(`${DENO_API}/apps/${appId}/deploy`, {
577
+ method: "POST",
578
+ headers: jsonHeaders,
579
+ body: JSON.stringify({
580
+ assets: {
581
+ "main.ts": { kind: "file", content: buildDenoRelayScript(relayAuth), encoding: "utf-8" },
582
+ },
583
+ }),
584
+ });
585
+ } catch (err) {
586
+ await deleteApp();
587
+ throw err;
588
+ }
589
+ if (!deployRes.ok) {
590
+ await deleteApp();
591
+ throw await denoError("upload relay script", deployRes);
592
+ }
593
+ const revision = (await deployRes.json()) as { id?: string };
594
+ const revisionId = revision.id;
595
+ if (!revisionId) {
596
+ await deleteApp();
597
+ const err = "Deno Deploy did not return a revision id";
598
+ logError(err);
599
+ throw new Error(err);
600
+ }
601
+
602
+ // 3. Poll until the revision succeeds (2s interval, 120s maximum timeout)
603
+ onProgress?.("Waiting for Deno Deploy build to finish…");
604
+ const deadline = Date.now() + 120_000;
605
+ let info: DenoRevision | undefined;
606
+
607
+ while (Date.now() < deadline) {
608
+ await new Promise<void>((r) => setTimeout(r, 2000));
609
+ let s: Response | null = null;
610
+ try {
611
+ s = await fetch(`${DENO_API}/revisions/${revisionId}`, { headers: auth });
612
+ } catch (err) {
613
+ log(
614
+ "warn",
615
+ `Deno Deploy revision status poll failed, retrying: ${(err as Error).message}`,
616
+ );
617
+ }
618
+ if (!s?.ok) continue;
619
+ info = (await s.json()) as DenoRevision;
620
+ if (info.status === "succeeded") break;
621
+ if (info.status === "failed" || info.status === "skipped") {
622
+ await deleteApp();
623
+ const reason = info.failure_reason ? ` (${info.failure_reason})` : "";
624
+ const err = `Deno Deploy build failed${reason}`;
625
+ logError(err);
626
+ throw new Error(err);
627
+ }
628
+ }
629
+ if (info?.status !== "succeeded") {
630
+ await deleteApp();
631
+ const timeoutErr = "Deployment timed out (120s)";
632
+ logError(timeoutErr);
633
+ throw new Error(timeoutErr);
634
+ }
635
+
636
+ // 4. Resolve the public URL: prefer the hostname routed to this revision,
637
+ // falling back to the org's managed *.deno.net wildcard domain.
638
+ onProgress?.("Resolving public URL…");
639
+ const routed = resolveRoutedDomain(info);
640
+ if (routed) {
641
+ const url = `https://${routed}`;
642
+ log("info", `Deno Deploy relay successfully deployed: ${url}`);
643
+ return { url, auth: relayAuth };
644
+ }
645
+ const managed = await firstManagedDenoDomain(token).catch(() => null);
646
+ if (!managed) {
647
+ await deleteApp();
648
+ const err = `Deployed but could not determine the public URL for "${slug}". Check the app's domain in the Deno Deploy dashboard.`;
649
+ logError(err);
650
+ throw new Error(err);
651
+ }
652
+ const url = `https://${slug}.${managed}`;
653
+ log("info", `Deno Deploy relay successfully deployed: ${url}`);
654
+ return { url, auth: relayAuth };
655
+ }