@liiift-studio/deploy-vercel-from-sanity 1.2.1 → 1.3.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/README.md +184 -37
- package/dist/index.d.mts +54 -4
- package/dist/index.d.ts +54 -4
- package/dist/index.js +527 -394
- package/dist/index.mjs +352 -219
- package/package.json +9 -4
- package/proxy/.env.example +40 -0
- package/proxy/README.md +228 -0
- package/proxy/core.ts +283 -0
- package/proxy/nextjs-app-router/route.ts +129 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liiift-studio/deploy-vercel-from-sanity",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
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",
|
|
@@ -35,12 +35,16 @@
|
|
|
35
35
|
"./package.json": "./package.json"
|
|
36
36
|
},
|
|
37
37
|
"files": [
|
|
38
|
-
"dist"
|
|
38
|
+
"dist",
|
|
39
|
+
"proxy"
|
|
39
40
|
],
|
|
40
41
|
"scripts": {
|
|
41
42
|
"build": "tsup",
|
|
42
43
|
"dev": "tsup --watch",
|
|
43
|
-
"prepublishOnly": "npm run build"
|
|
44
|
+
"prepublishOnly": "npm run typecheck && npm test && npm run build",
|
|
45
|
+
"test": "vitest run",
|
|
46
|
+
"test:watch": "vitest",
|
|
47
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
44
48
|
},
|
|
45
49
|
"peerDependencies": {
|
|
46
50
|
"@sanity/icons": ">=2 <6",
|
|
@@ -55,10 +59,11 @@
|
|
|
55
59
|
"@types/react": "^19",
|
|
56
60
|
"@types/react-dom": "^19.2.4",
|
|
57
61
|
"react": "^19",
|
|
62
|
+
"react-dom": "^19",
|
|
58
63
|
"sanity": "^6",
|
|
59
64
|
"tsup": "^8",
|
|
60
65
|
"typescript": "^5",
|
|
61
|
-
"
|
|
66
|
+
"vitest": "^3.2.7"
|
|
62
67
|
},
|
|
63
68
|
"repository": {
|
|
64
69
|
"type": "git",
|
|
@@ -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=
|
package/proxy/README.md
ADDED
|
@@ -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
|
+
}
|