Skip to content

Commit 62d14de

Browse files
authored
Merge pull request #48 from shotstack/derk/eng-1150-share-links
Make studio links permanent with an API key
2 parents f5d2f2b + 21a0148 commit 62d14de

4 files changed

Lines changed: 193 additions & 21 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,9 @@ shotstack status 01ja7-x8m2k-... --output json
8282

8383
### `shotstack studio <file>`
8484

85-
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` — clean, shareable, expires in 30 days. Falls back to inline base64url encoding if the share API is unreachable.
85+
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
@@ -93,6 +93,7 @@ shotstack studio my-template.json
9393
shotstack studio my-template.json --copy # also copies URL to clipboard
9494
shotstack studio my-template.json --no-open # print URL, don't open browser
9595
shotstack studio my-template.json --no-shorten # emit base64url inline (offline / debug)
96+
shotstack studio my-template.json --env stage # share with the saved stage key
9697
shotstack studio my-template.json --output json # emit {"url":"...","shortened":true} on stdout
9798
```
9899

‎skills/shotstack/SKILL.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,18 +123,19 @@ 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
130130
shotstack studio template.json --no-open # headless: just print the URL
131131
shotstack studio template.json --no-shorten # emit base64url URL inline (offline / debug)
132+
shotstack studio template.json --env stage # share with the saved stage key
132133
shotstack studio template.json --output json # piping: {"url":"...","shortened":true}, no browser
133134
```
134135

135136
On headless systems (no `xdg-open`, no `$DISPLAY`) the browser launch silently no-ops; the URL is still printed. Safe to run anywhere.
136137

137-
If the share API is unreachable, the command falls back to the inline base64url form automatically and prints a stderr warning. Shares expire after 30 days.
138+
**The short link is permanent when the CLI has an API key** (`SHOTSTACK_API_KEY`, or the key saved for `--env`/`SHOTSTACK_ENV`). Without one it lasts 30 days and the command says so on stderr; suggest `shotstack login` if the user wants links that last. A timeout, 429 or 5xx gets one retry, then the command falls back to the inline base64url form and prints the cause on stderr. If the share API refuses the edit (over 1 MB, missing `timeline` or `output`, or an invalid key), the command exits 1 with the reason: fix that, or use `--no-shorten`.
138139

139140
**Default to `studio` for anything non-trivial** — multi-scene, multi-track, or any creative an interested human will review. It previews client-side, costs nothing, and lets the human hit Render when it's right. Use `render` directly only for single-shot/simple edits, automated pipelines, or when told "just render it".
140141

‎src/commands/studio.ts‎

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@ import { spawn, execSync } from "node:child_process";
44
import { Command } from "commander";
55
import { emit, parseOutputFormat } from "../output.ts";
66
import { validateEdit, formatIssues } from "../lib/validate.ts";
7+
import { requireApiKey, MissingApiKeyError } from "../http/auth.ts";
8+
import { ApiError } from "../http/client.ts";
9+
import { resolveEnvName, ENV_NAMES } from "../http/env.ts";
710

811
const STUDIO_URL = "https://shotstack.studio";
912
const SHARE_API_URL = `${STUDIO_URL}/api/share`;
10-
const SHARE_TIMEOUT_MS = 3000;
13+
const SHARE_TIMEOUT_MS = 5000;
1114
const URL_WARN_THRESHOLD = 6000;
1215

1316
export const studioCommand = new Command("studio")
@@ -18,8 +21,9 @@ export const studioCommand = new Command("studio")
1821
.option("--copy", "Copy the URL to the clipboard")
1922
.option("--no-open", "Do not try to open the URL in a browser")
2023
.option("--no-shorten", "Skip the share API; emit a base64url URL inline")
24+
.option(`--env <name>`, `Environment whose API key makes the short link permanent: ${ENV_NAMES.join(" | ")}`)
2125
.option("--output <format>", "Output format: text | json", "text")
22-
.action(async (file: string, options: { copy?: boolean; open: boolean; shorten: boolean; output: string }) => {
26+
.action(async (file: string, options: { copy?: boolean; open: boolean; shorten: boolean; env?: string; output: string }) => {
2327
const format = parseOutputFormat(options.output);
2428

2529
const path = resolve(process.cwd(), file);
@@ -33,7 +37,8 @@ export const studioCommand = new Command("studio")
3337
process.exit(1);
3438
}
3539

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

3843
if (!shortened && url.length > URL_WARN_THRESHOLD) {
3944
console.error(
@@ -57,36 +62,75 @@ interface StudioUrl {
5762

5863
export async function buildStudioUrl(
5964
template: unknown,
60-
options: { shorten?: boolean } = {}
65+
options: { shorten?: boolean; apiKey?: string; env?: string } = {}
6166
): Promise<StudioUrl> {
6267
const shouldShorten = options.shorten !== false;
6368
if (shouldShorten) {
64-
const shortUrl = await tryShortenViaStudio(template);
65-
if (shortUrl) return { url: shortUrl, shortened: true };
66-
console.error("warning: could not reach the shotstack.studio share API; falling back to inline URL.");
69+
const shared = await shareViaStudio(template, options.apiKey);
70+
if ("url" in shared) {
71+
if (!options.apiKey) {
72+
console.error(
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."
76+
);
77+
}
78+
return { url: shared.url, shortened: true };
79+
}
80+
console.error(`warning: the shotstack.studio share API failed (${shared.failed}); falling back to inline URL.`);
6781
}
6882
return { url: buildInlineUrl(template), shortened: false };
6983
}
7084

85+
// Studio validates keys against the production and sandbox Edit API only, so a dev key would be refused.
86+
export function shareApiKey(cliEnv: string | undefined, credentialsFile?: string): string | undefined {
87+
const env = resolveEnvName(cliEnv);
88+
if (env === "dev") return undefined;
89+
try {
90+
return requireApiKey(env, credentialsFile);
91+
} catch (err) {
92+
if (err instanceof MissingApiKeyError) return undefined;
93+
throw err;
94+
}
95+
}
96+
7197
export function buildInlineUrl(template: unknown): string {
7298
const encoded = Buffer.from(JSON.stringify(template), "utf8").toString("base64url");
7399
return `${STUDIO_URL}/#json=${encoded}`;
74100
}
75101

