@liiift-studio/deploy-vercel-from-sanity 1.4.0 → 1.5.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/README.md CHANGED
@@ -291,12 +291,72 @@ HEAD, which is the same commit with the same author, so it is blocked
291
291
  identically. The only way out is a new commit by an authorised author.
292
292
 
293
293
  A Studio running in a browser holds no git credential, so it cannot make that
294
- commit itself. With `unblock` configured it dispatches a GitHub Actions workflow
295
- that does.
294
+ commit itself. `unblock` gives it two ways to borrow one.
296
295
 
297
- ### The credential split
296
+ ### Choose a mode
298
297
 
299
- This is the part worth understanding before enabling it.
298
+ | | `endpoint` **preferred** | `token` workflow dispatch |
299
+ |---|---|---|
300
+ | Needs a server | yes, one API route | no |
301
+ | GitHub credential in the bundle | **none** | one, `Actions: write` |
302
+ | Tokens to maintain | **1** | 2 |
303
+ | Workflow file on the default branch | not needed | required, or dispatch 404s |
304
+
305
+ Use `endpoint` if you have anywhere to put an API route. Its whole advantage is
306
+ that nothing sensitive reaches the browser, so there is only one credential and
307
+ it is a server secret like any other.
308
+
309
+ ---
310
+
311
+ ## `endpoint` mode
312
+
313
+ ```ts
314
+ vercelDeploy({
315
+ unblock: {
316
+ endpoint: 'https://example.com/api/deploy-unblock',
317
+ },
318
+ })
319
+ ```
320
+
321
+ That is the entire Studio-side configuration — note the absence of a token.
322
+
323
+ The Studio posts `{ ref, requestedBy }` to that URL with the signed-in user's
324
+ **Sanity session token** in an `Authorization: Bearer` header. Your route verifies
325
+ that token against Sanity, then makes the commit with its own server-held GitHub
326
+ token.
327
+
328
+ Requirements for the route:
329
+
330
+ - **Verify the session token.** Do not accept a shared secret instead — the bundle
331
+ is public, so a shared secret only moves the bar from "know the URL" to "open
332
+ devtools". Verify against
333
+ `https://<projectId>.api.sanity.io/v2021-06-07/users/me`. Note Sanity answers
334
+ `200` with a null `id` for an unauthenticated request rather than `401`.
335
+ - **Allow the Studio's origin.** A deployed Studio is on `*.sanity.studio`, not
336
+ your site's domain, so every call is cross-origin and the preflight fails
337
+ without an allow-list. Echo one allow-listed origin; do not send `*` to an
338
+ endpoint that takes an `Authorization` header.
339
+ - **Restrict which branches it will bump.** The recovery flow only ever needs the
340
+ branches you deploy.
341
+ - **Answer `{ error }` on failure.** The plugin shows that string to the editor
342
+ verbatim, in preference to its own generic message.
343
+ - **Serve it over https.** The plugin refuses a plaintext endpoint, because the
344
+ session token travels with the request. `localhost` is exempt for development.
345
+
346
+ The token forwarded is `client.config().token`. Sanity only exposes it under
347
+ token-based auth, so it can be absent under cookie-based login; the plugin
348
+ detects that and says so, rather than sending an anonymous request and letting
349
+ your route report it as a permissions failure.
350
+
351
+ A worked Next.js Pages Router implementation is in
352
+ [`docs/deploy-unblock-route.js`](docs/deploy-unblock-route.js).
353
+
354
+ ---
355
+
356
+ ## `token` mode — workflow dispatch
357
+
358
+ For setups with no server. The Studio dispatches a GitHub Actions workflow, and
359
+ the workflow makes the commit.
300
360
 
