Skip to content

Commit 831cf1a

Browse files
committed
feat: make studio links permanent with an API key and report share refusals
1 parent f5d2f2b commit 831cf1a

2 files changed

Lines changed: 163 additions & 17 deletions

File tree

‎src/commands/studio.ts‎

Lines changed: 55 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 });
3742

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

5863
export async function buildStudioUrl(
5964
template: unknown,
60-
options: { shorten?: boolean } = {}
65+
options: { shorten?: boolean; apiKey?: 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+
"note: this link lasts 30 days. Run `shotstack login` or set SHOTSTACK_API_KEY to make your links permanent."
74+
);
75+
}
76+
return { url: shared.url, shortened: true };
77+
}
78+
console.error(`warning: the shotstack.studio share API failed (${shared.failed}); falling back to inline URL.`);
6779
}
6880
return { url: buildInlineUrl(template), shortened: false };
6981
}
7082

83+
// 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 {
85+
const env = resolveEnvName(cliEnv);
86+
if (env === "dev") return undefined;
87+
try {
88+
return requireApiKey(env);
89+
} catch (err) {
90+
if (err instanceof MissingApiKeyError) return undefined;
91+
throw err;
92+
}
93+
}
94+
7195
export function buildInlineUrl(template: unknown): string {
7296
const encoded = Buffer.from(JSON.stringify(template), "utf8").toString("base64url");
7397
return `${STUDIO_URL}/#json=${encoded}`;
7498
}
7599

76-
async function tryShortenViaStudio(template: unknown): Promise<string | null> {
100+
type ShareOutcome = { url: string } | { failed: string; retry: boolean };
101+
102+
async function shareViaStudio(template: unknown, apiKey: string | undefined): Promise<ShareOutcome> {
103+
const headers: Record<string, string> = { "Content-Type": "application/json" };
104+
if (apiKey) headers["x-api-key"] = apiKey;
105+
const first = await postShare(template, headers);
106+
return "retry" in first && first.retry ? postShare(template, headers) : first;
107+
}
108+
109+
// A 4xx throws: the edit or the key was refused, and an inline URL would hide that.
110+
async function postShare(template: unknown, headers: Record<string, string>): Promise<ShareOutcome> {
111+
let res: Response;
77112
try {
78-
const res = await fetch(SHARE_API_URL, {
113+
res = await fetch(SHARE_API_URL, {
79114
method: "POST",
80-
headers: { "Content-Type": "application/json" },
115+
headers,
81116
body: JSON.stringify(template),
82117
signal: AbortSignal.timeout(SHARE_TIMEOUT_MS),
83118
});
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;
119+
} catch (err) {
120+
return { failed: (err as Error).message, retry: true };
121+
}
122+
const body = (await res.json().catch(() => ({}))) as { url?: string; error?: string };
123+
if (res.ok && body.url) return { url: body.url };
124+
if (res.status === 429 || res.status >= 500) return { failed: `HTTP ${res.status}`, retry: true };
125+
if (res.status >= 400) {
126+
const reason = `${body.error ?? "no reason given"} (HTTP ${res.status})`;
127+
throw new ApiError(res.status, body, `The shotstack.studio share API refused the request: ${reason}`);
89128
}
129+
return { failed: `HTTP ${res.status} without a url`, retry: false };
90130
}
91131

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

‎tests/studio.test.ts‎

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
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 { ApiError } from "../src/http/client.ts";
34

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

0 commit comments

Comments
 (0)