Skip to content
DocspackagesDocumentation

@lunora/studio

The Lunora Studio — a local admin console for your schema, data, functions, logs, and advisors.

PackagesStudio

The Lunora Studio is a self-contained admin console for inspecting and operating a Lunora backend: browse and edit data, run functions, read logs, inspect the schema, run advisors, manage auth, and more. It ships as a React component library so you can mount the whole console, embed a single panel, or compose your own layout.

It is modelled on Supabase Studio's two-zone console: a slim icon rail of domains down the left, a secondary nav listing that domain's pages, and the active panel filling the rest. Every page is a real, shareable URL (TanStack Router over the History API), so deep links and back/forward work.

Running the studio

Three ways, smallest setup first:

  1. lunora dev (zero config). The @lunora/vite plugin serves the studio at /__lunora during dev and prints the URL on startup. Add @lunora/studio to your project's deps; opt out with lunora({ studio: false }).
  2. Standalone app. apps/studio is a deployable Vite SPA that points at any worker via VITE_LUNORA_URL, for hosting the studio separately from dev.
  3. Embed. Mount the whole console, or compose individual panels under your own <LunoraProvider>.
// mount the whole console into <div id="root">
import { mountStudio } from "@lunora/studio/mount";

mountStudio({ baseUrl: "https://my-app.workers.dev" });
// or embed the shell / a single panel in your own React app
import { StudioApp, Studio, DataBrowser } from "@lunora/studio";

<StudioApp baseUrl="https://my-app.workers.dev" />;
<Studio dataEditable functions={LUNORA_FUNCTIONS} />;

The <Studio> shell takes a few host-controlled switches worth knowing:

  • dataEditable: allow row insert/edit/delete (off by default; the console is read-only until the host opts in).
  • runAsIdentity: let the function runner execute as a chosen authenticated identity, to test auth + RLS. Security-sensitive (it forges identity on an admin RPC). Only ever enable on a trusted loopback-dev gate, never in production.
  • functions: the descriptors that populate the function runner and API tab.
  • initialShardKey: the shard every shard-scoped panel targets on first load.

How the console is laid out

The icon rail has nine domains, top to bottom, with Settings pinned to the bottom. Each owns a set of pages:

DomainPages
OverviewHome · Dashboards
DatabaseData · SQL editor · Schema · Migrations · Vectors · Time Travel · Export / Import
FunctionsFunctions · API · Workflows · Agents · Queues
AuthUsers · Organizations · Sessions · Auth audit · Configuration
StorageFiles · Access Rules · KV
ObservabilityIssues · Logs · Traces · Evals · Audit · Realtime · Reactors · Fan-out · Containers · Metrics · Analytics · Health · Deployment health
AdvisorsHealth score · Security · RLS Policies · Permissions · Performance
OperationsScheduled · Mail · Log drains · Notifications · Payments · Flags
SettingsSettings

Most shard-scoped pages share a shard-key input: Durable Objects aren't enumerable server-side, so you target a shard by key. The data and SQL pages remember the shards you've visited and offer a "Shards seen" picker with a live table/row-count summary. A ⌘K command palette jumps to any page by name.

The rail also collapses to just icons, and the whole console localises its own UI strings (locale / i18n props).

Overview

  • Home: Connection, health, and advisor summary for your deployment. A roll-up of advisor findings (security, performance, schema) so the first thing you see is whatever needs attention; drill into any summary to its panel.
  • Dashboards: Chart widgets backed by saved read-only SQL queries.

Database

Everything that reads or shapes stored state.

  • Data: Browse rows across your shard and global tables, and edit the shard ones. A full-height table editor: pick a table, page through rows (table or JSON view), filter, and — on shard tables, when dataEditable is on — insert, patch, and delete. .global() (D1-backed) tables are read-only here: the browser offers no write affordances for them, and the shard would refuse one anyway (GLOBAL_TABLE_NOT_EDITABLE) because a global table is edited through D1, not through the shard that reads it. Three extra tools:
    • Mask preview: a "Mask sensitive columns" toggle that previews redact/hash/custom output client-side and flags masked columns in the grid header, mirroring what data masking does on the server. Codegen emits the (table, column, strategy) map it reads.
    • Row generation: generate realistic seed rows with @faker-js/faker, inferred from each column's type, before inserting.
    • Cascade preview: deleting a row opens an impact preview first — the tables whose rows reference it, how many there are, and whether the relation cascades, blocks the delete (restrict), or declares no action at all (those rows survive, pointing at a row that no longer exists). Edges are read from the schema metadata the browser already loads, so a relation whose onDelete the studio cannot see is labelled as undeclared rather than guessed at.
  • SQL editor: Run read-only SQL against a shard. A full-height query console for ad-hoc SELECTs against a shard's SQLite.
  • Schema: Inspect each table and its columns. Every table with its row count, expandable to its columns, indexes, and relations. It also renders an interactive schema diagram (React Flow): tables as nodes, relations as edges, with a storage-tier filter (shard / global), a find box, and export to PNG / SVG / JSON. The Insights "add the index" deep-link lands here with the offending table pre-expanded.
  • Migrations: Review migration status and run them. Inspect data-migration run-state and kick one off.
  • Vectors: Browse Vectorize indexes and run similarity searches.
  • Time Travel: Restore a shard to a point in the last 30 days (PITR).
  • Export / Import: Export a shard to NDJSON, or import rows from it.

