@liiift-studio/deploy-vercel-from-sanity 1.4.0 → 1.5.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 CHANGED
@@ -291,12 +291,75 @@ 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**, and read that list from the
340
+ environment rather than hard-coding it. The recovery flow only ever needs the
341
+ branches you deploy, but the list has to change when you add a deploy target —
342
+ and a hard-coded list can only be changed by shipping a deploy, which is the
343
+ very thing that is broken when this route is needed.
344
+ - **Answer `{ error }` on failure.** The plugin shows that string to the editor
345
+ verbatim, in preference to its own generic message.
346
+ - **Serve it over https.** The plugin refuses a plaintext endpoint, because the
347
+ session token travels with the request. `localhost` is exempt for development.
348
+
349
+ The token forwarded is `client.config().token`. Sanity only exposes it under
350
+ token-based auth, so it can be absent under cookie-based login; the plugin
351
+ detects that and says so, rather than sending an anonymous request and letting
352
+ your route report it as a permissions failure.
353
+
354
+ A worked Next.js Pages Router implementation is in
355
+ [`docs/deploy-unblock-route.js`](docs/deploy-unblock-route.js).
356
+
357
+ ---
358
+
359
+ ## `token` mode — workflow dispatch
360
+
361
+ For setups with no server. The Studio dispatches a GitHub Actions workflow, and
362
+ the workflow makes the commit.
300
363
 
301
364
  ```
302
365
  Studio bundle ── unblock.token Actions: write, one repo
@@ -312,8 +375,7 @@ Handing the Studio a `Contents: write` token would be far simpler and is the
312
375
  obvious first design. Do not: the Studio bundle is served publicly, so that token
313
376
  would let anyone who can load the Studio push arbitrary commits to the production
314
377
  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.
378
+ that the worst a leak permits is *running the bump workflow*.
317
379
 
318
380
  ### What a leaked dispatch token can actually do
319
381
 
@@ -331,61 +393,60 @@ The shipped workflow therefore opens with a branch allowlist, and a `concurrency
331
393
  group that serialises bumps per branch. Narrow the allowlist to the branches you
332
394
  actually deploy. Rotate the token if it leaks.
333
395
 
334
- ### 1. Add the workflow
396
+ ### Setup
335
397
 
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.
398
+ 1. Copy [`docs/version-bump.yml`](docs/version-bump.yml) to
399
+ `.github/workflows/version-bump.yml`, set the git identity in it to the account
400
+ whose commits Vercel accepts, and narrow the branch allowlist.
340
401
 
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".
402
+ > **The workflow file must exist on the repository's default branch**, and on
403
+ > every branch you deploy from. GitHub resolves a dispatch against the default
404
+ > branch's copy, then runs the copy on the requested ref. A file present only on
405
+ > `staging` returns a 404 — which the plugin reports in full, since GitHub uses
406
+ > the same 404 for "no such workflow" and "your token cannot see this repo".
346
407
 
347
- ### 2. Add the commit credential
408
+ 2. Create a fine-grained token with **`Contents: write`** on that one repository
409
+ and save it as the repository Actions secret **`DEPLOY_COMMIT_TOKEN`**.
348
410
 
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`**.
411
+ It must not be the default `GITHUB_TOKEN`: commits made with it are authored by
412
+ `github-actions[bot]`, which is not a team member either, so Vercel would block
413
+ the bump for the same reason it blocked the original commit.
352
414
 
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.
415
+ 3. Create a second fine-grained token scoped to **`Actions: write`** on the same
416
+ repository and nothing else and expose it to the Studio build:
356
417
 
357
- ### 3. Add the dispatch token
418
+ ```sh
419
+ SANITY_STUDIO_DEPLOY_UNBLOCK_GH_TOKEN=github_pat_…
420
+ ```
358
421
 
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:
422
+ 4. Configure the plugin:
361
423
 
362
- ```sh
363
- SANITY_STUDIO_DEPLOY_UNBLOCK_GH_TOKEN=github_pat_…
364
- ```
424
+ ```ts
425
+ vercelDeploy({
426
+ unblock: {
427
+ token: process.env.SANITY_STUDIO_DEPLOY_UNBLOCK_GH_TOKEN,
428
+ owner: 'your-org',
429
+ repo: 'your-site-repo',
430
+ workflow: 'version-bump.yml', // optional, this is the default
431
+ },
432
+ })
433
+ ```
365
434
 
366
- ### 4. Configure the plugin
435
+ ---
367
436
 
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
- ```
437
+ ## `unblock` reference
378
438
 
379
439
  | Field | Required | Description |