301
361
  ```
302
362
  Studio bundle ── unblock.token Actions: write, one repo
@@ -312,8 +372,7 @@ Handing the Studio a `Contents: write` token would be far simpler and is the
312
372
  obvious first design. Do not: the Studio bundle is served publicly, so that token
313
373
  would let anyone who can load the Studio push arbitrary commits to the production
314
374
  repository — and the next build would run them. The dispatch token is scoped so
315
- that the worst a leak permits is *running the bump workflow*, which produces a
316
- version bump and a deploy.
375
+ that the worst a leak permits is *running the bump workflow*.
317
376
 
318
377
  ### What a leaked dispatch token can actually do
319
378
 
@@ -331,61 +390,60 @@ The shipped workflow therefore opens with a branch allowlist, and a `concurrency
331
390
  group that serialises bumps per branch. Narrow the allowlist to the branches you
332
391
  actually deploy. Rotate the token if it leaks.
333
392
 
334
- ### 1. Add the workflow
393
+ ### Setup
335
394
 
336
- Copy [`docs/version-bump.yml`](docs/version-bump.yml) to
337
- `.github/workflows/version-bump.yml` in the **site** repository, then set the git
338
- identity in it to the account whose commits Vercel accepts, and narrow the branch
339
- allowlist to the branches you deploy.
395
+ 1. Copy [`docs/version-bump.yml`](docs/version-bump.yml) to
396
+ `.github/workflows/version-bump.yml`, set the git identity in it to the account
397
+ whose commits Vercel accepts, and narrow the branch allowlist.
340
398
 
341
- > **The workflow file must exist on the repository's default branch**, and on
342
- > every branch you deploy from. GitHub resolves a dispatch against the default
343
- > branch's copy, then runs the copy on the requested ref. A file present only on
344
- > `staging` returns a 404 — which the plugin reports in full, since GitHub uses
345
- > the same 404 for "no such workflow" and "your token cannot see this repo".
399
+ > **The workflow file must exist on the repository's default branch**, and on
400
+ > every branch you deploy from. GitHub resolves a dispatch against the default
401
+ > branch's copy, then runs the copy on the requested ref. A file present only on
402
+ > `staging` returns a 404 — which the plugin reports in full, since GitHub uses
403
+ > the same 404 for "no such workflow" and "your token cannot see this repo".
346
404
 
347
- ### 2. Add the commit credential
405
+ 2. Create a fine-grained token with **`Contents: write`** on that one repository
406
+ and save it as the repository Actions secret **`DEPLOY_COMMIT_TOKEN`**.
348
407
 
349
- Create a fine-grained token on the account whose commits Vercel accepts, scoped
350
- to **`Contents: write`** on that one repository, and save it as the repository
351
- Actions secret **`DEPLOY_COMMIT_TOKEN`**.
408
+ It must not be the default `GITHUB_TOKEN`: commits made with it are authored by
409
+ `github-actions[bot]`, which is not a team member either, so Vercel would block
410
+ the bump for the same reason it blocked the original commit.
352
411
 
353
- It must not be the default `GITHUB_TOKEN`: commits made with it are authored by
354
- `github-actions[bot]`, which is not a team member either, so Vercel would block
355
- the bump for the same reason it blocked the original commit.
412
+ 3. Create a second fine-grained token scoped to **`Actions: write`** on the same
413
+ repository and nothing else and expose it to the Studio build:
356
414
 
357
- ### 3. Add the dispatch token
415
+ ```sh
416
+ SANITY_STUDIO_DEPLOY_UNBLOCK_GH_TOKEN=github_pat_…
417
+ ```
358
418
 
359
- Create a second fine-grained token scoped to **`Actions: write`** on the same
360
- repository — and nothing else — and expose it to the Studio build:
419
+ 4. Configure the plugin:
361
420
 
362
- ```sh
363
- SANITY_STUDIO_DEPLOY_UNBLOCK_GH_TOKEN=github_pat_…
364
- ```
421
+ ```ts
422
+ vercelDeploy({
423
+ unblock: {
424
+ token: process.env.SANITY_STUDIO_DEPLOY_UNBLOCK_GH_TOKEN,
425
+ owner: 'your-org',
426
+ repo: 'your-site-repo',
427
+ workflow: 'version-bump.yml', // optional, this is the default
428
+ },
429
+ })
430
+ ```
365
431
 
366
- ### 4. Configure the plugin
432
+ ---
367
433
 