Functions

  • Functions: Run registered queries, mutations, and actions. Pick a function, edit its JSON args, and invoke it; per-function call/error stats sit above the runner. With runAsIdentity enabled, run a function as a chosen identity to test auth and RLS behaviour.
  • API: Interactive OpenAPI reference and copy-paste snippets for your functions. Renders the generated OpenAPI 3.1 / OpenRPC documents.
  • Workflows: Inspect declared Cloudflare Workflows and their bindings. Lists every durable workflow with its export name, generated class, binding, and deployed name; starts an instance from a JSON-params form; and tracks instances with their live status (queued / running / complete / errored) and output.
  • Queues: Inspect declared Cloudflare Queues: their producer bindings, consumer mode, and dead-letter queue (@lunora/queue).

Auth

Backed by @lunora/auth's admin API. Capability-driven: each page shows only when its better-auth plugin is enabled.

  • Users: Manage auth users: roles, bans, sessions, and identity.
  • Organizations: Browse and manage organizations, members, and invitations.
  • Sessions: Browse and revoke active sessions across all users.
  • Configuration: Enabled plugins and session config (read-only).

Storage

  • Files: Browse objects in your R2 storage buckets by prefix.
  • Access Rules: Inspect storage access rules, per bucket, operation, and key prefix.
  • KV: Browse and edit key-value pairs in your Workers KV namespaces (@lunora/bindings).

Observability

