@liiift-studio/deploy-vercel-from-sanity 1.2.0 → 1.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liiift-studio/deploy-vercel-from-sanity",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Sanity Studio plugin — trigger and monitor Vercel deployments with full status, history, and build logs. Supports Studio v3.30 through v6.",
5
5
  "license": "MIT",
6
6
  "author": "Liiift Studio",
@@ -22,16 +22,21 @@
22
22
  "types": "./dist/index.d.ts",
23
23
  "exports": {
24
24
  ".": {
25
- "types": "./dist/index.d.ts",
26
- "import": "./dist/index.mjs",
27
- "require": "./dist/index.js",
25
+ "import": {
26
+ "types": "./dist/index.d.mts",
27
+ "default": "./dist/index.mjs"
28
+ },
29
+ "require": {
30
+ "types": "./dist/index.d.ts",
31
+ "default": "./dist/index.js"
32
+ },
28
33
  "default": "./dist/index.mjs"
29
34
  },
30
35
  "./package.json": "./package.json"
31
36
  },
32
37
  "files": [
33
38
  "dist",
34
- "src"
39
+ "proxy"
35
40
  ],
36
41
  "scripts": {
37
42
  "build": "tsup",
@@ -43,7 +48,7 @@
43
48
  "@sanity/ui": ">=2 <5",
44
49
  "react": ">=18",
45
50
  "react-dom": ">=18",
46
- "sanity": ">=3.30"
51
+ "sanity": ">=3.30 <7"
47
52
  },
48
53
  "devDependencies": {
49
54
  "@sanity/icons": "^5",
@@ -51,10 +56,10 @@
51
56
  "@types/react": "^19",
52
57
  "@types/react-dom": "^19.2.4",
53
58
  "react": "^19",
59
+ "react-dom": "^19",
54
60
  "sanity": "^6",
55
61
  "tsup": "^8",
56
- "typescript": "^5",
57
- "react-dom": "^19"
62
+ "typescript": "^5"
58
63
  },
59
64
  "repository": {
60
65
  "type": "git",
@@ -62,6 +67,6 @@
62
67
  },
63
68
  "sideEffects": true,
64
69
  "engines": {
65
- "node": ">=20.19"
70
+ "node": ">=20.19 <22 || >=22.12"
66
71
  }
67
72
  }