380
440
  |---|---|---|
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 |
441
+ | `endpoint` | one of | URL of your site route. Wins when both modes are configured. Must be https. |
442
+ | `token` | one of | Fine-grained token, `Actions: write` on `repo` only. Ships in the Studio bundle — treat it as public. |
443
+ | `owner` | dispatch only | Repository owner, e.g. `your-org` |
444
+ | `repo` | dispatch only | Repository name |
384
445
  | `workflow` | no | Workflow filename. Defaults to `version-bump.yml`. |
385
446
  | `defaultRef` | no | Branch to bump when the blocked deployment names none. Normally unnecessary — the branch is read from the deployment being recovered. |
386
447
 
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.
448
+ A config with neither `endpoint` nor all of `token`/`owner`/`repo` is discarded,
449
+ so a half-finished setup renders no button rather than one that only errors.
389
450
 
390
451
  ### What the editor sees
391
452
 
@@ -394,9 +455,8 @@ not a general "deploy harder" control, and it is deliberately not offered when
394
455
  the plugin has nothing to target.
395
456
 
396
457
  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.
458
+ the commit is made, but Vercel still has to notice the push and build it, so the
459
+ new deployment turns up shortly after. Polling picks it up on its own.
400
460
 
401
461
  ### The real fix
402
462
 
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
 
@@ -975,6 +1054,7 @@ var import_jsx_runtime10 = require("react/jsx-runtime");
975
1054
  var POLL_INTERVAL_MS = 5e3;
976
1055
  var LABEL_WIDTH = 64;
977
1056
  var PENDING_TIMEOUT_MS = 6e4;
1057
+ var BUMP_WATCH_TIMEOUT_MS = 3e5;
978
1058
  function DeployItem({ target, token, onDelete, onEdit }) {
979
1059
  const { projectId, hookId } = parseHookUrl(target.url);
980
1060
  const toast = useToast();
@@ -1004,8 +1084,10 @@ function DeployItem({ target, token, onDelete, onEdit }) {
1004
1084
  const [logError, setLogError] = (0, import_react8.useState)(null);
1005
1085
  const [pollError, setPollError] = (0, import_react8.useState)(null);
1006
1086
  const [bumping, setBumping] = (0, import_react8.useState)(false);
1087
+ const [awaitingBump, setAwaitingBump] = (0, import_react8.useState)(false);
1007
1088
  const [bumpResult, setBumpResult] = (0, import_react8.useState)(null);
1008
1089
  const triggeredFromUidRef = (0, import_react8.useRef)(void 0);
1090
+ const bumpedFromUidRef = (0, import_react8.useRef)(void 0);
1009
1091
  const requestSeqRef = (0, import_react8.useRef)(0);
1010
1092
  const mountedRef = (0, import_react8.useRef)(true);
1011
1093
  (0, import_react8.useEffect)(() => {
@@ -1036,10 +1118,19 @@ function DeployItem({ target, token, onDelete, onEdit }) {
1036
1118
  fetchDeployments2().finally(() => setLoadingInitial(false));
1037
1119
  }, [fetchDeployments2]);
1038
1120
  (0, import_react8.useEffect)(() => {
1039
- if (!isActive) return;
1121
+ if (!isActive && !awaitingBump) return;
1040
1122
  const id = setInterval(fetchDeployments2, POLL_INTERVAL_MS);
1041
1123
  return () => clearInterval(id);
1042
- }, [isActive, fetchDeployments2]);
1124
+ }, [isActive, awaitingBump, fetchDeployments2]);
1125
+ (0, import_react8.useEffect)(() => {
1126
+ if (!awaitingBump) return;
1127
+ if (latest?.uid && latest.uid !== bumpedFromUidRef.current) setAwaitingBump(false);
1128
+ }, [awaitingBump, latest?.uid]);
1129
+ (0, import_react8.useEffect)(() => {
1130
+ if (!awaitingBump) return;
1131
+ const id = setTimeout(() => setAwaitingBump(false), BUMP_WATCH_TIMEOUT_MS);
1132
+ return () => clearTimeout(id);
1133
+ }, [awaitingBump]);
1043
1134
  (0, import_react8.useEffect)(() => {
1044
1135
  if (!isPending) return;
1045
1136
  if (latest?.uid && latest.uid !== triggeredFromUidRef.current) setPendingSince(null);
@@ -1158,11 +1249,19 @@ function DeployItem({ target, token, onDelete, onEdit }) {
1158
1249
  setBumping(true);
1159
1250
  setBumpResult(null);
1160
1251
  try {
1161
- await dispatchVersionBump({
1252
+ await requestUnblock({
1162
1253
  config: unblockConfig,
1163
1254
  ref,
1164
- requestedBy: currentUser?.name || currentUser?.email || void 0
1255
+ requestedBy: currentUser?.name || currentUser?.email || void 0,
1256
+ // Forwarded so a site endpoint can verify the caller is a signed-in project
1257
+ // user. Sanity only exposes this under token-based auth, so it can be
1258
+ // absent; `requestUnblock` reports that as its own case rather than
1259
+ // letting the server see an anonymous request and call it a permissions
1260
+ // failure. Unused by workflow-dispatch mode.
1261
+ studioToken: client.config().token
1165
1262
  });
1263
+ bumpedFromUidRef.current = latest?.uid;
1264
+ setAwaitingBump(true);
1166
1265
  setBumpResult({
1167
1266
  ok: true,
1168
1267
  message: `Version bump requested on ${ref}. The new deploy appears here in a minute or two.`
@@ -1179,7 +1278,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
1179
1278
  } finally {
1180
1279
  setBumping(false);
1181
1280
  }
1182
- }, [pluginConfig.unblock, latest?.meta?.githubCommitRef, currentUser, target.name, toast]);
1281
+ }, [pluginConfig.unblock, latest?.meta?.githubCommitRef, latest?.uid, currentUser, target.name, toast, client]);
1183
1282
  const branch = latest?.meta?.githubCommitRef;
1184
1283
  const commitMsg = latest?.meta?.githubCommitMessage?.split("\n")[0];
1185
1284
  const sha = shortSha(latest?.meta?.githubCommitSha);
@@ -1393,9 +1492,12 @@ function DeployItem({ target, token, onDelete, onEdit }) {
1393
1492
  ", which Vercel will build."
1394
1493
  ] })
1395
1494
  ] }) : /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(Text, { size: 0, muted: true, children: pluginConfig.unblock ? "This deployment carries no branch information, so a version bump cannot be targeted. Ask a developer to deploy manually." : "Ask a developer to push a version bump \u2014 a commit by an authorised author is needed before this site can deploy." }),
