Skip to content

Commit 10c1e94

Browse files
ryan-williamsclaude
andcommitted
fix CF deploy: handle non-JSON responses, infer account ID from token
Two fixes for Cloudflare Workers integration: 1. `cfApi()` now checks `Content-Type` before calling `.json()` — the CF Workers upload endpoint can return multipart boundaries instead of JSON, which caused `SyntaxError` in GHA deploys. 2. `resolveAccountId()` infers account ID from API token via `GET /accounts` when `CLOUDFLARE_ACCOUNT_ID` is not set. Single account auto-resolves; multiple accounts lists them with an error. Used by both `deploy` and `ls`. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b175810 commit 10c1e94

File tree

4 files changed

+138
-15
lines changed

4 files changed

+138
-15
lines changed
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Fix CF deploy: multipart upload response parsing
2+
3+
## Bug
4+
5+
`cors-prxy deploy` fails in GHA (and likely any non-interactive env) with:
6+
7+
```
8+
SyntaxError: No number after minus sign in JSON at position 1 (line 1 column 2)
9+
at JSON.parse (<anonymous>)
10+
...
11+
at async deployCf (deploy-cf.js:46:22)
12+
```
13+
14+
The response body starts with `--591155c8b58d...` — a multipart boundary string, not JSON.
15+
16+
## Cause
17+
18+
`cfApi()` unconditionally calls `resp.json()` on every response. The CF Workers upload endpoint (`PUT /accounts/{id}/workers/scripts/{name}`) with a `multipart/form-data` body may return a non-JSON response (or echo the boundary). The function doesn't check `Content-Type` or response status before parsing.
19+
20+
## Fix
21+
22+
In `deploy-cf.ts`, `cfApi` should:
23+
1. Check `response.ok` or at least `Content-Type` before calling `.json()`
24+
2. If the response isn't JSON, read as text and wrap in a structured error
25+
3. Ideally log the raw response body on failure for debugging
26+
27+
```typescript
28+
async function cfApi(path: string, apiToken: string, opts: RequestInit = {}) {
29+
const resp = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
30+
...opts,
31+
headers: {
32+
"Authorization": `Bearer ${apiToken}`,
33+
...opts.headers as Record<string, string>,
34+
},
35+
})
36+
const contentType = resp.headers.get("content-type") ?? ""
37+
if (!contentType.includes("application/json")) {
38+
const text = await resp.text()
39+
if (!resp.ok) {
40+
throw new Error(`CF API ${resp.status}: ${text.slice(0, 200)}`)
41+
}
42+
// Some success responses may not be JSON
43+
return { success: resp.ok, result: undefined, errors: [] }
44+
}
45+
return resp.json()
46+
}
47+
```
48+
49+
## Reproducer
50+
51+
Deploy from GHA with `CLOUDFLARE_API_TOKEN` secret set:
52+
53+
```yaml
54+
- run: cors-prxy deploy
55+
env:
56+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
57+
```
58+
59+
Works locally, fails in GHA. May be a difference in Node.js fetch behavior or CF API response based on request details.
60+
61+
## Context
62+
63+
Hit in https://github.com/runsascoded/aws-static-sso CI — GHA run 22867512771, `deploy-worker` job.

specs/infer-cf-account-id.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Infer Cloudflare account ID from API token
2+
3+
## Problem
4+
5+
`cors-prxy ls` requires `CLOUDFLARE_ACCOUNT_ID` env var to list CF Workers. If it's not set, CF workers are silently skipped (`listCfProxies` returns `[]`). This is surprising — the user has `CLOUDFLARE_API_TOKEN` set and expects `ls` to work.
6+
7+
Similarly, `deploy` requires `cloudflare.accountId` in config or `CLOUDFLARE_ACCOUNT_ID` env var.
8+
9+
## Fix
10+
11+
The Cloudflare API supports listing accounts a token has access to:
12+
13+
```
14+
GET https://api.cloudflare.com/client/v4/accounts
15+
Authorization: Bearer <token>
16+
```
17+
18+
If `CLOUDFLARE_ACCOUNT_ID` is not set:
19+
1. Call `GET /accounts` with the token
20+
2. If exactly one account is returned, use it
21+
3. If multiple accounts, error with a message listing them and asking the user to set `CLOUDFLARE_ACCOUNT_ID` or `cloudflare.accountId`
22+
4. If zero accounts (or the call fails), skip CF as today
23+
24+
### Where to apply
25+
26+
- `listCfProxies()` in `tags.ts` — currently bails if no `accountId`
27+
- `getCfAccountId()` in `deploy-cf.ts` — currently throws if no `accountId`
28+
29+
Extract a shared `resolveAccountId(apiToken: string): Promise<string>` helper in `deploy-cf.ts` that both can use.
30+
31+
## Context
32+
33+
Came up using `cors-prxy ls` in [aws-static-sso]`CLOUDFLARE_API_TOKEN` was set but `ls` showed no CF workers until `CLOUDFLARE_ACCOUNT_ID` was also set.
34+
35+
[aws-static-sso]: https://github.com/runsascoded/aws-static-sso

