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:
lunora dev(zero config). The@lunora/viteplugin serves the studio at/__lunoraduring dev and prints the URL on startup. Add@lunora/studioto your project's deps; opt out withlunora({ studio: false }).- Standalone app.
apps/studiois a deployable Vite SPA that points at any worker viaVITE_LUNORA_URL, for hosting the studio separately from dev. - 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:
| Domain | Pages |
|---|---|
| Overview | Home · Dashboards |
| Database | Data · SQL editor · Schema · Migrations · Vectors · Time Travel · Export / Import |
| Functions | Functions · API · Workflows · Agents · Queues |
| Auth | Users · Organizations · Sessions · Auth audit · Configuration |
| Storage | Files · Access Rules · KV |
| Observability | Issues · Logs · Traces · Evals · Audit · Realtime · Reactors · Fan-out · Containers · Metrics · Analytics · Health · Deployment health |
| Advisors | Health score · Security · RLS Policies · Permissions · Performance |
| Operations | Scheduled · Mail · Log drains · Notifications · Payments · Flags |
| Settings | Settings |
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
dataEditableis 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 whoseonDeletethe studio cannot see is labelled as undeclared rather than guessed at.
- 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
- 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
runAsIdentityenabled, 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.tracewaterfalls 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/healthendpoint.
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.ShardDOcompares a bearer againstLUNORA_ADMIN_TOKENand 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 fromadminGate— the seamaccessAdminGate(...)plugs Cloudflare Access into. An Access-authorized admin carries aCf-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 thelunora/notify.tsconfig'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 (outsidelunora/), 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.