@cubster/assets
v0.3.0
Published
Typed TypeScript SDK for the Cubster Assets API — keys, workspaces, uploads, grants, assets, and image/file URL builders, with an optional React subpath.
Maintainers
Readme
@cubster/assets
A typed, framework-agnostic TypeScript SDK for the Cubster Assets API — API keys, workspaces, uploads, client-upload grants, assets, and pure image/file URL builders. An optional @cubster/assets/react subpath adds a provider, hooks, and components.
The core (@cubster/assets) has zero React. React only enters when you import @cubster/assets/react.
Install
npm install @cubster/assetsreact / react-dom are optional peer dependencies — you only need them if you use the /react subpath.
Core usage
Node / server (API key)
Authenticate with a csk_... key. It is sent as Authorization: Bearer <key> and is bound to a single workspace, which scopes every request.
import { createCubsterClient } from "@cubster/assets";
const cubster = createCubsterClient({ apiKey: process.env.CUBSTER_API_KEY });
// Upload a file (Node 18+ has File/Blob globals)
const buffer = await readFile("./photo.png");
const asset = await cubster.uploads.upload({
file: new Blob([buffer], { type: "image/png" }),
filename: "photo.png",
name: "Marketing hero",
tags: ["hero", "landing"],
});
// List + filter
const images = await cubster.assets.list({ type: "image", tags: ["hero"], limit: 20 });
// Update / delete
await cubster.assets.update(asset.id, { displayName: "Hero (v2)" });
await cubster.assets.delete(asset.id);The API key is only ever placed on the
Authorizationheader — the SDK never logs it. Keep it in an environment variable, never in client-side code.
Browser / dashboard (session cookie)
Omit apiKey and the client falls back to the first-party Netlify Identity session: requests are sent with credentials: "include" so the nf_jwt cookie rides along. A same-origin dashboard passes baseUrl: "".
import { createCubsterClient } from "@cubster/assets";
// Same-origin: relative requests, session-cookie auth.
const cubster = createCubsterClient({ baseUrl: "" });
const workspaces = await cubster.workspaces.list();
const keys = await cubster.apiKeys.list();
const created = await cubster.apiKeys.create({ label: "CI" });
console.log(created.key); // the full secret — shown exactly onceAPI key services
A key is scoped to one or more services — today "assets" (uploads/assets/serve URLs) and "transcription" — chosen at creation. apiKeys.create({ services: ["assets"] }) mints a key limited to just that service; omit services and the key gets all of them. ApiKey.services (list + create responses) and client.me.get()'s key-auth services field both report a key's current scope. Read the full registry (id, label, description, routes) with client.services.list():
const services = await cubster.services.list();
// [{ id: "assets", label: "Assets", description: "...", routes: [...] }, ...]A request outside a key's scope 403s; read err.serviceDenied() (or the standalone getServiceDenied(err)) to get { service, enabled } without hand-parsing err.body. A key can't be re-scoped after creation — revoke and recreate it instead.
Client-upload grants (third-party browser upload)
A server holding a key mints a short-lived grant; a third-party browser uploads one file directly with only that grant (no key, no cookies).
// On your server (has the key):
const grant = await cubster.uploads.createGrant({
maxSize: 5_000_000,
mimeTypes: ["image/png", "image/jpeg"],
ttlSeconds: 300,
});
// → send `grant` to the browser
// In the third-party browser (no key):
import { createCubsterClient } from "@cubster/assets";
const anon = createCubsterClient();
const asset = await anon.uploads.uploadWithGrant({ grant, file /* a File */ });URL builders (pure)
No client needed — these mirror the API's serve-URL cascade exactly.
import { imageUrl, fileUrl, assetUrl } from "@cubster/assets";
imageUrl("key.png", { preset: "thumb" }); // /img/thumb/key.png
imageUrl("key.png", { width: 600, height: 400, fit: "cover" }); // /img/key.png/600/400/cover
fileUrl("doc.pdf"); // /f/doc.pdf
// Pick the right serve URL for an asset (image → /img, file/SVG → /f):
assetUrl(asset, { preset: "hero", baseUrl: "https://app.cubster.dev" });Presets: thumb (150×150), avatar (256×256), hero (1200×675), small (w=300), medium (w=600), large (w=1200). The positional cascade is contiguous — /img/:key/:width/:height/:fit/:quality/:format — so you can't skip an earlier segment. SVGs and generic files are always served as forced downloads from /f/:key.
Errors
Every non-2xx response throws a typed CubsterError carrying the HTTP status and the API's { error } message.
import { CubsterError } from "@cubster/assets";
try {
await cubster.assets.get("missing");
} catch (err) {
if (err instanceof CubsterError && err.status === 404) {
// handle not-found
}
}Transcription
transcribe() uploads an audio file and returns a durable, speaker-labelled transcript — the audio is kept as an asset (kind: "audio", served inline from /audio/:key), and the transcript (text, segments, words) lives alongside it. Cap: 4 MB per file (Netlify's synchronous-function body limit).
const transcript = await cubster.transcribe({
file: new Blob([buffer], { type: "audio/mp4" }),
filename: "standup.m4a",
name: "Daily standup",
language: "en", // BCP-47 tag; default "en"
diarize: true, // default true
});
transcript.status; // "completed"
transcript.speakerCount; // 2
transcript.segments; // [{ speaker: 0, start, end, text, confidence }, ...]
transcript.words; // per-word timing (detail responses only)List/get/delete/retry live under cubster.transcripts:
const transcripts = await cubster.transcripts.list({ status: "failed", limit: 20 });
// list items omit text/segments/words and carry a `preview` (first 200 chars) instead
const full = await cubster.transcripts.get(transcript.id, { workspace: "team-slug" }); // includes words; session auth targets a workspace, keys are workspace-bound and ignore it
await cubster.transcripts.delete(transcript.id); // also deletes the underlying audio assetFailure + retry: the audio is never lost on a provider failure. transcribe() still stores the asset and inserts a failed/pending transcript row, then throws a CubsterError. Its body carries { error, transcript: { id, status, assetId, retryUrl } } — use err.transcriptFailure() (or the standalone getTranscriptFailure(err)) to read it without hand-parsing err.body:
import { CubsterError } from "@cubster/assets";
try {
await cubster.transcribe({ file, filename: "clip.m4a" });
} catch (err) {
if (err instanceof CubsterError) {
const failure = err.transcriptFailure(); // { id, status, assetId, retryUrl } | null
if (failure) {
const retried = await cubster.transcripts.retry(failure.id);
}
}
}A completed transcript can't be retried — transcripts.retry 409s in that case.
Realtime sessions
client.realtime.createSession() mints a short-lived (60s) connect token for a realtime voice session — a key-only call, and the key must include the realtime service. Hand { endpoint, token } straight to the broker that will open the WebSocket; Cubster never touches the audio itself.
const session = await cubster.realtime.createSession({ name: "Standup check-in" });
// { sessionId, endpoint, token, expiresAt }
// connect: WebSocket(session.endpoint, ...) with `Authorization: Bearer ${session.token}`
// on the upgrade request — see the broker's own docs for the wire protocol.When the session ends, the broker posts the conversation transcript back to Cubster. It shows up like any other transcript (transcripts.list/get), but with no audio asset: assetId: null, source: { kind: "live", filename: null, contentType: null, size: null, durationSeconds, url: null }, and realtimeSessionId set to the session's id. TranscriptDetail-style UI should gate an <audio> player on source.url being non-null rather than assuming kind === "upload".
Diagrams
client.diagrams.* publishes agent-authored diagrams — bare SVG fragments drawn against the Cubster class vocabulary (cub-node, cub-edge, tones, etc.) — as versioned, workspace-scoped documents. Always preview() before you create(): it validates and rasterizes the same fragment without publishing anything, so you can look at the PNG and fix problems before they're public.
// 1. Draw, then look before you publish — writes nothing, even when valid.
const check = await cubster.diagrams.preview({ svg: mySvgFragment });
if (!check.valid) {
console.error(check.errors); // [{ code, message, element? }, ...]
} else {
await writeFile("preview.png", Buffer.from(check.png!, "base64"));
}// 2. Publish — starts private (see the note below).
const diagram = await cubster.diagrams.create({
title: "Auth flow",
svg: mySvgFragment,
kind: "flow",
tags: ["auth"],
});
diagram.url; // https://app.cubster.dev/d/<slug> — 404s until it's shared// List / fetch
const diagrams = await cubster.diagrams.list({ kind: "flow", includeArchived: false });
const latest = await cubster.diagrams.get(diagram.slug);
const v1 = await cubster.diagrams.get(diagram.slug, { version: 1 });// Append a new version (an update IS a new version — svg is required)
const updated = await cubster.diagrams.update(diagram.slug, { svg: revisedSvgFragment });
// Archive is a reversible hide, never a delete.
await cubster.diagrams.archive(diagram.slug);
await cubster.diagrams.unarchive(diagram.slug);// A semantic node/edge graph in, dagre-computed coordinates out. Writes nothing.
const layout = await cubster.diagrams.layout({
direction: "LR",
nodes: [{ id: "a", label: "Request" }, { id: "b", label: "Response" }],
edges: [{ from: "a", to: "b", label: "200 OK" }],
});
// The full class vocabulary (classes, modifiers, typography, grid, tones, markers) as JSON.
const vocab = await cubster.diagrams.vocabulary();Visibility is dashboard-only. A diagram always starts
visibility: "private"; there is no field, param, or flag anywhere in this SDK to change it — flip it tounlisted/publicfrom the Cubster dashboard.url/svgUrl/pngUrlare always present on aDiagram/DiagramSummary, but they 404 while private.There is no delete.
archive/unarchiveare the only lifecycle mutations besides create/update — a hotlinked diagram (a GitHub README, say) should never 404 because someone tidied up.
React usage (@cubster/assets/react)
Wrap your app in a CubsterProvider with a client, then use the hooks and components.
import { createCubsterClient } from "@cubster/assets";
import {
CubsterProvider,
useAssets,
useUpload,
AssetImage,
Uploader,
Dropzone,
} from "@cubster/assets/react";
const client = createCubsterClient({ baseUrl: "" }); // dashboard: same-origin session
function App() {
return (
<CubsterProvider client={client}>
<Gallery />
</CubsterProvider>
);
}
function Gallery() {
const { assets, loading, error, refetch } = useAssets({ type: "image" });
if (error) return <p role="alert">{error.message}</p>;
return (
<>
<Uploader accept="image/*" onUpload={refetch} />
<Dropzone accept="image/*" onUpload={refetch}>Drop images here</Dropzone>
{loading || !assets ? (
<p>Loading…</p>
) : (
<ul>
{assets.map((asset) => (
<li key={asset.id}>
<AssetImage asset={asset} preset="thumb" />
</li>
))}
</ul>
)}
</>
);
}useAssets(filters)—{ assets, loading, error, refetch }.assetsisnulluntil the first load resolves, so you can render a skeleton immediately.useUpload()—{ upload, uploading, error, asset, reset }.upload(input)resolves with the created asset and records it on the hook state.<AssetImage asset|storageKey preset|transform … />— renders an<img>off the URL builders, defaultsaltfrom the asset, emits a responsivesrcsetfor the default image case, and renders an optionalfallbackif the image fails to load.<Uploader>/<Dropzone>— a file input / drag-drop target that upload via the client, with real loading + error states andonUpload(asset)/onError(err)callbacks. They ship no design-system styles — aclassNameanddata-*attributes are yours to target.
API surface
| Namespace | Methods |
| --- | --- |
| apiKeys | create, list, revoke |
| services | list() |
| workspaces | list, create |
| uploads | upload, createGrant, uploadWithGrant |
| assets | list, get, update, delete |
| transcribe | transcribe(input) |
| transcripts | list, get, delete, retry |
| realtime | createSession(params?) |
| diagrams | create, list, get(slug, params?), update(slug, params), archive, unarchive, preview, layout, vocabulary |
| URL builders | imageUrl, fileUrl, rawImageUrl, assetUrl, imageCascadePath, imageSrcSet, assetSrcSet, assetStorageKey |
Limits (enforced by the API): max file size 25 MB; per-workspace storage cap 1 GB; image types png/jpeg/webp/gif/avif/svg+xml; file types pdf, text/plain, csv, markdown, json, zip; audio for transcription (mp3, m4a, aac, wav, webm, ogg, flac) capped at 4 MB per file.
License
MIT
