Skip to content

Qirava DMS docs

Studio: the admin app

Qirava Studio is the DMS's built-in admin web app. It is hardcoded into the engine as the "system" app, served at /studio, and built WITH Quill (the project's own UI framework) — the DMS dogfoods its own stack. The single most important thing to understand about Studio is that it is a CLIENT of the engine like any other app: it logs in, gets a session, and every mutation it performs calls a governance function WITH the caller's role in the request context. There is no privileged backdoor. Every write is re-checked server-side, deny-by-default. Studio exposes five role-gated screens — Dashboard, Users & RBAC, Databases, Workers & Apps, and Governance — and nothing it can do is something the signed-in user's role could not already do through the API.

What Studio is#

Studio lives in the qdms source tree at src/workers/apps/system/ — the "system" worker-app. It is the hardcoded default admin app, served by the DMS itself at the /studio/* URL prefix on the same HTTP port as the API (there is no separate static file server and no public/ directory). It is rendered server-side: each page is an SSR HTML document built from Quill view nodes and qquill-design components, and every change a user makes is a plain HTML <form> POST.

Hardcoded system app
Studio ships inside the engine binary. On boot, register_studio_handlers(&db) registers its page/auth/action handlers and its routes are seeded into the _sys_routes catalog (idempotent), so a default boot serves /studio/* with no extra configuration.
Built WITH Quill
Studio uses the same view layer (qquill-view: el/text/raw/fragment), design components (qquill-design: Alert, Stat), theming (qquill-theme), and style builder (qquill-style) as the public website and docs. The shell is modeled on qquill-docs::DocShell.
A client of the engine
Studio holds no special powers. It authenticates a human, gets a session token, and calls the SAME governance functions (auth.create_user, auth.set_db_grant, …) the API exposes, passing the session's role in ctx. The function decides whether to allow the action.
No backdoor
There is no path in Studio that bypasses the governance functions. Reads flow through the planner; writes flow through governance fns that re-check the caller's role server-side, deny-by-default.

The five screens#

Studio has exactly five screens, defined by the Screen enum (in nav order). Each screen requires a minimum role; the sidebar hides screens a role cannot reach, and each page handler independently returns a 403 page if a forbidden screen is navigated to directly.

ScreenPathSidebar labelMinimum role
Dashboard/studioDashboardany signed-in user (guest+)
Users & RBAC/studio/usersUsers & RBACadmin or custodian
Databases/studio/databasesDatabasesuser or higher
Workers & Apps/studio/workersWorkers & Appsadmin or custodian
Governance/studio/governanceGovernancecustodian only
  • Dashboard — welcome line plus stat cards: Databases, Tables, Users, Workers, and Active sessions (counted live from the catalogs).
  • Users & RBAC — a live table of _sys_users (username, role, db grants, enabled) plus governance forms: Create user, Set DB grant, and (custodian-only) Promote to admin.
  • Databases — the databases the signed-in user can access; a plain user sees only databases named in their own db_grants, while admin/custodian also get a read-only table inventory.
  • Workers & Apps — the deployed workers table and the registered routes table (deploy-from-Studio is on the roadmap).
  • Governance — the custodians list, any custom roles, and custodian-only forms: Create custodian and Define role (invites + M-of-N master-seed unlock are deferred to the crypto backend).

How a request flows#

Every authenticated /studio/* route is covered by a worker group whose before chain runs auth.session (validates the cookie, injects {user, role} into the shared ctx) and whose after chain runs auth.session_extend (bumps the idle TTL). The page handler reads {user, role} from ctx, performs its reads, and renders the StudioShell. A mutation is an HTML form POST: the handler parses the form body, verifies the CSRF token, calls the governance fn WITH the caller's role in ctx, then 302-redirects (Post/Redirect/Get) back with a ?flash= or ?error= message.

browser ──GET /studio/users──> worker
                                 │ before: auth.session  (cookie → ctx{user,role})
                                 ▼
                            users_response handler
                                 │ 1. require_user (ctx) — else redirect /studio/login
                                 │ 2. require_access (role) — else 403 page
                                 │ 3. read _sys_users via planner
                                 ▼
                            render StudioShell (SSR HTML)
                                 │ after: auth.session_extend (bump TTL)
                                 ▼
 browser <──200 HTML─────────────┘

 browser ──POST /studio/actions/create-user (form + _csrf)──> worker
                                 │ before: auth.session
                                 ▼
                            create_user_action handler
                                 │ 1. authed: ctx + CSRF verify
                                 │ 2. require_min("admin")  (fast UI gate)
                                 │ 3. call auth.create_user WITH ctx.role  ◄── REAL gate
                                 ▼
 browser <──302 /studio/users?flash=Created+user+'bob'──┘
A read (GET /studio/users) and a write (POST /studio/actions/create-user)

Setup: booting Studio#

On a default boot the qdms binary wires Studio for you. The wiring has three parts that are useful to understand if you ever embed Studio yourself: (1) register the handlers, (2) make the routes available to the system worker, and (3) attach the session-gate group in code (groups are not data-driven).

rust
// From src/bin/qdms.rs — register handlers + seed routes into _sys_routes.
use qdms::workers::apps::system::{register_studio_handlers, studio_route_inserts};
register_studio_handlers(&db);
for stmt in studio_route_inserts() {
    // idempotent: insert each route only if its id is absent
    let _ = db.execute(&stmt);
}

// Later, on the trusted system worker (so Studio shares the API port):
let mut sw = system_worker(&addr);
for route in qdms::workers::load_routes_for(&db, SYSTEM_WORKER_ID) {
    sw = sw.route(route);
}
// The session-gate group is attached in code (groups are not data-driven):
sw = sw.group(qdms::workers::apps::system::studio_session_group());

On first install the engine also mints ONE bootstrap custodian (the root of trust) and prints its random password once. That custodian is who you sign into Studio with to create the rest of your users and admins.

text
  studio:  admin app wired (login at /studio/login)
  BOOTSTRAP CUSTODIAN (shown once): user=<name> password=<random>

A real example: signing in and creating a user#

This is the end-to-end flow against a freshly booted DMS. Sign in at /studio/login, land on the Dashboard, open Users & RBAC, and submit the Create user form. Studio turns that form into a governance call carrying your role.

Request

POST /studio/login HTTP/1.1
Content-Type: application/x-www-form-urlencoded

username=ada&password=<bootstrap-password>

Response

HTTP/1.1 302 Found
Location: /studio
Set-Cookie: qdms_session=<opaque-token>; HttpOnly; SameSite=Lax; Path=/; Max-Age=1800
Cache-Control: no-store

Output

# Now GET /studio/users (cookie sent automatically) shows the Create user form.
# Submitting it:
POST /studio/actions/create-user
Cookie: qdms_session=<opaque-token>
Content-Type: application/x-www-form-urlencoded

_csrf=<session-bound-csrf>&username=bob&password=s3cret&role=user&db_grants=sales:read_write

# Studio calls auth.create_user WITH ctx.role=custodian, then:
HTTP/1.1 302 Found
Location: /studio/users?flash=Created+user+'bob'