@digo-labs/services
The browser client for the Digo platform: auth, device keys, the backend API, database tables, file storage, websocket channels, AI generation and AI jobs.
How to read this. This guide ships with the package, so it matches the version you have installed; the site describes the latest release. The types in node_modules/@digo-labs/services/dist/*.d.ts are exact and win if they disagree with it. Deeper pages: https://design.digo-labs.com/docs/guide/package-services.
Setup
Don't initialise this package by hand. defineApp from @digo-labs/app registers the app name, calls AuthHelper.init (with auth) and ApiHelper.init (with backend: true), then runs your services factory, where you construct the classes below (...databases, new Storage(), new Websockets<Channels>(), new Ai()).
Auth: useAuth, useGoogleSignIn, AuthHelper
import { useAuth } from '@digo-labs/services';
const { user, session, device, instance, isReadOnly, tier, globalTier, isLoading, isOffline, isAuthenticated, signIn, signOut } = useAuth();
await signIn.social({ provider: 'google', callbackURL: window.location.href });
tier is 'public' | 'user' | 'org' | 'admin' in the selected instance: an enrolled device's tier wins; otherwise org is a verified @productionclub.net email, and admin the admin role or an admin of that instance. instance is the selected instance's slug and globalTier ignores instance roles: gate instance management, budget limits, text, proxies and DMX on it. isReadOnly is true while the instance is archived (an archive revokes the instance's device keys, so devices get 401). isAuthenticated is true for a user or a device. AuthHelper.auth is the raw Better Auth client (admin plugin included).
For a Google sign-in button, useGoogleSignIn({ callbackPath }) (default '/') returns { signIn, isSigningIn, error }: signIn() redirects to Google and back to callbackPath on the current origin, and resolves to a Result<void> tuple; isSigningIn is true until the browser leaves or the request fails; error holds the failure.
import { useGoogleSignIn } from '@digo-labs/services';
const { signIn, isSigningIn } = useGoogleSignIn({ callbackPath: from });
async function handleSignIn() {
const [, error] = await signIn();
if (error) {
console.error(error);
setMessage('Sign in failed. Please try again.');
}
}
<button onClick={handleSignIn} disabled={isSigningIn}>Continue with Google</button>
Device keys: Devices, useDevice, DeviceHelper
A device key lets a kiosk act at a tier with nobody signed in. It belongs to the instance it was created in, lives in localStorage and is sent on every API call; a loading device switches its browser to its key's instance.
const devices = new Devices();
await devices.enrol({ name: 'Booth 1', tier: 'org', expiresInDays: 30 }); // create and store the key here, then sign this browser out
await devices.create({ name: 'Booth 2' }); // { device, key }, key not stored
await devices.list(); // DeviceRecord[]
await devices.revoke(id); // boolean
await devices.regenerate(id); // { device, key }: old key revoked, new one revealable
devices.forget(); // drop this device's key
await devices.create({ name: 'Booth 3', isRevealable: true }); // keep an encrypted copy
await devices.reveal(id); // the key again (admin, while revealable)
await devices.lock(id); // boolean: drop the copy for good, key keeps working
await devices.lockAll(); // number of keys locked in this instance
tier is 'user' | 'org' (default 'org'), expiresInDays up to 365 (default 30) or null for never. DeviceRecord.isRevealable says whether a key still has its copy. useDevice() returns { device, isLoading, isOffline }: only a 401 drops the stored key, while any other failure (network error, timeout, another 4xx, a 5xx) sets isOffline, keeps device as the record the backend last confirmed (kept in localStorage) and retries by itself; DeviceHelper.setKey(key) / clearKey() manage the stored key.
Database: Database<T>
One instance per table. Typed instances come from defineDatabases (@digo-labs/app/database); construct one directly for shared tables: new Database<StyleRecord>('styles') (second argument: primary key, default 'id').
await services.items.getAll(50, 0); // T[] (limit?, offset?), in primary-key order
await services.items.get(id); // T | null
await services.items.getBy('name', 'Hello'); // T[] (column, value, limit?, offset?)
await services.items.readAll(); // T[] every row, paged by primary key
await services.items.readAllBy('name', 'Hello'); // T[] every matching row
await services.items.create({ name: 'Hello' }); // T
await services.items.update(id, { name: 'Hi' }); // boolean
await services.items.update(id, { status: 'printing' }, { status: 'queued' }); // only if it still matches (needs read too)
await services.items.delete(id); // boolean
To read a whole table use readAll/readAllBy, not a loop over offset: they page after the last primary key read, so every row that exists from start to end is read exactly once, even while others are changed, added or removed (single-column primary key only; other tables get a 400).
Access is per table, declared in src/db.ts and synced by digo-db push; a table without a rule is denied. The client may not send id (give it a database default) or the table's owner column (the backend fills it from the session).
Instances: Instances, useInstance, InstanceHelper
Isolated installations of one app (schema, files, signed-in channels, device keys, AI budget each; open channels only get the instance in their name). main always exists; requests carry the instance in X-Digo-Instance: the page's host (jon.<app>.digo-labs.com is jon), else the browser's selection, else main. An unknown stored selection is dropped and the page reloads in main, signed in or not.
const instances = new Instances();
await instances.list(); // main, then all for a global admin or the ones you administer
await instances.current(); // { instance, tier, isReadOnly }
instances.select('jon'); // store for this browser and reload
instances.getSelected(); // 'jon'
await instances.create({ slug: 'jon', name: 'Jon' }); // global admins only, like the calls below
await instances.rename('main', 'Las Vegas');
await instances.archive('jon'); // read-only, device keys revoked
await instances.reopen('jon');
await instances.setAdmins('jon', ['[email protected]']); // replaces the list; admins of that instance only, never main
await instances.getAdmins('jon');
useInstance({ userId }) returns { instance, access, isLoading } (access is fetched only outside main, retried on network errors, and an unknown stored instance is dropped); useAuth() already uses it.
Storage: Storage
Files in the app's S3 bucket via presigned links. new Storage() uses <app-name>-digo-labs-storage; pass a name for a shared bucket (new Storage('common-styles-digo-labs-storage')).
const { path, url } = await storage.uploadFile(`sketches/${id}.png`, file); // File | Blob | ArrayBuffer, contentType?
await storage.uploadImage(path, { base64, mediaType: 'image/png' });
await storage.uploadFromUrl(path, remoteUrl, 'image/png');
storage.getUrl(path); // sync link through the files route
await storage.getSignedUrl(path, { expiresIn: 300 }); // short-lived link for private prefixes
await storage.download(path); // Blob
await storage.list('sketches/', { limit: 100 }); // StorageObject[]
await storage.delete([path]); // deleted paths
storage.getPathFromUrl(url); // string | null
Store paths in tables, not links. Per-prefix rules come from defineStorage in src/db.ts. Paths are relative to the selected instance: in jon they live under _instances/jon/, which getUrl, getPathFromUrl and list add and remove for you.
Websockets: Websockets<TMap>
Typed pub/sub on the websocket hub; TMap maps channel names to payloads. It connects in the constructor and reconnects by itself.
interface Channels { cursor: { x: number; y: number; }; }
const ws = new Websockets<Channels>({ auth: true });
ws.subscribe('cursor', data => move(data.x, data.y));
ws.send('cursor', { x: 1, y: 2 }); // to current subscribers
ws.setState('cursor', { x: 1, y: 2 }); // kept on the hub, replayed to new subscribers
ws.unsubscribe('cursor');
ws.onReady(() => refresh()); // also onOpen, onClose, onError, offReady, isLive, close
Options: auth, url (default wss://ws.digo-labs.com), app, id, reconnect, reconnectInterval, maxReconnectInterval, getToken.
- Open (default): channels go out as
<app>:<channel>(<app>__<instance>:<channel>outsidemain) unless they contain:or start with_. A naming convention; anyone can read them. - Signed in (
auth: true): a backend token (session or device key) is presented; names go as written and are checked against the app'sdefineChannelsrules.
AI: Ai
const ai = new Ai();
const stream = ai.text.generate({ messages: [{ role: 'user', content: 'Hi' }], systemPrompt, maxTokens: 400 });
for await (const { text } of stream) append(text);
const { text, stopReason, isBlocked } = await stream.collect();
const image = await ai.image.generate({ prompt, aspectRatio: '16:9' }, { persist: 'generations/a.jpg' });
await ai.image.edit({ prompt, image: url, strength: 0.6 });
await ai.image.upscale({ image: url, factor: 2 });
await ai.image.removeBackground({ image: url });
await ai.video.generate({ prompt, aspectRatio: '9:16', durationSeconds: 5 });
await ai.audio.speech({ text, voice: 'af_heart' });
await ai.audio.music({ prompt, durationSeconds: 30 });
await ai.audio.transcribe({ audio: url, language: 'en' });
A text stream that fails midway throws TextStreamError with a reason (busy, model_unavailable, provider_down, invalid_request or unknown). Text runs on Bedrock; media on fal. Media options are { persist?, signal?, onProgress? } (transcribe and text.generate take { signal } only). Results are { url, contentType?, width?, height?, seed?, durationSeconds?, model? }; without persist, url is the provider's temporary link, with it the file is copied to your bucket and url is storage.getUrl(persist). Raw providers: ai.bedrock.converseStream(body, signal?), ai.fal (subscribe, run, stream, queue, …) and ai.replicate (the replicate SDK), also exported as Bedrock, Fal, Replicate.
AI jobs: ai.jobs
Image pipelines run by the backend: input checks, tasks, outputs saved to your bucket, output checks. Reloads lose nothing.
const job = await services.ai.jobs.create({
key: `guest-${guestId}`, // idempotent per app instance
inputs: { sketch: `sketches/${guestId}.png` },
checks: [{ type: 'rekognition', input: 'sketch' }],
tasks: [{
key: 'traditional',
endpoint: 'fal:openai/gpt-image-2.5/flare/edit', // AI_JOB_ENDPOINTS in @digo-labs/common
input: { prompt, image_urls: [{ $input: 'sketch' }] },
output: { path: `designs/${guestId}/traditional.png` }, // under a write: 'server' prefix
}],
outputChecks: [{ type: 'rekognition' }],
});
const stop = services.ai.jobs.watch(job.id, current => setTasks(current.tasks));
Also add(id, tasks), check(id, taskKey, checks), cancel(id), get(id), and list(filters): the instance's jobs a page at a time, oldest first, in the shape get returns; filters from / to (ISO 8601 on creation time, to excluded), statuses, limit (100 by default, 200 at most), and the page's next, passed back as cursor until it is null. onReview is 'reject' (default) or 'continue'. create takes no tasks when the job only checks its inputs for now; add then works while the job is checking, and its tasks wait for the checks. A check is { type: 'rekognition', input?, reject?, review?, minConfidence?, timeoutMs? } or { type: 'bedrock', prompt, input?, guardrail?, model?, provider?, timeoutMs? }, where provider is 'bedrock' (default) or 'anthropic' (no guardrail there) and timeoutMs is 3 to 25 s (25 by default). watch waits on the backend for each change (get(id, { since, signal }) does the same by hand), calls back only on change and stops once everything settled. endpoint: 'fake:echo' costs nothing while building. A task's fallbacks (up to two { endpoint, input? }) take over in order when its provider fails, and fallbackAfter (ms) also starts the next one when the current is slow; servedBy names the endpoint whose image was saved. Any check takes up to two fallbacks (or one fallback, not both): Claude reviews tried in order, each only when the one before can't run or times out.
AI budgets: ai.budgets
Each instance's limits for jobs, kept on the backend. null means no limit.
const { budget, usage } = await services.ai.budgets.get();
await services.ai.budgets.update({ maxInFlight: null, dailyUsd: 200 }); // only the fields sent change
budget is { maxTasksPerJob, maxInFlight, hourlyUsd, dailyUsd, isPaused } (defaults 30, 8, 10, 50, false); usage is { hourlyUsd, dailyUsd, inFlight }, the estimated spend in the last hour and day and the requests at the providers now. Both calls act on the caller's own app and instance; changing a limit needs a global admin, while an instance admin may only pause and resume (isPaused). A cap a job request exceeds answers 429.
Lower level and CLI
ApiHelper.clientis the typed Hono client for every backend route;ApiHelper.unwrap<T>(promise)returns the JSON or throws anErrorwith the backend's message.new Dmx().send(channel, value)sets a DMX channel (admin only).Realtimeis an empty placeholder; useWebsockets.- The
digo-dbbinary (npm run db:pushin apps):pushcreates the schema, runsdrizzle-kit push, syncs theaccess,storageandchannelsexports ofsrc/db.tsand pushes every instance schema of the app; alsodrop,verify,refresh,schema-name. NeedsDATABASE_URL_MONOREPO(andADMIN_SECRETfor the refresh) in the environment, which the app-template'sscripts/with-monorepo-secrets.shreads live from AWS Secrets Manager (npm run db:pushrunspushthrough it; run the other subcommands through it too), anddrizzle-kitinstalled.
Gotchas
- Construct services inside the
servicesfactory.StorageandWebsocketsneed the app namedefineAppregisters and throw without it;new Ai()builds aStorage, so it throws too. useAuthneedsauthindefineApp, orAuthHelper.auththrows. One auth project per page.backendURLdoesn't reach fal or Replicate: their proxies always usehttps://api.digo-labs.com.- Tiers the backend enforces: text, fal/Replicate, all
ai.jobscalls andai.budgets.getneedorg;ai.budgets.updateand device create/list/revoke needadmin,instances.lista sign-in (instances.currentis open), and instance management a global admin. An instance admin isadminonly on that instance's own data, files, channels, devices and jobs; text, fal/Replicate and DMX check their global tier. An archived instance answers writes with423.401when signed out,403when the tier is too low. - Shared table names win. The backend resolves
commontables (such asstyles) before the app's, so an app table with the same name is unreachable. getUrlonly loads under aread: 'public'prefix (else404); usegetSignedUrl.getPublicUrlbuilds a raw S3 address that doesn't load: buckets are private.- Fixed AI values throw otherwise: image
aspectRatio1:1,16:9,9:16,4:3,3:4; video16:9,9:16,1:1, duration snapped to 5 or 10 s;voicemust be a Kokoro voice (af_heart,am_adam, …). - Websocket
send/setStatewhile disconnected are dropped, not queued. Re-read state inonReady.