/** * Stillmore Group API client, v1. One file, no dependencies: Node 18+, Deno, * Bun or an edge runtime. Keep keys on a server, never in a browser. * * import { StillmoreApi, verifyWebhook } from "./stillmore-api"; * const api = new StillmoreApi({ apiKey: process.env.STILLMORE_API_KEY! }); * const me = await api.get("/me"); * for await (const release of api.list("/releases", { status: "LIVE" })) console.log(release.title); * const release = await api.post("/releases", { title: "Night Signals", primaryArtistIds: [artistId] }); * * Typed objects: generate them from https://api.stillmoregroup.com/api/v1/openapi.json * with openapi-typescript and pass them as the type parameters. * * Writes carry an Idempotency-Key (a fresh UUID unless you pass one), kept * across retries, so a retried POST never does the work twice. 429 and 503 * are retried after Retry-After; network failures and 5xx are retried for * reads and keyed writes. */ export interface StillmoreApiOptions { /** sm_live_…, sm_test_… (sandbox) or an OAuth access token (sm_oat_…). */ apiKey: string; baseUrl?: string; /** A sub-label to act for (Stillmore-Organization), when the key may. */ organization?: string; /** Attempts after the first one, 3 by default. */ maxRetries?: number; fetch?: typeof fetch; } export interface RequestOptions { query?: Record; body?: unknown; idempotencyKey?: string; /** The revision you read, for PATCH: an older one answers 412. */ ifMatch?: string; } export interface Page { data: T[]; hasMore: boolean; nextCursor: string | null; } export class StillmoreApiError extends Error { constructor( readonly status: number, readonly type: string, readonly code: string, message: string, readonly requestId: string | null, readonly details?: unknown ) { super(message); this.name = "StillmoreApiError"; } } const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); export class StillmoreApi { private readonly baseUrl: string; private readonly maxRetries: number; private readonly fetcher: typeof fetch; constructor(private readonly options: StillmoreApiOptions) { if (!options.apiKey) throw new Error("Pass apiKey: a key from the API tab of your workspace."); this.baseUrl = (options.baseUrl ?? "https://api.stillmoregroup.com/api/v1").replace(/\/+$/, ""); this.maxRetries = options.maxRetries ?? 3; this.fetcher = options.fetch ?? fetch; } /** The same client acting for one of the key's sub-labels. */ forOrganization(organizationId: string): StillmoreApi { return new StillmoreApi({ ...this.options, organization: organizationId }); } /** The whole answer: `{ data }`, or `{ data, hasMore, nextCursor }` for lists. */ async request(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", path: string, options: RequestOptions = {}): Promise { const url = new URL(`${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`); for (const [name, value] of Object.entries(options.query ?? {})) if (value !== undefined) url.searchParams.set(name, String(value)); const writes = method !== "GET"; const idempotencyKey = options.idempotencyKey ?? (method === "POST" || method === "PUT" || method === "PATCH" ? crypto.randomUUID() : undefined); const headers: Record = { Authorization: `Bearer ${this.options.apiKey}`, Accept: "application/json" }; if (options.body !== undefined) headers["Content-Type"] = "application/json"; if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey; if (options.ifMatch) headers["If-Match"] = options.ifMatch; if (this.options.organization) headers["Stillmore-Organization"] = this.options.organization; const retriable = !writes || Boolean(idempotencyKey); for (let attempt = 0; ; attempt++) { let response: Response; try { response = await this.fetcher(url, { method, headers, ...(options.body !== undefined ? { body: JSON.stringify(options.body) } : {}) }); } catch (error) { if (!retriable || attempt >= this.maxRetries) throw error; await wait(500 * 2 ** attempt); continue; } if (response.ok) { if ((response.headers.get("content-type") ?? "").includes("text/csv")) return (await response.text()) as T; return (await response.json()) as T; } const retryAfter = Number(response.headers.get("retry-after")); const again = attempt < this.maxRetries && (response.status === 429 || response.status === 503 || (retriable && response.status >= 500)); if (again) { await wait(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 500 * 2 ** attempt); continue; } const body = (await response.json().catch(() => null)) as { error?: { type?: string; code?: string; message?: string; details?: unknown; requestId?: string } } | null; const error = body?.error; throw new StillmoreApiError(response.status, error?.type ?? "api_error", error?.code ?? "UNKNOWN", error?.message ?? response.statusText, error?.requestId ?? response.headers.get("x-request-id"), error?.details); } } async get(path: string, query?: RequestOptions["query"]): Promise { return (await this.request<{ data: T }>("GET", path, query ? { query } : {})).data; } async post(path: string, body: unknown = {}, options: Omit = {}): Promise { return (await this.request<{ data: T }>("POST", path, { ...options, body })).data; } async put(path: string, body: unknown = {}, options: Omit = {}): Promise { return (await this.request<{ data: T }>("PUT", path, { ...options, body })).data; } async patch(path: string, body: unknown, options: Omit = {}): Promise { return (await this.request<{ data: T }>("PATCH", path, { ...options, body })).data; } async delete(path: string): Promise { return (await this.request<{ data: T }>("DELETE", path)).data; } /** Every item of a list, page after page. */ async *list(path: string, query: RequestOptions["query"] = {}): AsyncGenerator { let cursor: string | undefined; do { const page = await this.request>("GET", path, { query: { limit: 100, ...query, ...(cursor ? { cursor } : {}) } }); yield* page.data; cursor = page.hasMore && page.nextCursor ? page.nextCursor : undefined; } while (cursor); } } /** * Checks a webhook: the X-Stillmore-Signature header (`t=…,v1=…[,v1=…]`) * against the raw body and your endpoint's secret, and that it is recent. * During a secret rotation two v1 signatures come; either one passes. */ export async function verifyWebhook(rawBody: string, signatureHeader: string | null | undefined, secret: string, toleranceSeconds = 300): Promise { if (!signatureHeader) return false; const parts = signatureHeader.split(",").map((part) => part.trim().split("=")); const timestamp = Number(parts.find(([name]) => name === "t")?.[1]); const signatures = parts.filter(([name]) => name === "v1").map(([, value]) => value ?? ""); if (!Number.isFinite(timestamp) || !signatures.length) return false; if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false; const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${timestamp}.${rawBody}`))); const expected = Array.from(mac, (byte) => byte.toString(16).padStart(2, "0")).join(""); return signatures.some((signature) => { if (signature.length !== expected.length) return false; let difference = 0; for (let index = 0; index < expected.length; index++) difference |= expected.charCodeAt(index) ^ signature.charCodeAt(index); return difference === 0; }); }