src/deploy-cf.ts

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,34 +14,59 @@ function getCfAuth(): { apiToken: string } {
1414
return { apiToken }
1515
}
1616

17-
function getCfAccountId(config: CorsProxyConfig): string {
18-
const accountId = config.cloudflare?.accountId ?? process.env.CLOUDFLARE_ACCOUNT_ID
19-
if (!accountId) {
20-
throw new Error(
21-
"Cloudflare account ID is required. Set it in config (cloudflare.accountId) " +
22-
"or via CLOUDFLARE_ACCOUNT_ID env var."
23-
)
17+
export async function resolveAccountId(apiToken: string, config?: CorsProxyConfig): Promise<string> {
18+
const explicit = config?.cloudflare?.accountId ?? process.env.CLOUDFLARE_ACCOUNT_ID
19+
if (explicit) return explicit
20+
21+
// Infer from API token
22+
const resp = await cfApi("/accounts", apiToken)
23+
if (!resp.success || !resp.result) {
24+
throw new Error("Failed to list Cloudflare accounts. Set CLOUDFLARE_ACCOUNT_ID or cloudflare.accountId in config.")
25+
}
26+
const accounts = resp.result as unknown as Array<{ id: string; name: string }>
27+
if (accounts.length === 0) {
28+
throw new Error("No Cloudflare accounts found for this API token.")
2429
}
25-
return accountId
30+
if (accounts.length === 1) {
31+
return accounts[0].id
32+
}
33+
const list = accounts.map(a => ` ${a.id} ${a.name}`).join("\n")
34+
throw new Error(
35+
`Multiple Cloudflare accounts found. Set CLOUDFLARE_ACCOUNT_ID or cloudflare.accountId:\n${list}`
36+
)
2637
}
2738

2839
function getWorkerName(config: CorsProxyConfig): string {
2940
return config.cloudflare?.workerName ?? `cors-prxy-${config.name}`
3041
}
3142

43+
interface CfApiResponse {
44+
success: boolean
45+
result?: Record<string, unknown>
46+
errors?: Array<{ message: string }>
47+
}
48+
3249
async function cfApi(
3350
path: string,
3451
apiToken: string,
3552
opts: RequestInit = {},
36-
): Promise<{ success: boolean; result?: Record<string, unknown>; errors?: Array<{ message: string }> }> {
53+
): Promise<CfApiResponse> {
3754
const resp = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
3855
...opts,
3956
headers: {
4057
"Authorization": `Bearer ${apiToken}`,
4158
...opts.headers as Record<string, string>,
4259
},
4360
})
44-
return resp.json() as Promise<{ success: boolean; result?: Record<string, unknown>; errors?: Array<{ message: string }> }>
61+
const contentType = resp.headers.get("content-type") ?? ""
62+
if (!contentType.includes("application/json")) {
63+
const text = await resp.text()
64+
if (!resp.ok) {
65+
throw new Error(`CF API ${resp.status}: ${text.slice(0, 200)}`)
66+
}
67+
return { success: resp.ok, result: undefined, errors: [] }
68+
}
69+
return resp.json() as Promise<CfApiResponse>
4570
}
4671

4772
function loadCfBundle(): string {
@@ -51,7 +76,7 @@ function loadCfBundle(): string {
5176

5277
export async function deployCf(config: CorsProxyConfig): Promise<DeployResult> {
5378
const { apiToken } = getCfAuth()
54-
const accountId = getCfAccountId(config)
79+
const accountId = await resolveAccountId(apiToken, config)
5580
const workerName = getWorkerName(config)
5681

5782
// Load pre-built worker bundle
@@ -147,7 +172,7 @@ export async function deployCf(config: CorsProxyConfig): Promise<DeployResult> {
147172

148173
export async function destroyCf(config: CorsProxyConfig): Promise<void> {
149174
const { apiToken } = getCfAuth()
150-
const accountId = getCfAccountId(config)
175+
const accountId = await resolveAccountId(apiToken, config)
151176
const workerName = getWorkerName(config)
152177

153178
console.log(`Deleting Cloudflare Worker: ${workerName}`)

src/tags.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,11 +104,11 @@ async function listLambdaProxies(regions: string[]): Promise<ProxyInfo[]> {
104104

105105
async function listCfProxies(): Promise<ProxyInfo[]> {
106106
const apiToken = process.env.CLOUDFLARE_API_TOKEN
107-
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID
108-
if (!apiToken || !accountId) return []
107+
if (!apiToken) return []
109108

110109
try {
111-
const { listCfWorkers } = await import("./deploy-cf.js")
110+
const { resolveAccountId, listCfWorkers } = await import("./deploy-cf.js")
111+
const accountId = await resolveAccountId(apiToken)
112112
const workers = await listCfWorkers(accountId, apiToken)
113113
return workers.map(w => ({
114114
name: w.name,

0 commit comments

Comments
 (0)