1396
- bumpResult && /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(Text, { size: 0, weight: bumpResult.ok ? "semibold" : void 0, children: [
1397
- bumpResult.ok ? "\u2713 " : "",
1398
- bumpResult.message
1495
+ bumpResult && /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(Flex, { align: "center", gap: 2, children: [
1496
+ awaitingBump && /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(Spinner, { muted: true }),
1497
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)(Text, { size: 0, weight: bumpResult.ok ? "semibold" : void 0, children: [
1498
+ bumpResult.ok && !awaitingBump ? "\u2713 " : "",
1499
+ bumpResult.message
1500
+ ] })
1399
1501
  ] })
1400
1502
  ] }) })
1401
1503
  ] }),
@@ -1785,7 +1887,7 @@ function DeployTargetForm({ initial, onSaved, onClose }) {
1785
1887
  }
1786
1888
 
1787
1889
  // src/version.ts
1788
- var VERSION = "1.4.0";
1890
+ var VERSION = "1.5.1";
1789
1891
 
1790
1892
  // src/components/DeployTool.tsx
1791
1893
  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
 
@@ -940,6 +1019,7 @@ import { Fragment, jsx as jsx10, jsxs as jsxs6 } from "react/jsx-runtime";
940
1019
  var POLL_INTERVAL_MS = 5e3;
941
1020
  var LABEL_WIDTH = 64;
942
1021
  var PENDING_TIMEOUT_MS = 6e4;