368
- ```ts
369
- vercelDeploy({
370
- unblock: {
371
- token: process.env.SANITY_STUDIO_DEPLOY_UNBLOCK_GH_TOKEN,
372
- owner: 'your-org',
373
- repo: 'your-site-repo',
374
- workflow: 'version-bump.yml', // optional, this is the default
375
- },
376
- })
377
- ```
434
+ ## `unblock` reference
378
435
 
379
436
  | Field | Required | Description |
380
437
  |---|---|---|
381
- | `token` | yes | Fine-grained token, `Actions: write` on `repo` only. Ships in the Studio bundle — treat it as public. Unset hides the button. |
382
- | `owner` | yes | Repository owner, e.g. `your-org` |
383
- | `repo` | yes | Repository name |
438
+ | `endpoint` | one of | URL of your site route. Wins when both modes are configured. Must be https. |
439
+ | `token` | one of | Fine-grained token, `Actions: write` on `repo` only. Ships in the Studio bundle — treat it as public. |
440
+ | `owner` | dispatch only | Repository owner, e.g. `your-org` |
441
+ | `repo` | dispatch only | Repository name |
384
442
  | `workflow` | no | Workflow filename. Defaults to `version-bump.yml`. |
385
443
  | `defaultRef` | no | Branch to bump when the blocked deployment names none. Normally unnecessary — the branch is read from the deployment being recovered. |
386
444
 
387
- An `unblock` block missing `token`, `owner` or `repo` is discarded, so a
388
- half-finished configuration renders no button rather than one that only errors.
445
+ A config with neither `endpoint` nor all of `token`/`owner`/`repo` is discarded,
446
+ so a half-finished setup renders no button rather than one that only errors.
389
447
 
390
448
  ### What the editor sees
391
449
 
@@ -394,9 +452,8 @@ not a general "deploy harder" control, and it is deliberately not offered when
394
452
  the plugin has nothing to target.
395
453
 
396
454
  Pressing it reports that the bump was **requested**. That is the honest claim:
397
- GitHub has accepted the dispatch, but the workflow still has to run, commit and
398
- push before Vercel sees anything, so the new deployment turns up a minute or two
399
- later. Polling picks it up on its own.
455
+ the commit is made, but Vercel still has to notice the push and build it, so the
456
+ new deployment turns up shortly after. Polling picks it up on its own.
400
457
 
401
458
  ### The real fix
402
459
 
package/dist/index.d.mts CHANGED
@@ -84,37 +84,57 @@ type VercelDeployMode = 'direct' | 'proxy';
84
84
  * commit's git author is not a member of the Vercel team.
85
85
  *
86
86
  * The Studio cannot fix this itself — the remedy is a commit by an authorised
87
- * author, and a browser holds no git credential. So the button dispatches a
88
- * GitHub Actions workflow, and that workflow does the commit with a token held
89
- * in Actions secrets.
87
+ * author, and a browser holds no git credential. There are two ways to borrow
88
+ * one, and they are not equivalent:
90
89
  *
91
- * The split matters. {@link token} ships inside the Studio bundle and must be
92
- * treated as public; scope it to **Actions: write on the one repo** so the worst
93
- * a leak permits is running that workflow. The credential that can actually
94
- * write code stays in GitHub, where the browser never sees it.
90
+ * - **{@link endpoint} (preferred).** The Studio posts the signed-in user's
91
+ * Sanity session token to a route on your own site; the route verifies it and
92
+ * commits with its own server-held GitHub token. Nothing secret reaches the
93
+ * browser, there is one token to maintain, and there is no workflow file whose
94
+ * presence on the default branch you have to remember.
95
+ *
96
+ * - **{@link token}.** The Studio dispatches a GitHub Actions workflow directly.
97
+ * Needs no server, but puts a GitHub token in the bundle, which is public.
98
+ * Scope it to **Actions: write on the one repo** so the worst a leak permits is
99
+ * running that workflow, and never give it `Contents: write` — a public token
100
+ * that can push code is a public token that can run code on your next build.
101
+ *
102
+ * Set `endpoint` if you have anywhere to put a route. It wins when both are set.
95
103
  */