Operational streams and per-shard signal from a running deployment.

  • Issues: Grouped error triage: Worker throws and container crashes folded by fingerprint (@lunora/fingerprint), so many instances of one bug read as a single row.
  • Logs: A live stream of recent function logs (the shard's log ring buffer).
  • Traces: Recent ctx.trace waterfalls for this shard, the drill-down from a log line (see observability).
  • Audit: A durable log of admin state-changing operations.
  • Realtime: Active WebSocket subscriptions on this shard.
  • Fan-out: Realtime fan-out cost and per-topic subscriber counts for this shard.
  • Containers: Live Cloudflare Containers: current lifecycle state per container from the log stream (@lunora/container).
  • Metrics: Per-shard health and aggregate metrics (request/error counts, uptime, DB size, reactive-cache hit rate).
  • Analytics: Usage and latency from Analytics Engine (request volume, p50/p95, and hot shards).
  • Health: At-a-glance connection, error, and shard signals.
  • Deployment health: Live liveness, readiness, and per-binding health from the deployment's /_lunora/health endpoint.

Advisors

The advisors surface: splinter-style lints over your schema, functions, and runtime signal.

  • Security: Review admin gates, credentials, and log redaction, the security-category findings.
  • RLS Policies: Inspect row-level-security policies and roles, per table.
  • Permissions: Inspect access policies per table, and probe a function as any identity.
  • Performance: Surface slow functions, error spikes, and cache problems. Runtime insights derived from each shard's durable metrics; findings deep-link to the fix (e.g. the Schema page to add a missing index).

Operations

The optional package-backed operational pages: scheduling, email, log forwarding, push notifications, payments, and feature flags.

  • Scheduled: Inspect and cancel scheduled jobs (@lunora/scheduler).
  • Mail: Email your app sent, captured in dev (@lunora/mail).
  • Log drains: Forward logs to Logpush, Tail Workers, or a webhook collector.
  • Notifications: The registered push devices and their last delivery outcome (@lunora/notify). One row per device — kind, target endpoint, owning user, last register/send touch, and any delivery error. Delivery secrets are stripped server-side. The store is a worker option rather than shard state, so this page is a one-shot read with a manual Refresh.
  • Payments: Synced customers, subscriptions, and webhook events.
  • Flags: Inspect feature flags and their live evaluation under a targeting context (@lunora/flags).

Settings

Read-only deployment config: vars, secrets, and bindings. Pinned to the bottom of the rail.

Admin gate

The studio reaches the backend two ways: reserved __lunora_admin__:* RPCs that ShardDO intercepts (data, schema, metrics, logs, migrations, export), and admin-gated worker endpoints under /_lunora/admin/* for things that live outside the shard (scheduler, storage, functions, global tables, auth).

The two planes are not gated the same way, and the difference matters when you deploy behind an identity provider:

  • The __lunora_admin__:* RPCs are token-only. ShardDO compares a bearer against LUNORA_ADMIN_TOKEN and nothing else, so with no token set they are disabled outright — there is no second way in.
  • The /_lunora/admin/* worker endpoints accept that same bearer or a per-request grant from adminGate — the seam accessAdminGate(...) plugs Cloudflare Access into. An Access-authorized admin carries a Cf-Access-Jwt-Assertion, not a bearer, so this plane opens with no token at all.

Running Access alone therefore leaves the shard-served pages (data, schema, metrics, logs, migrations, export) dark: the worker still needs a static LUNORA_ADMIN_TOKEN to reach ShardDO — it is also what the cross-shard orchestrators mint into their forwarded calls for an Access-only request.

The components issue no credentials of their own. Configure the client's auth token at the host.

// worker entry — token-only, so both planes are gated by adminToken
createWorker({
    shardDO: env.SHARD,
    adminToken: env.LUNORA_ADMIN_TOKEN,
    schedulerDO: env.SCHEDULER, // enables the scheduled-jobs page
    functions: LUNORA_FUNCTIONS, // enables function discovery
    storageList: createStorage({ bucket: env.FILES, bucketName: "default" }).list, // enables the file browser
});

globalIntrospector (D1 tables) and authAdmin (the user-management + organization dashboard, via @lunora/auth's createAuthAdmin(auth)) wire up the remaining pages; see the package README for the full per-page configuration. The auth dashboard is capability-driven: it shows only the surfaces whose better-auth plugin is enabled. Pages whose data source isn't configured are omitted from the <Studio> shell.

Optional-package nav gating

The studio also hides the pages for optional @lunora/* packages your app doesn't use (Payments, Mail, Notifications, Files + Access Rules, Vectors, Scheduler, and Workflows), so you never land on a page for a package you never installed, which would error with "unknown table" or render a shell with nothing behind it. Gating is about unused packages, not about emptiness: a page for a package you do use still shows its own empty state when there is genuinely nothing to list yet (Notifications with no registered devices, say). This is automatic: codegen statically detects which features a deployment wires up and emits the result into the generated ShardDO, which the studio reads once over the __lunora_admin__:studioFeatures RPC.

A feature's page shows when any of these is true:

  • a lunora/ source imports its package (e.g. @lunora/mail, or the lunora/notify.ts config's @lunora/notify) or reads its context helper (ctx.payments, ctx.storage, ctx.vectors, ctx.scheduler, ctx.notify, ctx.workflows);
  • a schema signal implies it: a v.storage() column or storage access rule (Files), a declared cron (Scheduler), a vector index (Vectors), or a declared workflow (Workflows);
  • the package is a declared dependency in your package.json, so a package wired only in your worker entry (outside lunora/), like @lunora/mail, still shows its page.

The gating fails open: every page stays visible until the RPC resolves, and a worker predating the RPC (or one that errors) keeps showing everything. A page is only ever hidden once the worker positively reports its feature as unused, so the worst case is an extra empty page, never a missing working one. No configuration is required; re-run codegen (lunora dev does this on save) after adding or removing a package and the nav updates itself.

Host capability gating

A second gate sits beside the usage flags: whether the worker's host can serve a page at all. Codegen knows the deploy target (target in lunora.config.*) and adds it to the same studioFeatures payload, together with the @lunora/platform capabilities that target's matrix rates unsupported. So a studio hosted apart from the worker still learns which host it is talking to. On a Node worker, for example, Time Travel (point-in-time recovery), Agents, Vectors, Containers, Analytics and Mail are backed by capabilities that host does not have.

Such a page stays in the nav, dimmed, with a one-line reason as its tooltip ("Point-in-time recovery is not supported on Node."). It is left out of the ⌘K palette, and its URL renders that reason instead of the panel, so none of the panel's admin calls are made. Unlike the usage gate, this one fails closed while the payload is in flight: a capability-gated page shows a loading skeleton until the worker answers, rather than mounting a panel that calls an op the host cannot answer. A worker that reports no target (an unregistered one, or one built before the field existed) gets no capability gating at all.