File size: 6,818 Bytes
cd8bd0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
import { randomBytes } from "crypto";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { cloudflareDeploySchema } from "@/shared/validation/freeProxySchemas";
import { createProxy } from "@/lib/localDb";
import { encrypt } from "@/lib/db/encryption";
import { buildCloudflareWorkerScript } from "@/lib/proxyRelay/cloudflareWorkerScript";

// Port of upstream decolua/9router PR #1360 β€” Cloudflare Workers proxy relay.
// Architecture mirrors src/app/api/settings/proxy/vercel-deploy/route.ts so the
// shared proxyFetch relay short-circuit, x-relay-auth scheme, and inline SSRF
// guard work unchanged. Only the deployment surface differs (Cloudflare Workers
// API instead of Vercel /v13/deployments).

const CLOUDFLARE_API_BASE = process.env.CLOUDFLARE_API_BASE || "https://api.cloudflare.com/client/v4";

export async function POST(request: Request) {
  const authError = await requireManagementAuth(request);
  if (authError) return authError;

  let rawBody: unknown = {};
  try {
    rawBody = await request.json();
  } catch {
    return createErrorResponse({
      status: 400,
      message: "Invalid JSON body",
      type: "invalid_request",
    });
  }

  const validation = validateBody(cloudflareDeploySchema, rawBody);
  if (isValidationFailure(validation)) {
    return createErrorResponse({
      status: 400,
      message: validation.error.message,
      type: "invalid_request",
    });
  }

  const { accountId, apiToken, projectName } = validation.data;

  // Generate random auth secret for the relay β€” stored in proxy notes, never
  // returned to client. Same scheme as the Vercel relay so the deployed worker
  // is not an open SSRF proxy reachable from any third party with the workers.dev URL.
  const relayAuth = randomBytes(24).toString("hex");
  const workerScript = buildCloudflareWorkerScript(relayAuth);

  try {
    // 1. PUT the Worker script β€” Cloudflare requires multipart/form-data with
    //    main_module + a metadata blob describing the upload.
    const workerScriptUrl = `${CLOUDFLARE_API_BASE}/accounts/${accountId}/workers/scripts/${projectName}`;
    const formData = new FormData();
    formData.append(
      "index.js",
      // Cloudflare's script-upload API only accepts application/javascript,
      // text/javascript, or multipart/form-data for the script part and rejects
      // "application/javascript+module" outright (#5128). ES-module semantics
      // come from `main_module` in the metadata blob below, not this MIME type.
      new Blob([workerScript], { type: "application/javascript" }),
      "index.js"
    );
    formData.append(
      "metadata",
      new Blob(
        [
          JSON.stringify({
            main_module: "index.js",
            compatibility_date: "2026-03-20",
            observability: { enabled: true },
          }),
        ],
        { type: "application/json" }
      ),
      "metadata.json"
    );

    const uploadRes = await fetch(workerScriptUrl, {
      method: "PUT",
      headers: { Authorization: `Bearer ${apiToken}` },
      body: formData,
    });

    if (!uploadRes.ok) {
      // Surface only the canonical Cloudflare error message; never forward raw
      // response text (may carry internal IDs / token hints).
      let upstreamMessage = "Cloudflare API rejected the Worker upload";
      try {
        const parsed = (await uploadRes.json().catch(() => null)) as {
          errors?: Array<{ message?: string }>;
        } | null;
        const candidate = parsed?.errors?.[0]?.message;
        if (typeof candidate === "string" && candidate.trim()) {
          upstreamMessage = candidate.trim().slice(0, 200);
        }
      } catch {
        /* fall through to generic message */
      }
      return createErrorResponse({
        status: uploadRes.status,
        message: `Cloudflare Worker upload failed: ${upstreamMessage}`,
        type: "upstream_error",
      });
    }

    // 2. Enable the workers.dev subdomain for this script so it is reachable.
    //    A failure here is non-fatal (some accounts already enable subdomains
    //    by default); the next call surfaces the correct error if anything is
    //    actually missing.
    await fetch(`${workerScriptUrl}/subdomain`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiToken}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ enabled: true }),
    }).catch(() => {});

    // 3. Look up the account's workers.dev subdomain to build the final URL.
    const subdomainRes = await fetch(
      `${CLOUDFLARE_API_BASE}/accounts/${accountId}/workers/subdomain`,
      {
        method: "GET",
        headers: {
          Authorization: `Bearer ${apiToken}`,
          "Content-Type": "application/json",
        },
      }
    );

    let deployUrl = "";
    if (subdomainRes.ok) {
      const subdomainData = (await subdomainRes.json().catch(() => null)) as {
        result?: { subdomain?: string };
      } | null;
      const sub = subdomainData?.result?.subdomain;
      if (typeof sub === "string" && sub) {
        deployUrl = `https://${projectName}.${sub}.workers.dev`;
      }
    }

    if (!deployUrl) {
      return createErrorResponse({
        status: 400,
        message:
          "Worker deployed but failed to retrieve workers.dev subdomain. Set up a workers.dev subdomain in the Cloudflare dashboard first.",
        type: "upstream_error",
      });
    }

    // Store as proxy pool entry β€” apiToken is NOT stored. relayAuth is
    // encrypted at rest when STORAGE_ENCRYPTION_KEY is configured (encrypt() is
    // a no-op in passthrough mode); the redactor strips both shapes from API responses.
    const encryptedRelayAuth = encrypt(relayAuth);
    const notesPayload =
      encryptedRelayAuth && encryptedRelayAuth !== relayAuth
        ? { relayAuthEnc: encryptedRelayAuth }
        : { relayAuth };

    // deployUrl is "https://<name>.<sub>.workers.dev" β€” strip the protocol so
    // the `host` column matches the Vercel-relay shape (proxyFetch prepends
    // "https://" when routing).
    const hostOnly = deployUrl.replace(/^https?:\/\//, "");
    const poolProxy = await createProxy({
      name: `Cloudflare Relay (${projectName})`,
      type: "cloudflare",
      host: hostOnly,
      port: 443,
      notes: JSON.stringify(notesPayload),
      source: "cloudflare-relay",
    });

    return Response.json({
      success: true,
      relayUrl: deployUrl,
      poolProxyId: poolProxy?.id,
    });
  } catch (error) {
    return createErrorResponseFromUnknown(error, "Cloudflare deploy failed");
  }
}