96
104
  interface UnblockConfig {
105
+ /**
106
+ * URL of a site API route that performs the bump server-side. **Preferred.**
107
+ *
108
+ * In this mode the Studio carries no GitHub credential at all. It posts the
109
+ * signed-in user's Sanity session token, the route verifies it against Sanity,
110
+ * and the route's own server-held GitHub token makes the commit. One token,
111
+ * never public, and no workflow file to keep on the default branch.
112
+ *
113
+ * Takes precedence over {@link token} when both are set.
114
+ */
115
+ endpoint?: string;
97
116
  /**
98
117
  * Fine-grained GitHub token, scoped to `Actions: write` on {@link repo} alone.
118
+ * Only used when {@link endpoint} is not set.
99
119
  *
100
120
  * Compiled into the Studio bundle, so anyone who can load the Studio can read
101
121
  * it and dispatch the workflow. Never give it `Contents: write` — that would
102
- * let a reader push arbitrary commits to the production repo.
103
- *
104
- * Leave unset to hide the button entirely.
122
+ * let a reader push arbitrary commits to the production repo. If you have a
123
+ * server to put a route on, prefer {@link endpoint} and avoid this entirely.
105
124
  */
106
125
  token?: string;
107
- /** Repository owner, e.g. `Liiift-Studio`. */
108
- owner: string;
109
- /** Repository name, e.g. `the-designers-foundry`. */
110
- repo: string;
126
+ /** Repository owner, e.g. `your-org`. Required for workflow-dispatch mode only. */
127
+ owner?: string;
128
+ /** Repository name. Required for workflow-dispatch mode only. */
129
+ repo?: string;
111
130
  /**
112
131
  * Workflow filename to dispatch. Defaults to `version-bump.yml`.
132
+ * Workflow-dispatch mode only.
113
133
  *
114
134
  * GitHub resolves a dispatch against the workflow file **on the repository's
115
135
  * default branch**, then runs the copy on the requested ref — so the file must
116
136
  * exist on the default branch as well as on every branch you deploy from, or
117
- * the dispatch returns 404.
137
+ * the dispatch returns 404. `endpoint` mode has no such constraint.
118
138
  */
119
139
  workflow?: string;
120
140
  /**
package/dist/index.d.ts CHANGED
@@ -84,37 +84,57 @@ type VercelDeployMode = 'direct' | 'proxy';
84
84
  * commit's git author is not a member of the Vercel team.
85
85
  *
86
86
  * The Studio cannot fix this itself — the remedy is a commit by an authorised
87
- * author, and a browser holds no git credential. So the button dispatches a
88
- * GitHub Actions workflow, and that workflow does the commit with a token held
89
- * in Actions secrets.
87
+ * author, and a browser holds no git credential. There are two ways to borrow
88
+ * one, and they are not equivalent:
90
89
  *
91
- * The split matters. {@link token} ships inside the Studio bundle and must be
92
- * treated as public; scope it to **Actions: write on the one repo** so the worst
93
- * a leak permits is running that workflow. The credential that can actually
94
- * write code stays in GitHub, where the browser never sees it.
90
+ * - **{@link endpoint} (preferred).** The Studio posts the signed-in user's
91
+ * Sanity session token to a route on your own site; the route verifies it and
92
+ * commits with its own server-held GitHub token. Nothing secret reaches the
93
+ * browser, there is one token to maintain, and there is no workflow file whose
94
+ * presence on the default branch you have to remember.
95
+ *
96
+ * - **{@link token}.** The Studio dispatches a GitHub Actions workflow directly.
97
+ * Needs no server, but puts a GitHub token in the bundle, which is public.
98
+ * Scope it to **Actions: write on the one repo** so the worst a leak permits is
99
+ * running that workflow, and never give it `Contents: write` — a public token
100
+ * that can push code is a public token that can run code on your next build.
101
+ *
102
+ * Set `endpoint` if you have anywhere to put a route. It wins when both are set.
95
103
  */
96
104
  interface UnblockConfig {
105
+ /**
106
+ * URL of a site API route that performs the bump server-side. **Preferred.**
107
+ *
108
+ * In this mode the Studio carries no GitHub credential at all. It posts the
109
+ * signed-in user's Sanity session token, the route verifies it against Sanity,
110
+ * and the route's own server-held GitHub token makes the commit. One token,
111
+ * never public, and no workflow file to keep on the default branch.
112
+ *
113
+ * Takes precedence over {@link token} when both are set.
114
+ */
115
+ endpoint?: string;
97
116
  /**
98
117
  * Fine-grained GitHub token, scoped to `Actions: write` on {@link repo} alone.
118
+ * Only used when {@link endpoint} is not set.
99
119
  *
100
120
  * Compiled into the Studio bundle, so anyone who can load the Studio can read
101
121
  * it and dispatch the workflow. Never give it `Contents: write` — that would
102
- * let a reader push arbitrary commits to the production repo.
103
- *
104
- * Leave unset to hide the button entirely.
122
+ * let a reader push arbitrary commits to the production repo. If you have a
123
+ * server to put a route on, prefer {@link endpoint} and avoid this entirely.
105
124
  */
106
125
  token?: string;
107
- /** Repository owner, e.g. `Liiift-Studio`. */
108
- owner: string;
109
- /** Repository name, e.g. `the-designers-foundry`. */
110
- repo: string;
126
+ /** Repository owner, e.g. `your-org`. Required for workflow-dispatch mode only. */
127
+ owner?: string;
128
+ /** Repository name. Required for workflow-dispatch mode only. */
129
+ repo?: string;
111
130
  /**
112
131
  * Workflow filename to dispatch. Defaults to `version-bump.yml`.
132
+ * Workflow-dispatch mode only.
113
133
  *
114
134
  * GitHub resolves a dispatch against the workflow file **on the repository's
115
135
  * default branch**, then runs the copy on the requested ref — so the file must
116
136
  * exist on the default branch as well as on every branch you deploy from, or
117
- * the dispatch returns 404.
137
+ * the dispatch returns 404. `endpoint` mode has no such constraint.
118
138
  */
119
139
  workflow?: string;
120
140
  /**
package/dist/index.js CHANGED
@@ -102,6 +102,11 @@ var import_react2 = require("react");
102
102
  var import_jsx_runtime2 = require("react/jsx-runtime");
103
103
  var DEFAULTS = { mode: "direct" };
104
104
  var ConfigContext = (0, import_react2.createContext)(DEFAULTS);
105
+ function isUsableUnblock(unblock) {
106
+ if (!unblock) return false;
107
+ if (unblock.endpoint) return true;
108
+ return Boolean(unblock.token && unblock.owner && unblock.repo);
109
+ }
105
110
  function resolveConfig(options) {
106
111
  const config = options ?? {};
107
112
  return {
@@ -109,9 +114,11 @@ function resolveConfig(options) {
109
114
  // Trailing slashes would double up when request paths are appended.
110
115
  proxyUrl: config.proxyUrl?.replace(/\/+$/, ""),
111
116
  statusKey: config.statusKey,
112
- // Dropped unless it can actually be used. A config missing the token, the owner
113
- // or the repo would otherwise render a button whose only outcome is an error.
114
- unblock: config.unblock?.token && config.unblock.owner && config.unblock.repo ? config.unblock : void 0
117
+ // Dropped unless it can actually be used, so an incomplete configuration renders
118
+ // no button rather than one whose only outcome is an error. Either mode will do:
119
+ // an `endpoint` needs nothing else, while workflow dispatch needs all three of
120
+ // token, owner and repo to build an authenticated request.
121
+ unblock: isUsableUnblock(config.unblock) ? config.unblock : void 0
115
122
  };
116
123
  }
117
124
  function ConfigProvider({ value, children }) {
@@ -760,6 +767,78 @@ function dispatchErrorMessage(status, workflow, ref) {
760
767
  }
761
768
  }
762
769
 
770
+ // src/lib/unblock.ts
771
+ function assertSafeEndpoint(endpoint) {
772
+ let parsed;
773
+ try {
774
+ parsed = new URL(endpoint);
775
+ } catch {
776
+ throw new Error(`The configured recovery endpoint is not a valid URL: "${endpoint}".`);
777
+ }
778
+ const isLocal = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
779
+ if (parsed.protocol !== "https:" && !isLocal) {
780
+ throw new Error("The recovery endpoint must be https \u2014 the Studio session token is sent with the request.");
781
+ }
782
+ return parsed;
783
+ }
784
+ async function postToEndpoint(opts) {
785
+ const { config, ref, requestedBy, studioToken } = opts;
786
+ const endpoint = assertSafeEndpoint(config.endpoint);
787
+ if (!studioToken) {
788
+ throw new Error(
789
+ "Your Studio session token is not available, so the site cannot verify who you are. This happens under cookie-based login \u2014 sign out and back in, or ask a developer to deploy manually."
790
+ );
791
+ }
792
+ let res;
793
+ try {
794
+ res = await fetch(endpoint.toString(), {
795
+ method: "POST",
796
+ headers: {
797
+ "Content-Type": "application/json",
798
+ Authorization: `Bearer ${studioToken}`
799
+ },
800
+ body: JSON.stringify({ ref, requestedBy })
801
+ });
802
+ } catch {
803
+ throw new Error(
804
+ `Could not reach ${endpoint.host}. Check the site is up and that the route allows requests from this Studio's origin.`
805
+ );
806
+ }
807
+ if (res.ok) return;
808
+ let detail = "";
809
+ try {
810
+ const body = await res.json();
811
+ if (typeof body?.error === "string") detail = body.error;
812
+ } catch {
813
+ }
814
+ throw new Error(detail || endpointErrorMessage(res.status, endpoint.host));
815
+ }
816
+ function endpointErrorMessage(status, host) {
817
+ switch (status) {
818
+ case 401:
819
+ return "The site did not accept your Studio session. Try signing out of the Studio and back in.";
820
+ case 403:
821
+ return "Your Studio account is not permitted to trigger a deploy recovery.";
822
+ case 404:
823
+ return `No recovery route at ${host}. It may not be deployed yet \u2014 check the endpoint URL.`;
824
+ case 429:
825
+ return "A bump was requested very recently. Wait a moment before trying again.";
826
+ default:
827
+ return status >= 500 ? "The site failed while making the bump commit. Check its function logs." : `The site refused the request (${status}).`;
828
+ }
829
+ }
830
+ async function requestUnblock(opts) {
831
+ if (opts.config.endpoint) return postToEndpoint(opts);
832
+ if (!opts.config.owner || !opts.config.repo) {
833
+ throw new Error("Deploy recovery is misconfigured \u2014 set either `endpoint`, or `owner` and `repo`.");
834
+ }
835
+ return dispatchVersionBump({
836
+ config: opts.config,
837
+ ref: opts.ref,
838
+ requestedBy: opts.requestedBy
839
+ });
840
+ }
841
+
763
842
  // src/components/DeployItem.tsx
764
843
  var import_sanity = require("sanity");
765
844
 
@@ -1158,10 +1237,16 @@ function DeployItem({ target, token, onDelete, onEdit }) {
1158
1237
  setBumping(true);
1159
1238
  setBumpResult(null);
1160
1239
  try {
1161
- await dispatchVersionBump({
1240
+ await requestUnblock({
1162
1241
  config: unblockConfig,
1163
1242
  ref,
1164
- requestedBy: currentUser?.name || currentUser?.email || void 0
1243
+ requestedBy: currentUser?.name || currentUser?.email || void 0,
1244
+ // Forwarded so a site endpoint can verify the caller is a signed-in project
1245
+ // user. Sanity only exposes this under token-based auth, so it can be
1246
+ // absent; `requestUnblock` reports that as its own case rather than
1247
+ // letting the server see an anonymous request and call it a permissions
1248
+ // failure. Unused by workflow-dispatch mode.
1249
+ studioToken: client.config().token
1165
1250
  });
1166
1251
  setBumpResult({
1167
1252
  ok: true,
@@ -1179,7 +1264,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
1179
1264
  } finally {
1180
1265
  setBumping(false);
1181
1266
  }
1182
- }, [pluginConfig.unblock, latest?.meta?.githubCommitRef, currentUser, target.name, toast]);
1267
+ }, [pluginConfig.unblock, latest?.meta?.githubCommitRef, currentUser, target.name, toast, client]);
1183
1268
  const branch = latest?.meta?.githubCommitRef;
1184
1269
  const commitMsg = latest?.meta?.githubCommitMessage?.split("\n")[0];
1185
1270
  const sha = shortSha(latest?.meta?.githubCommitSha);
@@ -1785,7 +1870,7 @@ function DeployTargetForm({ initial, onSaved, onClose }) {
1785
1870
  }
1786
1871
 
1787
1872
  // src/version.ts
1788
- var VERSION = "1.4.0";
1873
+ var VERSION = "1.5.0";
1789
1874
 
1790
1875
  // src/components/DeployTool.tsx
1791
1876
  var import_jsx_runtime13 = require("react/jsx-runtime");
package/dist/index.mjs CHANGED
@@ -67,6 +67,11 @@ import { createContext, useContext } from "react";
67
67
  import { jsx as jsx2 } from "react/jsx-runtime";
68
68
  var DEFAULTS = { mode: "direct" };
69
69
  var ConfigContext = createContext(DEFAULTS);
70
+ function isUsableUnblock(unblock) {
71
+ if (!unblock) return false;
72
+ if (unblock.endpoint) return true;
73
+ return Boolean(unblock.token && unblock.owner && unblock.repo);
74
+ }
70
75
  function resolveConfig(options) {
71
76
  const config = options ?? {};
72
77
  return {
@@ -74,9 +79,11 @@ function resolveConfig(options) {
74
79
  // Trailing slashes would double up when request paths are appended.
75
80
  proxyUrl: config.proxyUrl?.replace(/\/+$/, ""),
76
81
  statusKey: config.statusKey,
77
- // Dropped unless it can actually be used. A config missing the token, the owner
78
- // or the repo would otherwise render a button whose only outcome is an error.
79
- unblock: config.unblock?.token && config.unblock.owner && config.unblock.repo ? config.unblock : void 0
82
+ // Dropped unless it can actually be used, so an incomplete configuration renders
83
+ // no button rather than one whose only outcome is an error. Either mode will do:
84
+ // an `endpoint` needs nothing else, while workflow dispatch needs all three of
85
+ // token, owner and repo to build an authenticated request.
86
+ unblock: isUsableUnblock(config.unblock) ? config.unblock : void 0
80
87
  };
81
88
  }
82
89
  function ConfigProvider({ value, children }) {
@@ -725,6 +732,78 @@ function dispatchErrorMessage(status, workflow, ref) {
725
732
  }
726
733
  }
727
734
 
735
+ // src/lib/unblock.ts
736
+ function assertSafeEndpoint(endpoint) {
737
+ let parsed;
738
+ try {
739
+ parsed = new URL(endpoint);
740
+ } catch {
741
+ throw new Error(`The configured recovery endpoint is not a valid URL: "${endpoint}".`);
742
+ }
743
+ const isLocal = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
744
+ if (parsed.protocol !== "https:" && !isLocal) {
745
+ throw new Error("The recovery endpoint must be https \u2014 the Studio session token is sent with the request.");
746
+ }
747
+ return parsed;
748
+ }
749
+ async function postToEndpoint(opts) {
750
+ const { config, ref, requestedBy, studioToken } = opts;
751
+ const endpoint = assertSafeEndpoint(config.endpoint);
752
+ if (!studioToken) {
753
+ throw new Error(
754
+ "Your Studio session token is not available, so the site cannot verify who you are. This happens under cookie-based login \u2014 sign out and back in, or ask a developer to deploy manually."
755
+ );
756
+ }
757
+ let res;
758
+ try {
759
+ res = await fetch(endpoint.toString(), {
760
+ method: "POST",
761
+ headers: {
762
+ "Content-Type": "application/json",
763
+ Authorization: `Bearer ${studioToken}`
764
+ },
765
+ body: JSON.stringify({ ref, requestedBy })
766
+ });
767
+ } catch {
768
+ throw new Error(
769
+ `Could not reach ${endpoint.host}. Check the site is up and that the route allows requests from this Studio's origin.`
770
+ );
771
+ }
772
+ if (res.ok) return;
773
+ let detail = "";
774
+ try {
775
+ const body = await res.json();
776
+ if (typeof body?.error === "string") detail = body.error;
777
+ } catch {
778
+ }
779
+ throw new Error(detail || endpointErrorMessage(res.status, endpoint.host));
780
+ }
781
+ function endpointErrorMessage(status, host) {
782
+ switch (status) {
783
+ case 401:
784
+ return "The site did not accept your Studio session. Try signing out of the Studio and back in.";
785
+ case 403:
786
+ return "Your Studio account is not permitted to trigger a deploy recovery.";
787
+ case 404:
788
+ return `No recovery route at ${host}. It may not be deployed yet \u2014 check the endpoint URL.`;
789
+ case 429:
790
+ return "A bump was requested very recently. Wait a moment before trying again.";
791
+ default:
792
+ return status >= 500 ? "The site failed while making the bump commit. Check its function logs." : `The site refused the request (${status}).`;
793
+ }
794
+ }
795
+ async function requestUnblock(opts) {
796
+ if (opts.config.endpoint) return postToEndpoint(opts);
797
+ if (!opts.config.owner || !opts.config.repo) {
798
+ throw new Error("Deploy recovery is misconfigured \u2014 set either `endpoint`, or `owner` and `repo`.");
799
+ }
800
+ return dispatchVersionBump({
801
+ config: opts.config,
802
+ ref: opts.ref,
803
+ requestedBy: opts.requestedBy
804
+ });
805
+ }
806
+
728
807
  // src/components/DeployItem.tsx
729
808
  import { useClient, useCurrentUser } from "sanity";
730
809
 
@@ -1123,10 +1202,16 @@ function DeployItem({ target, token, onDelete, onEdit }) {
1123
1202
  setBumping(true);
1124
1203
  setBumpResult(null);
1125
1204
  try {
1126
- await dispatchVersionBump({
1205
+ await requestUnblock({
1127
1206
  config: unblockConfig,
1128
1207
  ref,
1129
- requestedBy: currentUser?.name || currentUser?.email || void 0
1208
+ requestedBy: currentUser?.name || currentUser?.email || void 0,
1209
+ // Forwarded so a site endpoint can verify the caller is a signed-in project
1210
+ // user. Sanity only exposes this under token-based auth, so it can be
1211
+ // absent; `requestUnblock` reports that as its own case rather than
1212
+ // letting the server see an anonymous request and call it a permissions
1213
+ // failure. Unused by workflow-dispatch mode.
1214
+ studioToken: client.config().token
1130
1215
  });
1131
1216
  setBumpResult({
1132
1217
  ok: true,
@@ -1144,7 +1229,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
1144
1229
  } finally {
1145
1230
  setBumping(false);
1146
1231
  }
1147
- }, [pluginConfig.unblock, latest?.meta?.githubCommitRef, currentUser, target.name, toast]);
1232
+ }, [pluginConfig.unblock, latest?.meta?.githubCommitRef, currentUser, target.name, toast, client]);
1148
1233
  const branch = latest?.meta?.githubCommitRef;
1149
1234
  const commitMsg = latest?.meta?.githubCommitMessage?.split("\n")[0];
1150
1235
  const sha = shortSha(latest?.meta?.githubCommitSha);
@@ -1750,7 +1835,7 @@ function DeployTargetForm({ initial, onSaved, onClose }) {
1750
1835
  }
1751
1836
 
1752
1837
  // src/version.ts
1753
- var VERSION = "1.4.0";
1838
+ var VERSION = "1.5.0";
1754
1839
 
1755
1840
  // src/components/DeployTool.tsx
1756
1841
  import { jsx as jsx13, jsxs as jsxs9 } from "react/jsx-runtime";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liiift-studio/deploy-vercel-from-sanity",
3
- "version": "1.4.0",
3
+ "version": "1.5.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",