Skip to content

Backend API reference

Stateless token + room-discovery service for Petal. It exists for one reason: mint scoped LiveKit JWTs server-side so the LiveKit API secret never ships inside a client (the P0 blocker, internal/ISSUES.md). It also provides cross-machine room discovery.

Minimal-backend design (product directive): the only infrastructure is LiveKit + this lean Vercel function — no database. Rooms live entirely on LiveKit; discovery reads LiveKit’s active-room list and the human room name rides in LiveKit room metadata. The only env vars are the three LiveKit creds. Favorites/recents and logs/stats stay local on the client.

Method Path Body Returns
POST /api/token { room, identity, displayName? } { url, token, room, displayName? }
GET /api/rooms { rooms: [{ id, name, open, occupancy }] }
POST /api/rooms { name, open?, room? } { room } (generates or stamps a credential)
POST /api/admin { action: "kick" | "close", room, identity? } { ok, action, room }
GET /api/updater (alias /latest.json) the Tauri updater manifest, verbatim from Blob
GET /api/download 302 to the current signed DMG’s Blob URL
GET / 302 redirect to the marketing site (petal.live)

room in the token request is the internal credential room-<32 lowercase hex chars>, derived client-side from the user-facing abc-defg-hjk access code. Bare human names like eng-sync, old public <label>-<32hex> credentials, and display labels are rejected and do not mint tokens. The returned room is the LiveKit room name (petal-room-<credential>), and displayName is the human room name read from LiveKit metadata when it is available. Public room discovery never returns the credential, derived LiveKit room name, or participant identities; its id is an opaque stable display/matching id and occupancy is the live participant count. The create response returns the short access code to the creator. Native clients that already generated a credential pass it as room so the backend stamps that LiveKit room’s metadata instead of creating a separate generated room. For those native credential stamps, open is only an initial value; if server metadata already exists, the backend preserves its open flag and refreshes only the display label.

Public token requests always mint visible participant tokens with the fixed Petal participant profile: publish, subscribe, data publish, and own-metadata updates enabled; hidden is always false. Caller-supplied grant fields are ignored for compatibility and must not be used for hidden gallery/bridge participants. Hidden subscribe-only tokens require a trusted server-owned path bound to an already-visible participant.

Token identity must be a generated participant id, not a human name. Accepted forms are the native UUID participant id, the web harness web-<uuid> id, and the legacy p-...-... generated fallback. Human-readable names belong in displayName; the backend rejects values such as alice or Jane Doe as LiveKit identities so callers cannot trivially mint a token whose authenticated subject is a teammate’s name.

/api/admin is the server-side revocation primitive for operators and future trusted app flows. It requires Authorization: Bearer <PETAL_ADMIN_TOKEN> and never accepts a room credential alone as admin authority. action: "kick" removes one LiveKit participant from the derived room; action: "close" deletes the LiveKit room. This is not user authentication: invite credentials are still bearer capabilities until Petal grows a real account/device-attestation layer.

The backend accepts native/server requests without an Origin header. Browser callers must come from the configured allowlist:

  • default: https://app.petal.live, https://meet.petal.live
  • local development: http://localhost:<port> and http://127.0.0.1:<port>
  • override: comma-separated PETAL_ALLOWED_ORIGINS

Disallowed browser origins are rejected before endpoint handlers run. /api/token and /api/rooms also have best-effort warm-instance token buckets keyed by forwarded client IP. This is not a global abuse-prevention service across all Vercel instances, but it closes the unbounded same-instance polling/minting path and keeps room/identity data out of limiter logs.

lib/slug.ts MUST stay byte-for-byte behaviorally identical to:

  • apps/desktop/src-tauri/src/rooms.rsslugify / livekit_room_name_for
  • shared/logic/meetingCode.tsslugify / livekitRoomName

If they diverge, the same invite credential maps to different LiveKit rooms on different clients and people never meet. test/local.ts asserts the exact values the native Rust unit tests use. Change one, change all three.

Separately, normalizeAccessCode/credentialForAccessCode (the access-code -> credential half of lib/slug.ts, used for parsing join links) are also duplicated in web-harness/api/_lib/slug.ts — that project’s own join-link interstitial (web-harness/api/j.ts, served at meet.petal.live) needs them but can’t import across Vercel project roots. Keep those two functions in sync too.

Distribution is private — no public GitHub releases repo. The release CI (built separately) signs + notarizes the app and uploads artifacts to Vercel Blob at fixed, stable pathnames (no random suffix, so this backend can always find “the current one” without any database):

Blob pathname What it is
latest.json the Tauri updater manifest, produced by CI
Petal_<version>_universal.dmg the human-facing download
Petal_universal.app.tar.gz the updater artifact tauri-plugin-updater fetches
Petal_universal.app.tar.gz.sig its minisign signature (referenced from inside latest.json)

This backend only reads from Blob (lib/blob.ts, via list) — CI does all the writing, this service never uploads anything. Three endpoints serve that content:

  • GET /api/updater (also reachable at /latest.json via a vercel.json rewrite) — fetches latest.json from Blob and returns it verbatim. This is what tauri.conf.json’s plugins.updater.endpoints should point at. Returns 204 No Content if no release has been published yet so the updater treats it as “no update available” instead of logging a launch-time endpoint error.
  • GET /api/download — looks up the current Petal_<version>_universal.dmg blob (by prefix Petal_ / suffix _universal.dmg — there’s exactly one live at a time since CI always uploads to the same stable pathname pattern) and 302-redirects to its public Blob URL. This is what a website “Download for macOS” button hits.
  • GET / — this project is a pure API host (app.petal.live); it just 302-redirects to the marketing site at https://petal.live/.

New required env var: BLOB_READ_WRITE_TOKEN (alongside the 3 existing LIVEKIT_* ones). Despite the name, this backend only ever calls list with it — Vercel Blob’s list API needs a token even for read-only access; the actual blob URLs it returns are public CDN URLs that don’t need the token to fetch. Get it from the Vercel project’s Storage → Blob tab (same project, or a linked Blob store) and set it as a Project env var like the LiveKit ones.

Optional admin env var: PETAL_ADMIN_TOKEN enables /api/admin. Without it, admin-control requests return 503 and no participant kick or room close is available through the backend.

Needs a local SFU: livekit-server --dev (listens on :7880, key/secret devkey/secret).

Terminal window
npm install
npm run typecheck
npm run test:local # runs against livekit-server :7880 (real create/list/delete)

test/local.ts verifies slug lockstep, JWT grants, a real RoomServiceClient round-trip against the running server, and rooms-directory idempotency.

  1. New Vercel project, root directory = backend/.
  2. Set Project env vars (server-side, never exposed to clients): LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET (from the LiveKit Cloud project) + BLOB_READ_WRITE_TOKEN (from the project’s Blob store — see “Auto-update & distribution” above). Still no database to provision.
  3. The deployed base URL (https://app.petal.live) is what the client bakes in as its token endpoint (internal/ISSUES.md) and its updater endpoint (/api/updater, alias /latest.json served from Vercel Blob). The marketing site (https://petal.live) is a separate repo/project (petal-website); the browser SPA and join links live at https://meet.petal.live (web-harness).

Desktop clients read PETAL_BACKEND_URL and call this service for POST /api/token and GET /api/rooms; browser harness builds can set VITE_PETAL_BACKEND_URL to the same deployed base URL. LiveKit API secrets stay in this backend’s environment only. Local probe binaries may still use LIVEKIT_* directly for transport diagnostics, but the app join/gallery/rooms paths do not.