76-
async function tryShortenViaStudio(template: unknown): Promise<string | null> {
102+
type ShareOutcome = { url: string } | { failed: string; retry: boolean };
103+
104+
async function shareViaStudio(template: unknown, apiKey: string | undefined): Promise<ShareOutcome> {
105+
const headers: Record<string, string> = { "Content-Type": "application/json" };
106+
if (apiKey) headers["x-api-key"] = apiKey;
107+
const first = await postShare(template, headers);
108+
return "retry" in first && first.retry ? postShare(template, headers) : first;
109+
}
110+
111+
// A 4xx throws: the edit or the key was refused, and an inline URL would hide that.
112+
async function postShare(template: unknown, headers: Record<string, string>): Promise<ShareOutcome> {
113+
let res: Response;
77114
try {
78-
const res = await fetch(SHARE_API_URL, {
115+
res = await fetch(SHARE_API_URL, {
79116
method: "POST",
80-
headers: { "Content-Type": "application/json" },
117+
headers,
81118
body: JSON.stringify(template),
119+
// The key must never follow a redirect to another host.
120+
redirect: "error",
82121
signal: AbortSignal.timeout(SHARE_TIMEOUT_MS),
83122
});
84-
if (!res.ok) return null;
85-
const json = (await res.json()) as { url?: string };
86-
return json.url ?? null;
87-
} catch {
88-
return null;
123+
} catch (err) {
124+
return { failed: (err as Error).message, retry: true };
125+
}
126+
const body = (await res.json().catch(() => ({}))) as { url?: string; error?: string };
127+
if (res.ok && body.url) return { url: body.url };
128+
if (res.status === 429 || res.status >= 500) return { failed: `HTTP ${res.status}`, retry: true };
129+
if (res.status >= 400) {
130+
const reason = `${body.error ?? "no reason given"} (HTTP ${res.status})`;
131+
throw new ApiError(res.status, body, `The shotstack.studio share API refused the request: ${reason}`);
89132
}
133+
return { failed: `HTTP ${res.status} without a url`, retry: false };
90134
}
91135

92136
async function copyToClipboard(text: string): Promise<void> {

‎tests/studio.test.ts‎

Lines changed: 128 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test";
2-
import { buildInlineUrl, buildStudioUrl } from "../src/commands/studio.ts";
1+
import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from "bun:test";
2+
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";
6+
import { ApiError } from "../src/http/client.ts";
37

48
const sample = {
59
timeline: {
@@ -103,3 +107,125 @@ describe("buildStudioUrl", () => {
103107
expect(fetchSpy).toHaveBeenCalledTimes(0);
104108
});
105109
});
110+
111+
describe("share API rules", () => {
112+
// Answers each share call with the next step; an Error step rejects like a timeout or network failure.
113+
function shareSequence(...steps: (Response | Error)[]) {
114+
const fetchMock = mock(async (_input: string | URL | Request, _init?: RequestInit) => {
115+
const step = steps.shift();
116+
if (!step) throw new Error("Unexpected extra share call");
117+
if (step instanceof Error) throw step;
118+
return step;
119+
});
120+
globalThis.fetch = fetchMock as unknown as typeof fetch;
121+
return fetchMock;
122+
}
123+
124+
const shared = () => new Response(JSON.stringify({ slug: "abc12345", url: "https://shotstack.studio/s/abc12345" }));
125+
const refusal = (status: number, error: string) => new Response(JSON.stringify({ error }), { status });
126+
const timeout = () => Object.assign(new Error("The operation was aborted due to timeout"), { name: "TimeoutError" });
127+
128+
let stderr: ReturnType<typeof spyOn>;
129+
beforeEach(() => {
130+
stderr = spyOn(console, "error").mockImplementation(() => {});
131+
});
132+
afterEach(() => {
133+
stderr.mockRestore();
134+
});
135+
136+
test("sends the API key and prints no expiry note", async () => {
137+
const fetchMock = shareSequence(shared());
138+
const result = await buildStudioUrl(sample, { apiKey: "key" });
139+
const headers = fetchMock.mock.calls[0]![1]!.headers as Record<string, string>;
140+
expect(headers["x-api-key"]).toBe("key");
141+
expect(fetchMock.mock.calls[0]![1]!.redirect).toBe("error");
142+
expect(result.shortened).toBe(true);
143+
expect(stderr).not.toHaveBeenCalled();
144+
});
145+
146+
test("without a key, says on stderr that the link lasts 30 days", async () => {
147+
const fetchMock = shareSequence(shared());
148+
await buildStudioUrl(sample);
149+
const headers = fetchMock.mock.calls[0]![1]!.headers as Record<string, string>;
150+
expect(headers["x-api-key"]).toBeUndefined();
151+
expect(String(stderr.mock.calls[0]![0])).toContain("lasts 30 days");
152+
});
153+
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+
160+
test("retries once after a timeout", async () => {
161+
const fetchMock = shareSequence(timeout(), shared());
162+
const result = await buildStudioUrl(sample, { apiKey: "key" });
163+
expect(fetchMock).toHaveBeenCalledTimes(2);
164+
expect(result).toEqual({ url: "https://shotstack.studio/s/abc12345", shortened: true });
165+
});
166+
167+
test("retries once after a 429", async () => {
168+
const fetchMock = shareSequence(new Response('{"message":"Too Many Requests"}', { status: 429 }), shared());
169+
const result = await buildStudioUrl(sample, { apiKey: "key" });
170+
expect(fetchMock).toHaveBeenCalledTimes(2);
171+
expect(result.shortened).toBe(true);
172+
});
173+
174+
test("falls back after two 5xx and names the cause", async () => {
175+
const fetchMock = shareSequence(refusal(502, "storage-failed"), new Response("", { status: 503 }));
176+
const result = await buildStudioUrl(sample, { apiKey: "key" });
177+
expect(fetchMock).toHaveBeenCalledTimes(2);
178+
expect(result.shortened).toBe(false);
179+
expect(String(stderr.mock.calls[0]![0])).toContain("HTTP 503");
180+
});
181+
182+
test("falls back after two timeouts and names the cause", async () => {
183+
shareSequence(timeout(), timeout());
184+
const result = await buildStudioUrl(sample, { apiKey: "key" });
185+
expect(result.shortened).toBe(false);
186+
expect(String(stderr.mock.calls[0]![0])).toContain("timeout");
187+
});
188+
189+
test("a 400 is an error with the share API's reason, not an inline URL", async () => {
190+
const fetchMock = shareSequence(refusal(400, "edit-missing-timeline-or-output"));
191+
const err = await buildStudioUrl(sample, { apiKey: "key" }).catch((e: unknown) => e);
192+
expect((err as Error).message).toContain("edit-missing-timeline-or-output");
193+
expect(fetchMock).toHaveBeenCalledTimes(1);
194+
});
195+
196+
test("a 413 is an ApiError carrying payload-too-large", async () => {
197+
shareSequence(refusal(413, "payload-too-large"));
198+
const err = await buildStudioUrl(sample, { apiKey: "key" }).catch((e: unknown) => e);
199+
expect(err).toBeInstanceOf(ApiError);
200+
expect((err as ApiError).status).toBe(413);
201+
expect((err as ApiError).message).toContain("payload-too-large");
202+
});
203+
});
204+
205+
describe("shareApiKey", () => {
206+
const saved = process.env.SHOTSTACK_API_KEY;
207+
afterEach(() => {
208+
if (saved === undefined) delete process.env.SHOTSTACK_API_KEY;
209+
else process.env.SHOTSTACK_API_KEY = saved;
210+
});
211+
212+
test("uses the key a sandbox render would use", () => {
213+
process.env.SHOTSTACK_API_KEY = "stage-key";
214+
expect(shareApiKey("stage")).toBe("stage-key");
215+
});
216+
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+
227+
test("shares anonymously for dev because Studio cannot validate dev keys", () => {
228+
process.env.SHOTSTACK_API_KEY = "dev-key";
229+
expect(shareApiKey("dev")).toBeUndefined();
230+
});
231+
});

0 commit comments

Comments
 (0)