@@ -0,0 +1,40 @@
1
+ # Vercel API token. Scope it to the Team that owns these projects — not Full Account.
2
+ VERCEL_API_TOKEN=
3
+
4
+ # One hook per deploy target. The suffix after VERCEL_DEPLOY_HOOK_ is the proxyKey
5
+ # you set on the target document in Sanity (case-insensitive).
6
+ VERCEL_DEPLOY_HOOK_PRODUCTION=https://api.vercel.com/v1/integrations/deploy/prj_xxx/yyy
7
+ VERCEL_DEPLOY_HOOK_STAGING=https://api.vercel.com/v1/integrations/deploy/prj_xxx/zzz
8
+
9
+ # Project details for status lookups: projectId:hookId[:teamId]
10
+ # Both ids are the two path segments of the hook URL above.
11
+ VERCEL_DEPLOY_PROJECT_PRODUCTION=prj_xxx:yyy
12
+ VERCEL_DEPLOY_PROJECT_STAGING=prj_xxx:zzz
13
+
14
+ # Secret configured on the Sanity webhook. This is what makes the deploy endpoint
15
+ # safe to expose — without it, anyone could POST it and trigger a build.
16
+ SANITY_WEBHOOK_SECRET=
17
+
18
+ # Sent by the Studio with status, log and cancel requests. Must hold the SAME value
19
+ # as the plugin's `statusKey` option. Generate one with: openssl rand -hex 16
20
+ #
21
+ # It ships inside the Studio bundle, so treat it as public. It permits reading
22
+ # deployment status and build logs for the targets configured above, and
23
+ # cancelling their in-progress deployments — cancel is a write, so this is not a
24
+ # read-only key. Requests are scoped to the configured projects.
25
+ #
26
+ # Required. If unset, status requests fail closed with a 500.
27
+ VERCEL_DEPLOY_STATUS_KEY=
28
+
29
+ # Origins allowed to call the status endpoints, comma-separated. The Studio is
30
+ # cross-origin and sends a custom header, so the browser preflights every call and
31
+ # blocks it without this.
32
+ VERCEL_DEPLOY_ALLOWED_ORIGINS=https://your-project.sanity.studio,http://localhost:3333
33
+
34
+ # Optional. Restrict deploys further than Sanity's write ACL already does.
35
+ # Without this, anyone who can create a document can deploy — which excludes
36
+ # viewers, since they cannot write. Set it to require specific roles.
37
+ # VERCEL_DEPLOY_ALLOWED_ROLES=administrator,editor
38
+ # SANITY_PROJECT_ID=
39
+ # SANITY_DATASET=production
40
+ # SANITY_READ_TOKEN=
@@ -0,0 +1,228 @@
1
+ # Deploy proxy
2
+
3
+ Optional. Use this if you need **editors and above to trigger deploys, without any
4
+ credential being stealable from the dataset**.
5
+
6
+ ## Why it exists
7
+
8
+ In the default (`direct`) mode the Studio talks to Vercel itself. That means two
9
+ things live in the dataset:
10
+
11
+ - the **Vercel API token**, in a `config.vercelDeploy` document
12
+ - a **deploy hook URL** on every target
13
+
14
+ Sanity has no per-document access control, so both are readable by everyone who
15
+ can read the dataset — and if the dataset is public, by anyone at all. The hook
16
+ URL matters as much as the token: it *is* the deploy credential, so restricting
17
+ the Deploy tool to editors does nothing while a viewer can read the URL and
18
+ `curl` it.
19
+
20
+ Proxy mode moves both to a server you control. The Studio holds no credential.
21
+
22
+ ## How it works
23
+
24
+ ```
25
+ Deploy Studio creates a vercelDeploy.request document
26
+ └─ Sanity's write ACL authorises this. Viewers cannot write, so they cannot deploy.
27
+ └─ Sanity webhook (signed) ──▶ proxy ──▶ POSTs the hook URL from its own env
28
+
29
+ Status Studio ──▶ proxy /deployments|/events|/cancel ──▶ Vercel API with the server-held token
30
+ ```
31
+
32
+ The only value that reaches the browser is a **status key**. It permits reading
33
+ deployment status and build logs for the configured **projects**, and cancelling
34
+ their in-progress deployments — cancel is a write, so this is not a read-only key.
35
+ It ships inside the Studio bundle, so treat it as public.
36
+
37
+ Read [What this does not solve](#what-this-does-not-solve) before deciding this is
38
+ enough for your threat model.
39
+
40
+ ## Setup
41
+
42
+ Roughly 15 minutes.
43
+
44
+ ### 1. Add the route
45
+
46
+ Copy `core.ts` and `nextjs-app-router/route.ts` into your Next.js app:
47
+
48
+ ```
49
+ app/api/vercel-deploy/[...path]/route.ts ← nextjs-app-router/route.ts
50
+ app/api/vercel-deploy/core.ts ← core.ts
51
+ ```
52
+
53
+ `route.ts` imports `parseBody` from `next-sanity`, so install it if you have not:
54
+
55
+ ```sh
56
+ npm i next-sanity
57
+ ```
58
+
59
+ Not on Next.js? `core.ts` has no framework dependency. Wrap it in your own handler
60
+ and verify the Sanity webhook signature before calling `handleDeployRequest` —
61
+ that verification is the only thing standing between the endpoint and an
62
+ unauthenticated deploy trigger.
63
+
64
+ ### 2. Set environment variables
65
+
66
+ See `.env.example`. The two per-target ones:
67
+
68
+ ```sh
69
+ VERCEL_DEPLOY_HOOK_PRODUCTION=https://api.vercel.com/v1/integrations/deploy/prj_xxx/yyy
70
+ VERCEL_DEPLOY_PROJECT_PRODUCTION=prj_xxx:yyy
71
+ ```
72
+
73
+ `prj_xxx` and `yyy` are the last two path segments of the hook URL. The suffix
74
+ after the prefix (`PRODUCTION`) is the **proxy key** you put on the target
75
+ document; it is matched case-insensitively. Environment variable names cannot
76
+ contain hyphens, so keys must be `[A-Za-z0-9_]` — use `my_site`, not `my-site`.
77
+
78
+ For a team-owned project, append the team id — `projectId:hookId:teamId`. **The
79
+ Team ID field on the target document is ignored in proxy mode**; the proxy reads
80
+ it from here, because the Studio never talks to Vercel.
81
+
82
+ Scope `VERCEL_API_TOKEN` to the **Team** that owns the projects, not Full Account.
83
+
84
+ `VERCEL_DEPLOY_STATUS_KEY` and the plugin's `statusKey` must hold the **same
85
+ value**. Generate one with `openssl rand -hex 16`. The proxy fails closed — if the
86
+ server-side variable is unset, status requests return 500 rather than serving
87
+ everyone.
88
+
89
+ Set `VERCEL_DEPLOY_ALLOWED_ORIGINS` to your Studio's origin(s), comma-separated —
90
+ for example `https://acme.sanity.studio,http://localhost:3333`. The Studio is
91
+ cross-origin and sends a custom header, so without this the browser blocks every
92
+ status call at the preflight.
93
+
94
+ ### 3. Add the Sanity webhook
95
+
96
+ Sanity → Project → API → Webhooks → Create:
97
+
98
+ Use a **GROQ-powered webhook**, not a legacy one.
99
+
100
+ | Field | Value |
101
+ | --- | --- |
102
+ | URL | `https://your-site.com/api/vercel-deploy/deploy` |
103
+ | Dataset | your dataset |
104
+ | Trigger on | Create |
105
+ | Filter | `_type == "vercelDeploy.request"` |
106
+ | Projection | *leave empty* — the proxy needs both `proxyKey` and `_createdBy` |
107
+ | HTTP method | POST |
108
+ | Secret | same value as `SANITY_WEBHOOK_SECRET` |
109
+
110
+ The secret must match byte for byte; `parseBody` verifies the
111
+ `sanity-webhook-signature` header against it and rejects anything else.
112
+
113
+ ### 4. Point the plugin at it
114
+
115
+ `SANITY_STUDIO_DEPLOY_STATUS_KEY` goes in the **Studio's** `.env`, not the proxy's.
116
+ Sanity only inlines variables prefixed `SANITY_STUDIO_`, and it inlines them at
117
+ **build time** — so it must be present wherever you run `sanity build` / `sanity
118
+ deploy`, and changing it needs a rebuild. If it is missing the header is omitted
119
+ entirely and the proxy answers 401, which reads like a mismatch rather than an
120
+ absence.
121
+
122
+ ```ts
123
+ vercelDeploy({
124
+ mode: 'proxy',
125
+ proxyUrl: 'https://your-site.com/api/vercel-deploy',
126
+ statusKey: process.env.SANITY_STUDIO_DEPLOY_STATUS_KEY,
127
+ })
128
+ ```
129
+
130
+ ### 5. Set a proxy key on each target
131
+
132
+ In the Deploy tool, each target now asks for a **Proxy Key** instead of a hook
133
+ URL. Use the same suffix as the environment variables — `production`, `staging`.
134
+
135
+ ## Verify it worked
136
+
137
+ Status path — expect `{"deployments":[…]}`. `{"error":"Unknown target key"}` is
138
+ also JSON, so read the body, not just the status:
139
+
140
+ ```sh
141
+ curl -s -H "x-deploy-status-key: $VERCEL_DEPLOY_STATUS_KEY" \
142
+ "https://your-site.com/api/vercel-deploy/deployments?key=production"
143
+ ```
144
+
145
+ CORS — curl does not implement CORS, so the call above passes even with
146
+ `VERCEL_DEPLOY_ALLOWED_ORIGINS` unset. Check the preflight explicitly and look for
147
+ `Access-Control-Allow-Origin` in the response headers:
148
+
149
+ ```sh
150
+ curl -si -X OPTIONS \
151
+ -H "Origin: https://your-project.sanity.studio" \
152
+ -H "Access-Control-Request-Method: GET" \
153
+ -H "Access-Control-Request-Headers: x-deploy-status-key" \
154
+ "https://your-site.com/api/vercel-deploy/deployments" | head -20
155
+ ```
156
+
157
+ Deploy path — click **Deploy** in the Studio, then check, in order:
158
+
159
+ 1. **Sanity → API → Webhooks → your webhook → delivery log.** This is where the
160
+ proxy's response lands. The Studio never sees it, so a 400/403/404/502 here is
161
+ invisible in the UI and this log is the only place it appears.
162
+ 2. A new `vercelDeploy.request` document exists. If not, the click failed
163
+ client-side — check the browser console.
164
+ 3. A new deployment in Vercel.
165
+
166
+ ## When the webhook fires but nothing deploys
167
+
168
+ | Symptom in the delivery log | Cause |
169
+ | --- | --- |
170
+ | `400 Request document has no proxyKey` | The target has no Proxy Key set |
171
+ | `404 No hook configured for key "…"` | Key does not match any `VERCEL_DEPLOY_HOOK_*` variable |
172
+ | `403 Requesting user is not permitted` | `VERCEL_DEPLOY_ALLOWED_ROLES` excludes the requester |
173
+ | `502 Deploy hook returned …` | Vercel rejected the hook — check it still exists |
174
+ | `401 Invalid signature` | `SANITY_WEBHOOK_SECRET` does not match the webhook's secret |
175
+ | `500 Missing SANITY_WEBHOOK_SECRET` | The variable is not set on the proxy |
176
+ | `403 Request document has no creator` | `_createdBy` was absent — the webhook has a projection set; leave it empty |
177
+ | An opaque 500 with no body | `VERCEL_DEPLOY_ALLOWED_ROLES` is set without `SANITY_PROJECT_ID` / `SANITY_READ_TOKEN` |
178
+ | An SSO/login page instead of JSON | **Vercel Deployment Protection** is on for the app hosting the proxy. Use Protection Bypass for Automation, or add the route to the **OPTIONS Allowlist**. This breaks the *status* path too — a preflight from the Studio gets the SSO challenge, which surfaces as a blocked CORS request rather than an auth error. |
179
+
180
+ Status shows nothing while deploys work? That is the CORS or status-key path, not
181
+ the webhook — check the browser console for a blocked preflight, and confirm both
182
+ sides hold the same status key.
183
+
184
+ ## Who can deploy
185
+
186
+ By default: anyone Sanity lets create a document. That already excludes viewers,
187
+ which is usually what "editors and above" means in practice.
188
+
189
+ To require specific roles, set `VERCEL_DEPLOY_ALLOWED_ROLES` plus the `SANITY_*`
190
+ variables. The proxy then looks up the roles of the user in `_createdBy` — a field
191
+ Sanity stamps and the client cannot forge — and rejects anything not on the list.
192
+ Note that `contributor` can also write, so include it explicitly or exclude it
193
+ deliberately.
194
+
195
+ ## What this does not solve
196
+
197
+ **The status key is effectively public, and it can do more than read.** A hosted
198
+ Studio bundle is fetchable without logging in, and the key is compiled into it. So
199
+ in the worst case anyone on the internet can: list your deployments (which yields
200
+ deployment ids), read their build logs, and **cancel an in-progress production
201
+ build**. `VERCEL_DEPLOY_ALLOWED_ORIGINS` does not prevent this — CORS constrains
202
+ browsers, not `curl`.
203
+
204
+ **Build logs can contain secrets.** A failed build that dumps `process.env`, a
205
+ registry 401 echoing a token, a stack trace with a connection string — all of that
206
+ is returned to a status-key holder. Do not treat log exposure as harmless.
207
+
208
+ If that is unacceptable, do not rely on the status key alone: put the proxy behind
209
+ network controls, or drop `statusKey` usage and accept losing status while keeping
210
+ the deploy path, which is properly gated by the signed webhook and Sanity's write
211
+ ACL.
212
+
213
+ **Scoping is per project, not per target.** `assertDeploymentInProject` checks the
214
+ deployment's `projectId`. A key holder can therefore read logs for, and cancel, any
215
+ deployment in a configured project — including git-push deployments the plugin's UI
216
+ never lists — but nothing outside those projects.
217
+
218
+ **Role gating is proxy-wide.** `VERCEL_DEPLOY_ALLOWED_ROLES` is one list for all
219
+ targets; there is no "contributors may deploy staging but not production."
220
+
221
+ **Request documents accumulate** unless you prune them. The proxy does not delete
222
+ them, so they double as an audit trail of who deployed what and when. Their type is
223
+ not registered in the schema, so they appear in the Studio as "Unknown document
224
+ type" if you browse to one — that is expected, not a bug.
225
+
226
+ **`assertDeploymentInProject` assumes** `GET /v13/deployments/{id}` returns a
227
+ top-level `projectId`. If Vercel ever changes that, the proxy returns a loud 502
228
+ rather than silently allowing or denying.
package/proxy/core.ts ADDED
@@ -0,0 +1,283 @@
1
+ // Framework-agnostic deploy proxy — holds the Vercel token and hook URLs so the Studio never does
2
+ //
3
+ // Copy this file, and one of the runtime wrappers next to it, into your own
4
+ // project. It has no dependency on the Sanity plugin.
5
+
6
+ /** Environment the proxy needs. Everything here is a server-side secret except TARGETS' keys. */
7
+ export interface ProxyEnv {
8
+ /** Vercel API token. Scope it to the Team that owns the projects, not Full Account. */
9
+ vercelToken: string
10
+ /**
11
+ * Map of proxy key to Vercel deploy hook URL, e.g.
12
+ * `{ production: 'https://api.vercel.com/v1/integrations/deploy/prj_.../...' }`.
13
+ * These are deploy credentials — they belong here, not in the dataset.
14
+ */
15
+ hooks: Record<string, string>
16
+ /**
17
+ * Map of proxy key to the project the hook belongs to, used for status lookups:
18
+ * `{ production: { projectId: 'prj_…', hookId: '…', teamId: 'team_…' } }`.
19
+ * projectId and hookId are the last two path segments of the deploy hook URL.
20
+ */
21
+ projects: Record<string, { projectId: string; hookId: string; teamId?: string }>
22
+ /**
23
+ * Key the Studio sends with status, log and cancel requests.
24
+ *
25
+ * Present in the Studio bundle and therefore public. It permits reading
26
+ * deployment status and build logs for the configured targets, and cancelling
27
+ * their in-progress deployments — cancel is a write, so this is not a read-only
28
+ * key. Requests are scoped to the configured projects. Required.
29
+ */
30
+ statusKey?: string
31
+ /**
32
+ * Sanity roles permitted to deploy, checked against the requesting user.
33
+ * Omit to accept anyone Sanity's write ACL already let create the request —
34
+ * which excludes viewers, since they cannot write at all.
35
+ */
36
+ allowedRoles?: string[]
37
+ /** Sanity project id, dataset and a read token — only needed when `allowedRoles` is set. */
38
+ sanity?: { projectId: string; dataset: string; readToken: string }
39
+ }
40
+
41
+ /** Minimal shape of the webhook payload the Studio's request document produces. */
42
+ export interface DeployRequestPayload {
43
+ _id?: string
44
+ _type?: string
45
+ proxyKey?: string
46
+ /** Sanity stamps this; the client cannot forge it. */
47
+ _createdBy?: string
48
+ }
49
+
50
+ /** Result of handling a request, ready to be turned into a runtime response. */
51
+ export interface ProxyResult {
52
+ status: number
53
+ body: unknown
54
+ }
55
+
56
+ const VERCEL_API = 'https://api.vercel.com'
57
+
58
+ /** Only genuine Vercel deploy hooks are ever POSTed to. */
59
+ const VERCEL_HOOK_RE = /^https:\/\/api\.vercel\.com\/v1\/integrations\/deploy\//
60
+
61
+ /** Call the Vercel API with the server-held token. */
62
+ async function vercel<T>(path: string, env: ProxyEnv, init?: RequestInit): Promise<T> {
63
+ const res = await fetch(`${VERCEL_API}${path}`, {
64
+ ...init,
65
+ headers: {
66
+ Authorization: `Bearer ${env.vercelToken}`,
67
+ 'Content-Type': 'application/json',
68
+ ...init?.headers,
69
+ },
70
+ })
71
+ if (!res.ok) throw new Error(`Vercel API ${res.status}`)
72
+ return res.json() as Promise<T>
73
+ }
74
+
75
+ /**
76
+ * Look up the Sanity roles of the user who created the request document.
77
+ *
78
+ * Only used when `allowedRoles` is set. Without it, authorisation is already
79
+ * handled by Sanity's write ACL — a viewer cannot create the document at all.
80
+ */
81
+ async function userRoles(userId: string, env: ProxyEnv): Promise<string[]> {
82
+ if (!env.sanity) throw new Error('allowedRoles is set but sanity config is missing')
83
+ const { projectId, readToken } = env.sanity
84
+ const res = await fetch(`https://api.sanity.io/v2021-06-07/projects/${projectId}/acl`, {
85
+ headers: { Authorization: `Bearer ${readToken}` },
86
+ })
87
+ if (!res.ok) throw new Error(`Sanity ACL lookup failed: ${res.status}`)
88
+ const acl = (await res.json()) as Array<{ userId: string; roles?: Array<{ name: string }> }>
89
+ const entry = acl.find(a => a.userId === userId)
90
+ return entry?.roles?.map(r => r.name) ?? []
91
+ }
92
+
93
+ /**
94
+ * Handle a verified deploy-request webhook.
95
+ *
96
+ * The caller MUST have verified the Sanity webhook signature before calling this
97
+ * — see the runtime wrappers. An unverified payload is an unauthenticated deploy
98
+ * trigger.
99
+ *
100
+ * @param payload Parsed webhook body for the created request document.
101
+ * @param env Proxy environment.
102
+ */
103
+ export async function handleDeployRequest(payload: DeployRequestPayload, env: ProxyEnv): Promise<ProxyResult> {
104
+ // Lowercased to match envFromProcess, which lowercases the env-var side. Without
105
+ // this, a target typed as "Production" would 404 against a PRODUCTION env var.
106
+ const key = payload.proxyKey?.trim().toLowerCase()
107
+ if (!key) return { status: 400, body: { error: 'Request document has no proxyKey' } }
108
+
109
+ const hook = env.hooks[key]
110
+ if (!hook) return { status: 404, body: { error: `No hook configured for key "${key}"` } }
111
+ if (!VERCEL_HOOK_RE.test(hook)) return { status: 500, body: { error: `Hook for "${key}" is not a Vercel deploy hook` } }
112
+
113
+ if (env.allowedRoles?.length) {
114
+ const userId = payload._createdBy
115
+ if (!userId) return { status: 403, body: { error: 'Request document has no creator' } }
116
+ const roles = await userRoles(userId, env)
117
+ if (!roles.some(r => env.allowedRoles!.includes(r))) {
118
+ return { status: 403, body: { error: 'Requesting user is not permitted to deploy' } }
119
+ }
120
+ }
121
+
122
+ const res = await fetch(hook, { method: 'POST' })
123
+ if (!res.ok) return { status: 502, body: { error: `Deploy hook returned ${res.status}` } }
124
+ return { status: 200, body: { ok: true, key } }
125
+ }
126
+
127
+ /**
128
+ * Reject a request whose status key does not match.
129
+ *
130
+ * Fails closed: an unset key rejects everything rather than serving everyone. The
131
+ * previous behaviour left cancellation open to unauthenticated callers, since
132
+ * cancel is gated by this same check.
133
+ */
134
+ function checkStatusKey(provided: string | null, env: ProxyEnv): ProxyResult | null {
135
+ if (!env.statusKey) {
136
+ return { status: 500, body: { error: 'VERCEL_DEPLOY_STATUS_KEY is not configured' } }
137
+ }
138
+ return provided === env.statusKey ? null : { status: 401, body: { error: 'Invalid status key' } }
139
+ }
140
+
141
+ /**
142
+ * Confirm a deployment actually belongs to the project behind `key`.
143
+ *
144
+ * Without this, `deploymentId` is caller-supplied and the project lookup only
145
+ * supplies `teamId` — so a team-scoped token would let any holder of the (public)
146
+ * status key read build logs for, or cancel, any deployment in the whole team.
147
+ *
148
+ * @param deploymentId Deployment the caller is asking about.
149
+ * @param project Project configured for the requested proxy key.
150
+ */
151
+ async function assertDeploymentInProject(
152
+ deploymentId: string,
153
+ project: { projectId: string; teamId?: string },
154
+ env: ProxyEnv,
155
+ ): Promise<ProxyResult | null> {
156
+ const query = new URLSearchParams()
157
+ if (project.teamId) query.set('teamId', project.teamId)
158
+ const suffix = query.toString() ? `?${query}` : ''
159
+ let deployment: { projectId?: string }
160
+ try {
161
+ deployment = await vercel<{ projectId?: string }>(
162
+ `/v13/deployments/${encodeURIComponent(deploymentId)}${suffix}`,
163
+ env,
164
+ )
165
+ } catch (err) {
166
+ // Distinguish an unknown deployment from a broken token or a rate limit —
167
+ // collapsing all three into 404 makes a revoked token look like a UI bug.
168
+ const status = Number(String(err instanceof Error ? err.message : '').match(/\b(\d{3})\b/)?.[1])
169
+ if (status === 401 || status === 403) {
170
+ return { status: 502, body: { error: 'Vercel rejected the proxy token — check VERCEL_API_TOKEN' } }
171
+ }
172
+ if (status === 429) return { status: 429, body: { error: 'Vercel rate limit reached' } }
173
+ return { status: 404, body: { error: 'Deployment not found' } }
174
+ }
175
+ if (!deployment.projectId) {
176
+ // Loud rather than silently denying forever if the API shape ever changes.
177
+ return { status: 502, body: { error: 'Vercel returned no projectId; cannot verify deployment ownership' } }
178
+ }
179
+ if (deployment.projectId !== project.projectId) {
180
+ return { status: 403, body: { error: 'Deployment does not belong to this target' } }
181
+ }
182
+ return null
183
+ }
184
+
185
+ /** List recent deployments for a proxy key. */
186
+ export async function handleDeployments(
187
+ params: { key: string | null; limit?: number; statusKey: string | null },
188
+ env: ProxyEnv,
189
+ ): Promise<ProxyResult> {
190
+ const denied = checkStatusKey(params.statusKey, env)
191
+ if (denied) return denied
192
+ const project = params.key ? env.projects[params.key.trim().toLowerCase()] : undefined
193
+ if (!project) return { status: 404, body: { error: 'Unknown target key' } }
194
+
195
+ const query = new URLSearchParams({
196
+ projectId: project.projectId,
197
+ 'meta-deployHookId': project.hookId,
198
+ limit: String(params.limit ?? 10),
199
+ })
200
+ if (project.teamId) query.set('teamId', project.teamId)
201
+ const data = await vercel<{ deployments: unknown[] }>(`/v6/deployments?${query}`, env)
202
+ return { status: 200, body: { deployments: data.deployments ?? [] } }
203
+ }
204
+
205
+ /** Fetch build log events for a deployment. */
206
+ export async function handleEvents(
207
+ params: { key: string | null; deploymentId: string | null; statusKey: string | null },
208
+ env: ProxyEnv,
209
+ ): Promise<ProxyResult> {
210
+ const denied = checkStatusKey(params.statusKey, env)
211
+ if (denied) return denied
212
+ const project = params.key ? env.projects[params.key.trim().toLowerCase()] : undefined
213
+ if (!project) return { status: 404, body: { error: 'Unknown target key' } }
214
+ if (!params.deploymentId) return { status: 400, body: { error: 'Missing deploymentId' } }
215
+ const wrongProject = await assertDeploymentInProject(params.deploymentId, project, env)
216
+ if (wrongProject) return wrongProject
217
+
218
+ // direction=backward returns the *newest* events. Without it Vercel returns the
219
+ // first 100, so a failed build showed the top of its log and never the error.
220
+ const query = new URLSearchParams({ limit: '100', direction: 'backward' })
221
+ if (project.teamId) query.set('teamId', project.teamId)
222
+ const raw = await vercel<unknown>(
223
+ `/v2/deployments/${encodeURIComponent(params.deploymentId)}/events?${query}`,
224
+ env,
225
+ )
226
+ // Vercel has returned both a bare array and a wrapped object here; tolerate both.
227
+ const events = Array.isArray(raw)
228
+ ? raw
229
+ : Array.isArray((raw as { events?: unknown[] })?.events)
230
+ ? (raw as { events: unknown[] }).events
231
+ : []
232
+ return { status: 200, body: { events } }
233
+ }
234
+
235
+ /** Cancel an in-progress deployment. */
236
+ export async function handleCancel(
237
+ params: { key?: string; deploymentId?: string; statusKey: string | null },
238
+ env: ProxyEnv,
239
+ ): Promise<ProxyResult> {
240
+ const denied = checkStatusKey(params.statusKey, env)
241
+ if (denied) return denied
242
+ const project = params.key ? env.projects[params.key.trim().toLowerCase()] : undefined
243
+ if (!project) return { status: 404, body: { error: 'Unknown target key' } }
244
+ if (!params.deploymentId) return { status: 400, body: { error: 'Missing deploymentId' } }
245
+ const wrongProject = await assertDeploymentInProject(params.deploymentId, project, env)
246
+ if (wrongProject) return wrongProject
247
+
248
+ const query = new URLSearchParams()
249
+ if (project.teamId) query.set('teamId', project.teamId)
250
+ const suffix = query.toString() ? `?${query}` : ''
251
+ await vercel(`/v12/deployments/${encodeURIComponent(params.deploymentId)}/cancel${suffix}`, env, {
252
+ method: 'PATCH',
253
+ })
254
+ return { status: 200, body: { ok: true } }
255
+ }
256
+
257
+ /** Build a {@link ProxyEnv} from process.env, using the documented variable names. */
258
+ export function envFromProcess(e: Record<string, string | undefined>): ProxyEnv {
259
+ const hooks: ProxyEnv['hooks'] = {}
260
+ const projects: ProxyEnv['projects'] = {}
261
+ // VERCEL_DEPLOY_HOOK_<KEY> and VERCEL_DEPLOY_PROJECT_<KEY> pairs define the targets.
262
+ for (const [name, value] of Object.entries(e)) {
263
+ if (!value) continue
264
+ const hookMatch = name.match(/^VERCEL_DEPLOY_HOOK_(.+)$/)
265
+ if (hookMatch) hooks[hookMatch[1].toLowerCase()] = value
266
+ const projMatch = name.match(/^VERCEL_DEPLOY_PROJECT_(.+)$/)
267
+ if (projMatch) {
268
+ const [projectId, hookId, teamId] = value.split(':')
269
+ projects[projMatch[1].toLowerCase()] = { projectId, hookId, teamId: teamId || undefined }
270
+ }
271
+ }
272
+ const allowedRoles = e.VERCEL_DEPLOY_ALLOWED_ROLES?.split(',').map(r => r.trim()).filter(Boolean)
273
+ return {
274
+ vercelToken: e.VERCEL_API_TOKEN ?? '',
275
+ hooks,
276
+ projects,
277
+ statusKey: e.VERCEL_DEPLOY_STATUS_KEY,
278
+ allowedRoles: allowedRoles?.length ? allowedRoles : undefined,
279
+ sanity: e.SANITY_PROJECT_ID && e.SANITY_READ_TOKEN
280
+ ? { projectId: e.SANITY_PROJECT_ID, dataset: e.SANITY_DATASET ?? 'production', readToken: e.SANITY_READ_TOKEN }
281
+ : undefined,
282
+ }
283
+ }