1022
+ var BUMP_WATCH_TIMEOUT_MS = 3e5;
943
1023
  function DeployItem({ target, token, onDelete, onEdit }) {
944
1024
  const { projectId, hookId } = parseHookUrl(target.url);
945
1025
  const toast = useToast();
@@ -969,8 +1049,10 @@ function DeployItem({ target, token, onDelete, onEdit }) {
969
1049
  const [logError, setLogError] = useState5(null);
970
1050
  const [pollError, setPollError] = useState5(null);
971
1051
  const [bumping, setBumping] = useState5(false);
1052
+ const [awaitingBump, setAwaitingBump] = useState5(false);
972
1053
  const [bumpResult, setBumpResult] = useState5(null);
973
1054
  const triggeredFromUidRef = useRef2(void 0);
1055
+ const bumpedFromUidRef = useRef2(void 0);
974
1056
  const requestSeqRef = useRef2(0);
975
1057
  const mountedRef = useRef2(true);
976
1058
  useEffect4(() => {
@@ -1001,10 +1083,19 @@ function DeployItem({ target, token, onDelete, onEdit }) {
1001
1083
  fetchDeployments2().finally(() => setLoadingInitial(false));
1002
1084
  }, [fetchDeployments2]);
1003
1085
  useEffect4(() => {
1004
- if (!isActive) return;
1086
+ if (!isActive && !awaitingBump) return;
1005
1087
  const id = setInterval(fetchDeployments2, POLL_INTERVAL_MS);
1006
1088
  return () => clearInterval(id);
1007
- }, [isActive, fetchDeployments2]);
1089
+ }, [isActive, awaitingBump, fetchDeployments2]);
1090
+ useEffect4(() => {
1091
+ if (!awaitingBump) return;
1092
+ if (latest?.uid && latest.uid !== bumpedFromUidRef.current) setAwaitingBump(false);
1093
+ }, [awaitingBump, latest?.uid]);
1094
+ useEffect4(() => {
1095
+ if (!awaitingBump) return;
1096
+ const id = setTimeout(() => setAwaitingBump(false), BUMP_WATCH_TIMEOUT_MS);
1097
+ return () => clearTimeout(id);
1098
+ }, [awaitingBump]);
1008
1099
  useEffect4(() => {
1009
1100
  if (!isPending) return;
1010
1101
  if (latest?.uid && latest.uid !== triggeredFromUidRef.current) setPendingSince(null);
@@ -1123,11 +1214,19 @@ function DeployItem({ target, token, onDelete, onEdit }) {
1123
1214
  setBumping(true);
1124
1215
  setBumpResult(null);
1125
1216
  try {
1126
- await dispatchVersionBump({
1217
+ await requestUnblock({
1127
1218
  config: unblockConfig,
1128
1219
  ref,
1129
- requestedBy: currentUser?.name || currentUser?.email || void 0
1220
+ requestedBy: currentUser?.name || currentUser?.email || void 0,
1221
+ // Forwarded so a site endpoint can verify the caller is a signed-in project
1222
+ // user. Sanity only exposes this under token-based auth, so it can be
1223
+ // absent; `requestUnblock` reports that as its own case rather than
1224
+ // letting the server see an anonymous request and call it a permissions
1225
+ // failure. Unused by workflow-dispatch mode.
1226
+ studioToken: client.config().token
1130
1227
  });
1228
+ bumpedFromUidRef.current = latest?.uid;
1229
+ setAwaitingBump(true);
1131
1230
  setBumpResult({
1132
1231
  ok: true,
1133
1232
  message: `Version bump requested on ${ref}. The new deploy appears here in a minute or two.`
@@ -1144,7 +1243,7 @@ function DeployItem({ target, token, onDelete, onEdit }) {
1144
1243
  } finally {
1145
1244
  setBumping(false);
1146
1245
  }
1147
- }, [pluginConfig.unblock, latest?.meta?.githubCommitRef, currentUser, target.name, toast]);
1246
+ }, [pluginConfig.unblock, latest?.meta?.githubCommitRef, latest?.uid, currentUser, target.name, toast, client]);
1148
1247
  const branch = latest?.meta?.githubCommitRef;
1149
1248
  const commitMsg = latest?.meta?.githubCommitMessage?.split("\n")[0];
1150
1249
  const sha = shortSha(latest?.meta?.githubCommitSha);
@@ -1358,9 +1457,12 @@ function DeployItem({ target, token, onDelete, onEdit }) {
1358
1457
  ", which Vercel will build."
1359
1458
  ] })
1360
1459
  ] }) : /* @__PURE__ */ jsx10(Text, { size: 0, muted: true, children: pluginConfig.unblock ? "This deployment carries no branch information, so a version bump cannot be targeted. Ask a developer to deploy manually." : "Ask a developer to push a version bump \u2014 a commit by an authorised author is needed before this site can deploy." }),
1361
- bumpResult && /* @__PURE__ */ jsxs6(Text, { size: 0, weight: bumpResult.ok ? "semibold" : void 0, children: [
1362
- bumpResult.ok ? "\u2713 " : "",
1363
- bumpResult.message
1460
+ bumpResult && /* @__PURE__ */ jsxs6(Flex, { align: "center", gap: 2, children: [
1461
+ awaitingBump && /* @__PURE__ */ jsx10(Spinner, { muted: true }),
1462
+ /* @__PURE__ */ jsxs6(Text, { size: 0, weight: bumpResult.ok ? "semibold" : void 0, children: [
1463
+ bumpResult.ok && !awaitingBump ? "\u2713 " : "",
1464
+ bumpResult.message
1465
+ ] })
1364
1466
  ] })
1365
1467
  ] }) })
1366
1468
  ] }),
@@ -1750,7 +1852,7 @@ function DeployTargetForm({ initial, onSaved, onClose }) {
1750
1852
  }
1751
1853
 
1752
1854
  // src/version.ts
1753
- var VERSION = "1.4.0";
1855
+ var VERSION = "1.5.1";
1754
1856
 
1755
1857
  // src/components/DeployTool.tsx
1756
1858
  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.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",