Publish the unified MIT-licensed Stuffbox SDK source
Hop-State: A_06FPAF0M83E7T0JJ7EP2WQ0 Hop-Proposal: R_06FPAF04S0D4G8TA8TNK3C0 Hop-Task: T_06FPADTV2YD4PD5GMTK3150 Hop-Attempt: AT_06FPADTV2ZYKEY8GH3JTK98
This commit is contained in:
@@ -0,0 +1,400 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
StuffboxClient,
|
||||
StuffboxError,
|
||||
type FetchLike,
|
||||
} from "../src";
|
||||
|
||||
function json(value: unknown, init: ResponseInit = {}): Response {
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set("content-type", "application/json");
|
||||
return new Response(JSON.stringify(value), {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
describe("StuffboxClient", () => {
|
||||
it("creates a PKCE connection request using the protocol wire names", async () => {
|
||||
const fetcher = vi.fn<FetchLike>().mockResolvedValue(
|
||||
json({
|
||||
request_id: "cr_1",
|
||||
client_id: "app_0123456789abcdefghijklmnop",
|
||||
callback_url: "https://synapsis.test/stuffbox/callback",
|
||||
authorization_url: "https://stuffbox.test/authorize/cr_1",
|
||||
expires_at: "2026-01-01T00:05:00.000Z",
|
||||
}),
|
||||
);
|
||||
const client = new StuffboxClient({
|
||||
baseUrl: "https://stuffbox.test/",
|
||||
fetch: fetcher,
|
||||
accessToken: "must-not-leak",
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.createConnectionRequest({
|
||||
clientId: "app_0123456789abcdefghijklmnop",
|
||||
callbackUrl: "https://synapsis.test/stuffbox/callback",
|
||||
codeChallenge: "c".repeat(43),
|
||||
scopes: ["assets:read", "assets:write"],
|
||||
state: "state-with-enough-entropy",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
id: "cr_1",
|
||||
clientId: "app_0123456789abcdefghijklmnop",
|
||||
callbackUrl: "https://synapsis.test/stuffbox/callback",
|
||||
authorizationUrl: "https://stuffbox.test/authorize/cr_1",
|
||||
expiresAt: "2026-01-01T00:05:00.000Z",
|
||||
});
|
||||
|
||||
const [url, init] = fetcher.mock.calls[0];
|
||||
expect(url).toBe("https://stuffbox.test/api/v1/connection-requests");
|
||||
expect(init?.method).toBe("POST");
|
||||
expect(init?.headers).not.toHaveProperty("Authorization");
|
||||
expect(JSON.parse(String(init?.body))).toEqual({
|
||||
client_id: "app_0123456789abcdefghijklmnop",
|
||||
callback_url: "https://synapsis.test/stuffbox/callback",
|
||||
code_challenge: "c".repeat(43),
|
||||
code_challenge_method: "S256",
|
||||
scopes: ["assets:read", "assets:write"],
|
||||
state: "state-with-enough-entropy",
|
||||
});
|
||||
});
|
||||
|
||||
it("automatically registers a self-hosted callback and returns its client ID", async () => {
|
||||
const fetcher = vi.fn<FetchLike>().mockResolvedValue(
|
||||
json({
|
||||
request_id: "cr_self_hosted",
|
||||
client_id: "app_self_hosted_0123456789",
|
||||
callback_url: "https://synapsis.test/stuffbox/callback",
|
||||
authorization_url: "https://stuffbox.test/authorize/cr_self_hosted",
|
||||
expires_at: "2026-01-01T00:05:00.000Z",
|
||||
}),
|
||||
);
|
||||
const client = new StuffboxClient({
|
||||
baseUrl: "https://stuffbox.test",
|
||||
fetch: fetcher,
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.createConnectionRequest({
|
||||
selfHosted: true,
|
||||
callbackUrl: "https://synapsis.test/stuffbox/callback",
|
||||
codeChallenge: "c".repeat(43),
|
||||
scopes: ["assets:read", "assets:write"],
|
||||
state: "state-with-enough-entropy",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
id: "cr_self_hosted",
|
||||
clientId: "app_self_hosted_0123456789",
|
||||
callbackUrl: "https://synapsis.test/stuffbox/callback",
|
||||
authorizationUrl: "https://stuffbox.test/authorize/cr_self_hosted",
|
||||
expiresAt: "2026-01-01T00:05:00.000Z",
|
||||
});
|
||||
|
||||
expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body))).toEqual({
|
||||
registration_mode: "self_hosted",
|
||||
callback_url: "https://synapsis.test/stuffbox/callback",
|
||||
code_challenge: "c".repeat(43),
|
||||
code_challenge_method: "S256",
|
||||
scopes: ["assets:read", "assets:write"],
|
||||
state: "state-with-enough-entropy",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a connection response that omits the client ID", async () => {
|
||||
const fetcher = vi.fn<FetchLike>().mockResolvedValue(
|
||||
json({
|
||||
request_id: "cr_missing_client",
|
||||
callback_url: "https://synapsis.test/stuffbox/callback",
|
||||
authorization_url: "https://stuffbox.test/authorize/cr_missing_client",
|
||||
expires_at: "2026-01-01T00:05:00.000Z",
|
||||
}),
|
||||
);
|
||||
const client = new StuffboxClient({
|
||||
baseUrl: "https://stuffbox.test",
|
||||
fetch: fetcher,
|
||||
});
|
||||
|
||||
const error = await client
|
||||
.createConnectionRequest({
|
||||
selfHosted: true,
|
||||
callbackUrl: "https://synapsis.test/stuffbox/callback",
|
||||
codeChallenge: "c".repeat(43),
|
||||
scopes: ["assets:read"],
|
||||
state: "state-with-enough-entropy",
|
||||
})
|
||||
.catch((cause: unknown) => cause);
|
||||
|
||||
expect(error).toBeInstanceOf(StuffboxError);
|
||||
expect(error).toMatchObject({
|
||||
code: "invalid_response",
|
||||
message: "Missing string field: clientId",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a connection response that omits the canonical callback URL", async () => {
|
||||
const fetcher = vi.fn<FetchLike>().mockResolvedValue(
|
||||
json({
|
||||
request_id: "cr_missing_callback",
|
||||
client_id: "app_self_hosted_0123456789",
|
||||
authorization_url: "https://stuffbox.test/authorize/cr_missing_callback",
|
||||
expires_at: "2026-01-01T00:05:00.000Z",
|
||||
}),
|
||||
);
|
||||
const client = new StuffboxClient({
|
||||
baseUrl: "https://stuffbox.test",
|
||||
fetch: fetcher,
|
||||
});
|
||||
|
||||
const error = await client
|
||||
.createConnectionRequest({
|
||||
selfHosted: true,
|
||||
callbackUrl: "https://synapsis.test/stuffbox/callback",
|
||||
codeChallenge: "c".repeat(43),
|
||||
scopes: ["assets:read"],
|
||||
state: "state-with-enough-entropy",
|
||||
})
|
||||
.catch((cause: unknown) => cause);
|
||||
|
||||
expect(error).toBeInstanceOf(StuffboxError);
|
||||
expect(error).toMatchObject({
|
||||
code: "invalid_response",
|
||||
message: "Missing string field: callbackUrl",
|
||||
});
|
||||
});
|
||||
|
||||
it("dispatches authorization-code and refresh grants through one endpoint", async () => {
|
||||
const fetcher = vi.fn<FetchLike>().mockImplementation(async () =>
|
||||
json({
|
||||
access_token: "at_new",
|
||||
token_type: "bearer",
|
||||
expires_in: 600,
|
||||
refresh_token: "rt_new",
|
||||
refresh_token_expires_in: 2_592_000,
|
||||
scope: "assets:read assets:write",
|
||||
}),
|
||||
);
|
||||
const client = new StuffboxClient({
|
||||
baseUrl: "https://stuffbox.test",
|
||||
fetch: fetcher,
|
||||
});
|
||||
|
||||
const exchanged = await client.exchangeAuthorizationCode({
|
||||
clientId: "app_0123456789abcdefghijklmnop",
|
||||
code: "code",
|
||||
codeVerifier: "verifier",
|
||||
redirectUri: "https://node.test/callback",
|
||||
});
|
||||
const refreshed = await client.refreshTokens("rt_old");
|
||||
|
||||
expect(exchanged).toEqual(refreshed);
|
||||
expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body))).toEqual({
|
||||
grant_type: "authorization_code",
|
||||
client_id: "app_0123456789abcdefghijklmnop",
|
||||
code: "code",
|
||||
code_verifier: "verifier",
|
||||
redirect_uri: "https://node.test/callback",
|
||||
});
|
||||
expect(JSON.parse(String(fetcher.mock.calls[1][1]?.body))).toEqual({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: "rt_old",
|
||||
});
|
||||
expect(fetcher.mock.calls[0][0]).toBe("https://stuffbox.test/api/v1/token");
|
||||
expect(fetcher.mock.calls[1][0]).toBe("https://stuffbox.test/api/v1/token");
|
||||
});
|
||||
|
||||
it("adds a lazy bearer token only to protected requests", async () => {
|
||||
const accessToken = vi.fn().mockResolvedValue("at_secret");
|
||||
const fetcher = vi.fn<FetchLike>().mockResolvedValue(
|
||||
json({ items: [], next_cursor: "next" }),
|
||||
);
|
||||
const client = new StuffboxClient({
|
||||
baseUrl: "https://stuffbox.test",
|
||||
fetch: fetcher,
|
||||
accessToken,
|
||||
});
|
||||
|
||||
await expect(client.listAssets({ limit: 10 })).resolves.toEqual({
|
||||
items: [],
|
||||
nextCursor: "next",
|
||||
});
|
||||
expect(accessToken).toHaveBeenCalledOnce();
|
||||
expect(fetcher.mock.calls[0][0]).toBe(
|
||||
"https://stuffbox.test/api/v1/assets?limit=10",
|
||||
);
|
||||
expect(fetcher.mock.calls[0][1]?.headers).toHaveProperty(
|
||||
"Authorization",
|
||||
"Bearer at_secret",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an asset page size outside the public API range before fetching", async () => {
|
||||
const fetcher = vi.fn<FetchLike>();
|
||||
const client = new StuffboxClient({
|
||||
baseUrl: "https://stuffbox.test",
|
||||
fetch: fetcher,
|
||||
accessToken: "at_secret",
|
||||
});
|
||||
|
||||
await expect(client.listAssets({ limit: 101 })).rejects.toThrow(
|
||||
"Asset list limit must be between 1 and 100",
|
||||
);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates and completes a direct upload without handling file bytes", async () => {
|
||||
const fetcher = vi
|
||||
.fn<FetchLike>()
|
||||
.mockResolvedValueOnce(
|
||||
json({
|
||||
upload_id: "up_1",
|
||||
upload_url: "https://objects.test/signed",
|
||||
method: "PUT",
|
||||
required_headers: { "Content-Type": "image/png" },
|
||||
expires_at: "2026-01-01T00:10:00.000Z",
|
||||
completion_token: "uct_1",
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
json({
|
||||
asset: {
|
||||
id: "as_1",
|
||||
public_id: "pub_1",
|
||||
canonical_url: "https://stuffbox.test/f/pub_1",
|
||||
original_filename: "image.png",
|
||||
mime_type: "image/png",
|
||||
byte_size: 12,
|
||||
status: "active",
|
||||
created_at: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const client = new StuffboxClient({
|
||||
baseUrl: "https://stuffbox.test",
|
||||
fetch: fetcher,
|
||||
accessToken: "at_1",
|
||||
});
|
||||
|
||||
const session = await client.createUpload({
|
||||
filename: "image.png",
|
||||
mimeType: "image/png",
|
||||
size: 12,
|
||||
});
|
||||
const asset = await client.completeUpload(session.id, {
|
||||
completionToken: session.completionToken,
|
||||
});
|
||||
|
||||
expect(session.requiredHeaders).toEqual({ "Content-Type": "image/png" });
|
||||
expect(asset.url).toBe("https://stuffbox.test/f/pub_1");
|
||||
expect(JSON.parse(String(fetcher.mock.calls[1][1]?.body))).toEqual({
|
||||
completion_token: "uct_1",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws typed structured errors with retry metadata", async () => {
|
||||
const fetcher: FetchLike = async () =>
|
||||
json(
|
||||
{
|
||||
error: {
|
||||
code: "quota_exceeded",
|
||||
message: "Storage quota exceeded",
|
||||
details: { remaining_bytes: 4 },
|
||||
request_id: "req_1",
|
||||
},
|
||||
},
|
||||
{ status: 409, headers: { "retry-after": "12" } },
|
||||
);
|
||||
const client = new StuffboxClient({
|
||||
baseUrl: "https://stuffbox.test",
|
||||
fetch: fetcher,
|
||||
accessToken: "at_1",
|
||||
});
|
||||
|
||||
const error = await client
|
||||
.createUpload({ filename: "large.mp4", mimeType: "video/mp4", size: 100 })
|
||||
.catch((cause: unknown) => cause);
|
||||
expect(error).toBeInstanceOf(StuffboxError);
|
||||
expect(error).toMatchObject({
|
||||
code: "quota_exceeded",
|
||||
status: 409,
|
||||
requestId: "req_1",
|
||||
retryAfter: 12,
|
||||
details: { remaining_bytes: 4 },
|
||||
});
|
||||
});
|
||||
|
||||
it("supports endpoint overrides and per-request bearer suppression", async () => {
|
||||
const fetcher = vi.fn<FetchLike>().mockResolvedValue(json({ items: [] }));
|
||||
const client = new StuffboxClient({
|
||||
baseUrl: "https://example.test/custom",
|
||||
fetch: fetcher,
|
||||
accessToken: "at_default",
|
||||
endpoints: { assets: "/assets" },
|
||||
});
|
||||
|
||||
await client.listAssets({}, { accessToken: null });
|
||||
expect(fetcher.mock.calls[0][0]).toBe("https://example.test/custom/assets");
|
||||
expect(fetcher.mock.calls[0][1]?.headers).not.toHaveProperty("Authorization");
|
||||
});
|
||||
|
||||
it("revokes opaque tokens without adding another bearer credential", async () => {
|
||||
const fetcher = vi
|
||||
.fn<FetchLike>()
|
||||
.mockResolvedValue(new Response(null, { status: 204 }));
|
||||
const client = new StuffboxClient({
|
||||
baseUrl: "https://stuffbox.test",
|
||||
fetch: fetcher,
|
||||
accessToken: "at_configured",
|
||||
});
|
||||
|
||||
await client.revokeToken({
|
||||
token: "rt_revoked",
|
||||
tokenTypeHint: "refresh_token",
|
||||
});
|
||||
|
||||
expect(fetcher.mock.calls[0][0]).toBe("https://stuffbox.test/api/v1/revoke");
|
||||
expect(fetcher.mock.calls[0][1]?.headers).not.toHaveProperty("Authorization");
|
||||
expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body))).toEqual({
|
||||
token: "rt_revoked",
|
||||
token_type_hint: "refresh_token",
|
||||
});
|
||||
});
|
||||
|
||||
it("gets and deletes an owner-scoped asset by encoded ID", async () => {
|
||||
const fetcher = vi
|
||||
.fn<FetchLike>()
|
||||
.mockResolvedValueOnce(
|
||||
json({
|
||||
asset: {
|
||||
id: "asset/one",
|
||||
public_asset_id: "pub_1",
|
||||
url: "https://stuffbox.test/f/pub_1",
|
||||
filename: "photo.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
byteSize: 20,
|
||||
status: "active",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
||||
const client = new StuffboxClient({
|
||||
baseUrl: "https://stuffbox.test",
|
||||
fetch: fetcher,
|
||||
accessToken: "at_1",
|
||||
});
|
||||
|
||||
await expect(client.getAsset("asset/one")).resolves.toMatchObject({
|
||||
id: "asset/one",
|
||||
publicId: "pub_1",
|
||||
});
|
||||
await client.deleteAsset("asset/one");
|
||||
|
||||
expect(fetcher.mock.calls[0][0]).toBe(
|
||||
"https://stuffbox.test/api/v1/assets/asset%2Fone",
|
||||
);
|
||||
expect(fetcher.mock.calls[1][1]?.method).toBe("DELETE");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createPkcePair,
|
||||
deriveCodeChallenge,
|
||||
generateCodeVerifier,
|
||||
generateState,
|
||||
isValidCodeVerifier,
|
||||
} from "../src";
|
||||
|
||||
describe("PKCE helpers", () => {
|
||||
it("derives the RFC 7636 S256 example", async () => {
|
||||
const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
|
||||
await expect(deriveCodeChallenge(verifier)).resolves.toBe(
|
||||
"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||
);
|
||||
});
|
||||
|
||||
it("generates valid verifier lengths", () => {
|
||||
for (const length of [43, 64, 128]) {
|
||||
const verifier = generateCodeVerifier(length);
|
||||
expect(verifier).toHaveLength(length);
|
||||
expect(isValidCodeVerifier(verifier)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid verifier lengths and characters", async () => {
|
||||
expect(() => generateCodeVerifier(42)).toThrow(RangeError);
|
||||
expect(isValidCodeVerifier("a".repeat(42))).toBe(false);
|
||||
await expect(deriveCodeChallenge(`${"a".repeat(42)}!`)).rejects.toThrow(
|
||||
"Invalid RFC 7636 code verifier",
|
||||
);
|
||||
});
|
||||
|
||||
it("creates an S256 pair and a high-entropy state", async () => {
|
||||
const pair = await createPkcePair();
|
||||
expect(pair.method).toBe("S256");
|
||||
expect(pair.challenge).toBe(await deriveCodeChallenge(pair.verifier));
|
||||
expect(generateState()).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
uploadFile,
|
||||
type UploadProgress,
|
||||
type UploadTransport,
|
||||
} from "../src/react/index.js";
|
||||
|
||||
class FakeUploadTarget {
|
||||
onprogress: ((event: ProgressEvent) => void) | null = null;
|
||||
}
|
||||
|
||||
class FakeXMLHttpRequest {
|
||||
static latest: FakeXMLHttpRequest | undefined;
|
||||
readonly upload = new FakeUploadTarget();
|
||||
readonly headers: Record<string, string> = {};
|
||||
status = 0;
|
||||
method = "";
|
||||
url = "";
|
||||
body: unknown;
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
onabort: (() => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
FakeXMLHttpRequest.latest = this;
|
||||
}
|
||||
|
||||
open(method: string, url: string): void {
|
||||
this.method = method;
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
setRequestHeader(name: string, value: string): void {
|
||||
this.headers[name.toLowerCase()] = value;
|
||||
}
|
||||
|
||||
send(body: unknown): void {
|
||||
this.body = body;
|
||||
const size = (body as { size?: number }).size ?? 0;
|
||||
this.upload.onprogress?.({ loaded: size } as ProgressEvent);
|
||||
this.status = 200;
|
||||
this.onload?.();
|
||||
}
|
||||
|
||||
abort(): void {
|
||||
this.onabort?.();
|
||||
}
|
||||
}
|
||||
|
||||
describe("direct React upload helper", () => {
|
||||
const original = globalThis.XMLHttpRequest;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.XMLHttpRequest = original;
|
||||
FakeXMLHttpRequest.latest = undefined;
|
||||
});
|
||||
|
||||
it("PUTs the file to object storage, reports progress, then completes", async () => {
|
||||
globalThis.XMLHttpRequest = FakeXMLHttpRequest as unknown as typeof XMLHttpRequest;
|
||||
const file = { name: "photo.png", type: "image/png", size: 4 } as File;
|
||||
const progress: UploadProgress[] = [];
|
||||
const transport: UploadTransport = {
|
||||
createUpload: vi.fn().mockResolvedValue({
|
||||
id: "upl_1",
|
||||
uploadUrl: "https://objects.example/signed-put",
|
||||
method: "PUT",
|
||||
requiredHeaders: { "content-type": "image/png", "if-none-match": "*" },
|
||||
expiresAt: "2026-07-15T00:10:00.000Z",
|
||||
}),
|
||||
completeUpload: vi.fn().mockResolvedValue({
|
||||
id: "ast_1",
|
||||
publicId: "file_1",
|
||||
url: "https://stuffbox.example/f/file_1",
|
||||
filename: "photo.png",
|
||||
mimeType: "image/png",
|
||||
byteSize: 4,
|
||||
status: "active",
|
||||
createdAt: "2026-07-15T00:00:00.000Z",
|
||||
}),
|
||||
};
|
||||
|
||||
const asset = await uploadFile(transport, file, {
|
||||
onProgress: (value) => progress.push(value),
|
||||
});
|
||||
|
||||
expect(asset.id).toBe("ast_1");
|
||||
expect(transport.createUpload).toHaveBeenCalledWith(
|
||||
{ filename: "photo.png", mimeType: "image/png", size: 4 },
|
||||
{},
|
||||
);
|
||||
expect(FakeXMLHttpRequest.latest).toMatchObject({
|
||||
method: "PUT",
|
||||
url: "https://objects.example/signed-put",
|
||||
body: file,
|
||||
headers: { "content-type": "image/png", "if-none-match": "*" },
|
||||
});
|
||||
expect(transport.completeUpload).toHaveBeenCalledWith("upl_1", undefined, {});
|
||||
expect(progress.map((value) => value.phase)).toEqual([
|
||||
"creating",
|
||||
"uploading",
|
||||
"uploading",
|
||||
"uploading",
|
||||
"completing",
|
||||
"complete",
|
||||
]);
|
||||
expect(progress.at(-1)?.percent).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user