Skip to content

Commit 21a0148

Browse files
committed
fix: keep the studio share key off redirects and explain dev links
1 parent 3b2baeb commit 21a0148

4 files changed

Lines changed: 31 additions & 7 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ shotstack status 01ja7-x8m2k-... --output json
8484

8585
Opens a `shotstack.studio` URL that loads the Edit JSON in the browser-based editor. By default, posts the JSON to the share API and emits a short URL like `https://shotstack.studio/s/abc12345`. With an API key (`SHOTSTACK_API_KEY`, or the key saved for `--env`) the link is permanent; without one it lasts 30 days, and the command says so on stderr. A timeout, 429 or 5xx gets one retry, then the command falls back to inline base64url encoding and prints the cause. If the share API refuses the edit (too large, invalid, or an invalid key), the command exits 1 with the reason.
8686

87-
No render API key required; no render credits charged. Use to hand a generated edit off to a human for review or quick tweaks before rendering.
87+
No render API key required and no render credits are charged; a key only makes the short link permanent. Use to hand a generated edit off to a human for review or quick tweaks before rendering.
8888

8989
```sh
9090
shotstack studio my-template.json

‎skills/shotstack/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ shotstack generate seedance-2.0-text-to-video "Waves break over black sand at du
123123

124124
## Default workflow: preview in Studio (no key, no credits)
125125

126-
**`shotstack studio <file>` is the default way to look at an edit** — prefer it over `render` unless you specifically need a cloud-exported MP4. It posts the JSON to https://shotstack.studio and opens `https://shotstack.studio/s/<slug>` in the browser — a short, shareable URL. No API key, no render credits charged; it previews client-side, so it works with no key. The human plays, edits, and decides whether to spend credits on a render.
126+
**`shotstack studio <file>` is the default way to look at an edit** — prefer it over `render` unless you specifically need a cloud-exported MP4. It posts the JSON to https://shotstack.studio and opens `https://shotstack.studio/s/<slug>` in the browser — a short, shareable URL. No render credits charged; it previews client-side, so it works with no key (a key only makes the short link permanent). The human plays, edits, and decides whether to spend credits on a render.
127127

128128
```sh
129129
shotstack studio template.json # opens browser + prints short URL

‎src/commands/studio.ts‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export const studioCommand = new Command("studio")
3838
}
3939

4040
const apiKey = options.shorten ? shareApiKey(options.env) : undefined;
41-
const { url, shortened } = await buildStudioUrl(template, { shorten: options.shorten, apiKey });
41+
const { url, shortened } = await buildStudioUrl(template, { shorten: options.shorten, apiKey, env: options.shorten ? resolveEnvName(options.env) : undefined });
4242

4343
if (!shortened && url.length > URL_WARN_THRESHOLD) {
4444
console.error(
@@ -62,15 +62,17 @@ interface StudioUrl {
6262

6363
export async function buildStudioUrl(
6464
template: unknown,
65-
options: { shorten?: boolean; apiKey?: string } = {}
65+
options: { shorten?: boolean; apiKey?: string; env?: string } = {}
6666
): Promise<StudioUrl> {
6767
const shouldShorten = options.shorten !== false;
6868
if (shouldShorten) {
6969
const shared = await shareViaStudio(template, options.apiKey);
7070
if ("url" in shared) {
7171
if (!options.apiKey) {
7272
console.error(
73-
"note: this link lasts 30 days. Run `shotstack login` or set SHOTSTACK_API_KEY to make your links permanent."
73+
options.env === "dev"
74+
? "note: this link lasts 30 days. Dev keys can't make links permanent; use --env stage or --env v1."
75+
: "note: this link lasts 30 days. Run `shotstack login` or set SHOTSTACK_API_KEY to make your links permanent."
7476
);
7577
}
7678
return { url: shared.url, shortened: true };
@@ -81,11 +83,11 @@ export async function buildStudioUrl(
8183
}
8284

8385
// Studio validates keys against the production and sandbox Edit API only, so a dev key would be refused.
84-
export function shareApiKey(cliEnv: string | undefined): string | undefined {
86+
export function shareApiKey(cliEnv: string | undefined, credentialsFile?: string): string | undefined {
8587
const env = resolveEnvName(cliEnv);
8688
if (env === "dev") return undefined;
8789
try {
88-
return requireApiKey(env);
90+
return requireApiKey(env, credentialsFile);
8991
} catch (err) {
9092
if (err instanceof MissingApiKeyError) return undefined;
9193
throw err;
@@ -114,6 +116,8 @@ async function postShare(template: unknown, headers: Record<string, string>): Pr
114116
method: "POST",
115117
headers,
116118
body: JSON.stringify(template),
119+
// The key must never follow a redirect to another host.
120+
redirect: "error",
117121
signal: AbortSignal.timeout(SHARE_TIMEOUT_MS),
118122
});
119123
} catch (err) {

‎tests/studio.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from "bun:test";
22
import { buildInlineUrl, buildStudioUrl, shareApiKey } from "../src/commands/studio.ts";
3+
import { mkdtempSync, rmSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
36
import { ApiError } from "../src/http/client.ts";
47

58
const sample = {
@@ -135,6 +138,7 @@ describe("share API rules", () => {
135138
const result = await buildStudioUrl(sample, { apiKey: "key" });
136139
const headers = fetchMock.mock.calls[0]![1]!.headers as Record<string, string>;
137140
expect(headers["x-api-key"]).toBe("key");
141+
expect(fetchMock.mock.calls[0]![1]!.redirect).toBe("error");
138142
expect(result.shortened).toBe(true);
139143
expect(stderr).not.toHaveBeenCalled();
140144
});
@@ -147,6 +151,12 @@ describe("share API rules", () => {
147151
expect(String(stderr.mock.calls[0]![0])).toContain("lasts 30 days");
148152
});
149153

154+
test("for dev, the note names the cause instead of asking for a key", async () => {
155+
shareSequence(shared());
156+
await buildStudioUrl(sample, { env: "dev" });
157+
expect(String(stderr.mock.calls[0]![0])).toContain("Dev keys");
158+
});
159+
150160
test("retries once after a timeout", async () => {
151161
const fetchMock = shareSequence(timeout(), shared());
152162
const result = await buildStudioUrl(sample, { apiKey: "key" });
@@ -204,6 +214,16 @@ describe("shareApiKey", () => {
204214
expect(shareApiKey("stage")).toBe("stage-key");
205215
});
206216

217+
test("returns no key when none is set or saved", () => {
218+
const dir = mkdtempSync(join(tmpdir(), "shotstack-creds-"));
219+
delete process.env.SHOTSTACK_API_KEY;
220+
try {
221+
expect(shareApiKey("stage", join(dir, "credentials.json"))).toBeUndefined();
222+
} finally {
223+
rmSync(dir, { recursive: true, force: true });
224+
}
225+
});
226+
207227
test("shares anonymously for dev because Studio cannot validate dev keys", () => {
208228
process.env.SHOTSTACK_API_KEY = "dev-key";
209229
expect(shareApiKey("dev")).toBeUndefined();

0 commit comments

Comments
 (0)