Admin Dashboard - developerz-ai/ultimate GitHub Wiki

Admin dashboard

One declaration serves it. defineAdmin({ entities, db }) is a list, a detail and a form for every entity at /admin/<entity> β€” the app writes no page, no screen glue and no repo adapter. As of 2026-10.

// apps/admin/app/admin/admin.ts β€” the whole dashboard
import { db } from '@app/db';
import { adminEntitiesOf, defineAdmin } from '@ultimat3/admin';

export const admin = defineAdmin({ entities: adminEntitiesOf(db), db });
Input Default Override
entities required β€” adminEntitiesOf(db) is "every entity the handle serves" name them: entities: [posts, users]
db the app's database() handle; each resource reads through its own table β€” tenancy, soft delete and sealing are the handle's resources: { <entity>: { repo } } for one resource. Neither is X_ADMIN_REPO_UNBOUND at declaration
auth.actor the actor the HTTP pipeline resolved for the request auth: { actor: (request) => … }
auth.authz roleAuthz() β€” the role map decides each permission by name, so the admin is closed until a role grants admin:read and <entity>:read policyAuthz({ policies }) for row- or tenant-reading rules; singlePolicyAuthz(policy) when one grant runs the whole admin
resources.<entity> derived: list columns, filters, sorts, label listFields, labelField, columns, scopes, count (a tile on /admin β€” below), rows, fields.<name>.sensitive, permission (the noun <noun>:read|write|delete is named after; default the entity's name)
basePath /admin basePath: '/back-office'

x dev and the container mount every admin the app declared: one catch-all under its base path, GET and POST. x new scaffolds the declaration; x g entity adds an entity to the handle and it is a screen on the next boot.

URL Screen Write it accepts
/admin the home: a count tile per resource that opted in (below), then each resource the actor may open, with the permission pair behind every operation POST an app-wide action
/admin/<entity> list β€” scope tabs, a filter bar, the batch bar, sort headers and a keyset pager, all links and native forms (?scope=, ?f.<field>=, ?sort=<field>:<dir>, ?cursor=) POST _operation=batch (name, selection=checked|all, ids)
/admin/<entity>/lookup?term= the picker every filter and input referencing the entity searches: contains on its label, keyset-paged β€”
/admin/<entity>/new create form POST β†’ 303 to the row, or 422 with the issues
/admin/<entity>/<id> detail: sections, related lists, row actions, history (?history= pages older) POST _operation=action (input.<field>) Β· _operation=delete (needs confirmation)
/admin/<entity>/<id>?action=<name> an action's own form, when it declares an input schema posts at the row
/admin/<entity>/<id>/edit edit form POST β†’ 303 to the row, or 422
/admin/search?term= Β· /admin/audit built-in screens β€”
/admin/jobs Β· /admin/jobs/runs Β· /queues Β· /tasks Β· /workers the jobs dashboard every admin carries β€” below row actions and the batch bar, as any resource
/admin/<page path> a pages: entry β€”

A refusal is a 403 document naming the permission, and an audit entry. Anonymous is the pipeline's own 401 (or the app's sign-in redirect). Every admin document is noindex, nofollow and carries no og:* / twitter:* tag. A row action lands on its row (303); a batch answers a page of counts (200).

The home

A resource is counted on /admin only when it declares count: true. As of 2026-10. A tile is one full AdminRepo.count() over the actor's rows scope on every visit, so an operator reloading the front page over a big table was a load generator β€” the same reason a scope tab's count is opt-in.

resources: { posts: { count: true } },
Resource On the home
count: true, the actor may list it a StatTile linked to the list, and a segment of the DonutChart
no count no tile and no segment; still in the permission matrix
the actor may not list it nothing β€” no tile, no segment, no matrix
its repo has no count(), or the store refuses it (X_TENANCY_UNSCOPED for an actor with no org) no tile; the page never fails

A count reads no row, so it is not audited. Upgrading from a release that counted every listable resource: add count: true to each resource whose tile you want back.

The list

As of 2026-10. Everything on a list page is derived from the entity or declared once on the resource.

On the page Source Notes
Filter bar one control per derived filter: text β†’ contains, enum β†’ a select of the entity's own values, boolean, number/date/timestamp β†’ a from–to range, reference β†’ a picker the label field is always first β€” it is the list's search box. Money, JSON, sealed and tenant columns are never filters
Scope tabs scopes: { open: { where, default?, count? } } at most one default; a count is read only for a scope that sets count: true
Reference cells the TARGET resource's labelField, linked to the row one read per referenced resource per page, never one per row
Computed columns columns: { age: { value, render } } render: badge, relative-time, money, truncate, link, json, or a component
Row scope rows: (actor) => AdminFilter[] applied by the list, the search, the detail read, the lookup, the labels and the MCP tools β€” one declaration, no second copy of the resource for a second audience
URL parameter Means
?f.<field>=<value> the field's default operator (contains for text, eq otherwise)
?f.<field>.<op>=<value> eq neq contains gt gte lt lte in (repeat the parameter) is-null (true / false) β€” the ones the field's shape answers
?scope=<name> a declared scope; absent is the default; * is no scope at all β€” what a related card's link opens. A scope may not be declared as *
?sort=<field>:<asc|desc> a sortable field
?cursor=<signed> keyset position on (sort, id): any number of rows sharing a sort value, and NULLs (last ascending, first descending), are reached both ways

An unknown field, operator, sort, scope or parameter β€” or a value that is not the column's type (a number is decimal digits: no blank, hex or exponent; a date is one the calendar has) β€” is X_ADMIN_FILTER_INVALID: a 400 naming what the list answers, never the unfiltered list. The MCP list tool takes the same grammar (scope, where: [{ field, op?, value }]).

Statements per list page: 1 for the rows (2 when the sort column is nullable, one more when a page ends inside one millisecond of an instant sort, and a page past its first adds the rest of the boundary's tie), 1 per counted scope, 1 per referenced resource per 200 ids (2 for one with more than 50 rows that a control picks from). A reference picker is a <select> up to 50 rows and the target's lookup screen above that β€” both work with scripting off.

Detail, form, actions

As of 2026-10.

Declared on the resource Rule
sections: [{ titleKey, fields }] titled groups on the detail; a field named in none falls into a default section drawn last β€” adding a column never hides it
formGroups: [{ titleKey, fields }] the same for create and edit
related: ['comments'] a hasMany of the entity, drawn as the related resource's OWN list β€” its columns, its rows, its policy β€” filtered to this row. A resource the actor may not list is left out
fields.<name>.hintKey Β· .on: 'create' | 'update' the line under the control; a field that exists on one side only, enforced on the write too
Declared on an action Rule
input: t.object({ … }) the button is a link to the action's form; a refusal is a 422 with each issue on its field. No schema: a confirm only
when: (row) => boolean decides the button on the detail and each list row, and is asked again before the handler: X_ADMIN_ACTION_NOT_APPLICABLE (409)
batch: true in the list's batch bar: checked rows, or every row the list's URL matches (200 per request, continued from where it stopped). Each row through the button's gate and on the audit log; answered done / refused / failed / remaining
batch: { threshold, chunk? } past threshold rows, one admin.batch job per chunk, run by a worker as the operator; queued in the answer. The batch id is derived from actor, action, rows and input, so retrying a request whose enqueue failed part-way dedupes the chunks already queued
matching: ({ where, input, ctx }) => { affected, remaining } "all matching" as ONE set-based call over the list's where (row scope, scope, filters) β€” a store's bounded bulk verb β€” decided and audited once; a destructive one types <entity>:all matching. Checked rows still run row by row
readonly: true the action changes nothing (an export, a verification), so its admin-level gate is admin:read, not admin:write: a read-only staff role and an admin:read MCP token may run it. The action's own permission is still required beside it
destructive: true gated on admin:destroy; one row types <entity>:<id>, a batch <entity>:<n> rows

The admin-level gate an action needs β€” adminPermissionForAction (packages/admin/src/action-gate.ts), first match wins, so a contradiction fails closed:

Declares Gate
destructive: true admin:destroy, whatever else it declares
matching admin:write, even with readonly: true β€” it changes every row the list matches
readonly: true admin:read
neither admin:write

Still one MCP tool per action β€” a batch action's takes ids. No script on any of it: the row checkboxes join the bar's form by their form attribute, and a confirmation is a server round trip. An action's input fields are labelled admin.input.<action>.<field> β€” beside its own admin.action.<action>, never under it, so both fit one JSON catalog. An action on a row the actor cannot see (gone, or outside its rows) is refused, when or none.

A create, or an update of the fields it touches, whose resulting row falls outside the actor's rows(actor) is refused before the repo is called β€” audited as a denial, reason admin.error.row-out-of-scope, on the screens and over MCP alike. Both are then decided again on the validated values they write, as the subject's input (an update: beside its row).

An edit form carries the row's version (_version); a row someone changed after the form was drawn is a 409 (admin.error.row-changed) with the form redrawn over the row as it is now β€” never overwritten. A datetime-local box holds minutes, so an instant posted back exactly as it was drawn is left out of the write.

The audit log

As of 2026-10. defineAdmin({ audit }) takes one AuditLog; the default is memoryAuditLog().

Store Keeps Use
memoryAuditLog() this process's entries, until a restart tests, x dev
postgresAuditLog() x_admin_audit β€” applied at boot with every framework table, from @ultimat3/admin/schema; one insert per audited write, inside the write's own transaction (AuditLog.atomic) β€” a CRUD write, an action's handler, a matching set and a queued batch's enqueues alike: an entry that cannot be written rolls the work back, and the log holds one failed entry production: audit: Bun.env['DATABASE_URL'] ? postgresAuditLog() : memoryAuditLog()
your own AuditLog anything with append, entries(query) (async), atomic(run) and kind β€”
Rule Detail
What is recorded every write and every refusal. An allowed read (list, detail, search, a page) is stored by postgresAuditLog({ reads: true }) only
Redaction a sensitive or sealed value is only ever [redacted], in the diff and on screen
Where it shows the detail page's history card β€” audit.entries({ entity, entityId, changes: true }), keyset by ?history=; /admin/audit (audit:read) for the whole trail. Both read only the actor's tenant's entries (orgId) when the actor has an org
A copy elsewhere memoryAuditLog({ sinks }) / postgresAuditLog({ sinks }) β€” each AuditSink gets a copy of every entry; a sink is write-only and never read back

The jobs dashboard

As of 2026-10. Every defineAdmin() serves it; the app declares nothing. It is built from the admin's own pieces β€” four resources over the queue's operator surface (JobIntrospection) with a repo:, scopes, a row scope and actions, plus one overview screen. Zero JavaScript, like every admin screen.

URL Shows Actions (job:manage)
/admin/jobs tiles: ready, running, delayed, suspended, dead, workers, oldest ready (one stats()); done vs failed over ?range=1h|24h|7d|30d from the settle counters; volume, failure rate and mean duration per job name. Re-read every 30 s by a declared <meta http-equiv="refresh"> β€”
/admin/jobs/runs one tab per state; the search box is an id prefix; filters by name, queue, state, tenant, created time retry (dead rows), retry from a step, run now (waiting on its clock), cancel (live), remove
/admin/jobs/runs/<id> input (redacted by @ultimat3/jobs), steps, error and stack, progress, tenant, trace, history the row's own
/admin/jobs/queues depth per state, paused, a link to the queue's jobs pause, resume
/admin/jobs/tasks schedule, IANA zone, last fire, next fire, paused pause, resume, run now
/admin/jobs/workers identity, host, queues, in flight, heartbeat β€”
Rule Detail
Read-only is a permission job:read sees every screen and no control; job:manage (behind admin:write, or admin:destroy for remove) runs every action and is refused on the server when forged
"All matching" retry, run now and remove over a state tab are ONE bulk call (requeueMany, promoteMany, removeMany, at most 1,000 rows, answering { affected, remaining }). A tab the action is not for, no tab, or a narrowing by id or time is refused by name
Tenancy an actor with an orgId sees that org's job rows only, and none of the fleet β€” queues, tasks, workers and the overview's figures are every tenant's. A platform operator is the actor without one. Override: resources: { x_jobs: { rows } }
No counts on the state tabs stats() counts every tenant, so a count on a tab would show an org-scoped operator other orgs' numbers. The counts are the overview's tiles, which only a platform operator sees
Grants the four resources share the permission noun job (AdminResourceOptions.permission), so job:read and job:manage are the whole vocabulary β€” never x_jobs:read. Both are declared by defineAdmin(); a role that should operate the queue is granted them in the role map
MCP admin.x_jobs.list, admin.x_jobs.read, admin.x_job_tasks.list, admin.action.job.retry, admin.action.job.queue.pause, … β€” the resources' and actions' own tools
/_x/jobs the same overview component over the dev process's queue

Screens and their source

The served admin, in every environment:

Screen Derived from Shows
Entity list the entity's columns, by type keyset-paginated table; filters derived per column type (The list)
Entity detail / edit the entity's Standard Schema a widget per column kind (fields.ts, the one type β†’ widget table); input handed straight to the schema, so validation is the same one the write uses
Action form AdminAction.input + its permission a form per action, a 422 with each issue on its field, a 403 naming the permission
Jobs dashboard the queue's operator surface (JobIntrospection) the overview, runs, queues, tasks, workers β€” above
Audit the AuditLog every recorded entry, newest first β€” above

The /_x dev dashboard (devDashboard(), @ultimat3/admin/dev) is a separate page that refuses to mount in production. Its tabs are DEV_PANELS in packages/admin/src/dev/server.ts; each has a --json twin at /_x/<tab>?json=1:

Tab Shows
routes render mode, hydrate timing, offline strategy, budget, meta status. The admin's own routes are MOUNTED routes β€” x routes prints defineAdmin() Β· admin:read + <table>:read where a page names its file
timeline one request as a flamegraph β€” SQL, cache hits, action calls, policy decisions β€” with the N+1 counted
live every live subscriber, what it received, and the matcher's decision trace
jobs the jobs overview component over the dev process's queue, its JSON payload (queues, runs, step traces, tasks) folded under it
db the tables, read-only by default, and the schema diff against the migration history
mail every caught message, rendered, grouped per locale
cache the tag graph and the invalidation log
policy rule-by-rule decision trace for the last request
manifest the emitted x.manifest.json diffed against the committed one. Its admin section records each resource's filters, sorts, scopes, row scope, sections, related lists, actions and the audit store's kind

Authz

One authz seam for the whole admin: the rendered button, the HTTP call behind it, the MCP tool, the nav item, and the search result all ask the same interface.

Property Detail
Actor the actor the HTTP pipeline resolved for the request β€” the app's own authenticator; auth: { actor } overrides
Decision roleAuthz() by default β€” the role map decides each permission by name, and admin:destroy implies admin:write implies admin:read, as staticAuthz answers. policyAuthz({ policies }) adapts the app's policy layer when a rule reads the row or the tenant: a mapped permission is decided by its policy, an unmapped one by the role map, so a grant in defineRoles is enough for the jobs and audit screens (job:read, audit:read). The three framework constructors decide on the actor's roles AND its direct grants (Actor.permissions), as the request pipeline does; a custom AdminAuthz is handed actor.permissions and decides with it as it chooses
Admin permissions admin:read, admin:write, admin:destroy, admin:impersonate β€” ordinary permission strings evaluated by the same policy engine
Per-entity gate an actor needs the admin-level permission and the table's: <table>:read to list, open and search, <table>:write to create and edit, <table>:delete to delete. defineAdmin() declares them; x g entity / x g resource declare and grant a new table's three to the scaffold's admin role
View-only a role holding admin:read and each <table>:read and nothing else β€” every write is refused by the decision that hid its button. It may still run a readonly action whose own permission it holds. Not a mode
Jobs and audit screens job:read and audit:read, beside admin:read; every jobs action is job:manage
Denial { allowed, permission, reason, trace }; reason is an i18n key or a rule name, never a sentence
UI invariant the UI cannot show what the call would refuse β€” one decision per request, consulted by every surface
Destructive ops delete requires an echoed confirmation token and is always audited. Both are data in a table, not a code path in a view
Audit every write and refusal is an AuditLog entry with a redacted field diff β€” The audit log. sensitive columns are never rendered and never diffed

What it never does

Never Why
A second authz system two authz systems is how every Meteor-like framework died. There is one policy layer
An admin-only user table or role table admins are users with permissions; a parallel identity store drifts and gets forgotten in offboarding
A "superuser" bypass there is no flag that skips policy evaluation
Writes without a policy an action with no policy does not compile; the admin cannot invoke what does not exist
A raw SQL console that writes read-only SQL only, statement-capped and row-capped. Writes and data-modifying CTEs are refused with X_MCP_QUERY_REJECTED
Offset pagination offset re-scans every page and skips rows when the table is written to mid-page. Keyset only
Hidden framework internals as user data /_x panels are dev-only and gated by role

MCP over the app's own actions

The admin exposes an MCP surface over the actions the app already declared, so the user's agents can drive the user's product with exactly the permissions the human has.

mcp: { expose: true, description: 'Publish a draft post' },
Property Consequence
Actor = the signed-in user's session an agent can never exceed the human it acts for
Policies unchanged no separate "API permissions" screen to get wrong
Tool list is generated adding a feature adds a capability; deleting one removes it
Every tool carries a scope the admin-level permission it needs β€” table below. A token can be narrower than its actor, never wider
Ships on day-one agent access to operations, not a v3 roadmap item
Exposure two surfaces, two defaults β€” and this catalog is the only exception in the framework. Inside the admin's own catalog a registered action becomes a tool unless it sets mcp: { expose: false }, because every tool here is already gated on an admin permission and the CRUD tools carry no mcp block at all. Everywhere else β€” the app MCP surface (defineAppMcp), the LLM tool list, openapi.json's x-ultimate.mcpTool and x.manifest.json β€” one predicate decides, and it is opt-in: only mcp: { expose: true } makes a tool, and naming an un-exposed primitive in defineAppMcp is X_MCP_TOOL_UNDECLARED at boot (Actions)

The projected tool calls action.run(...) β€” the same entry point the HTTP route calls. Policy evaluation lives inside run, so there is nothing to keep in sync. Details: MCP and AI.

Tool scopes

As of 2026-10. adminMcp() hands the MCP registry one scope per tool (adminToolScopes, packages/admin/src/mcp-scopes.ts). Tool names are unchanged.

Scope Tools
admin:read admin.<entity>.list, admin.<entity>.read, admin.search, a readonly action's admin.action.<name>
admin:write admin.<entity>.create, admin.<entity>.update, any other action β€” matching ones included
admin:destroy admin.<entity>.delete, a destructive action

A call is decided in this order:

# Check Refusal
1 the actor's own admin authz (adminMcpTools) β€” a tool it denies is absent X_MCP_TOOL_UNKNOWN β€” no such tool for this caller, whatever the token holds
2 the token's scope, before any argument is validated or a handler runs X_MCP_SCOPE_DENIED, naming the scope
3 the call's row-level decision inside the tool the tool's own refusal, audited

The token's scopes are tokenScopes on what adminMcp({ actor }) returns for a session (AdminMcpActor), expanded by adminTokenScopes: admin:destroy β‡’ admin:write β‡’ admin:read.

tokenScopes The token may call
absent everything its actor may β€” what every admin token was before tools carried a scope
['admin:read'] list, read, search and readonly actions β€” a read-only agent token
['admin:write'] the above, plus create, update and every non-destructive action
['admin:destroy'] every tool its actor may call
[] nothing β€” an empty list is a token granted nothing, never "unstated"
a non-admin scope (posts:write) dropped; the admin's tools read its own vocabulary only

Named tokenScopes, not scopes: core's Actor.scopes is a different thing, and an actor handed straight through must not have its [] read as "this token may do nothing". A stdio host builds its caller's scopes with adminTokenScopes(granted) too.

Theming

The generated admin reads the same 24 semantic colour roles as the app, through the same four blocks: light in :root, dark behind the media query, both mirrored under html[data-theme]. A raw hex in an admin stylesheet is the same raw-colour guard finding as anywhere else, and ThemeTokenRef is a template-literal type, so --x-* typechecks and a hex does not.

The /_x dev dashboard is a standalone page with no stylesheet pipeline, so it inlines its six channels β€” bg, surface-raised, fg, fg-muted, line, accent β€” into a <style> element. As of 2026-08 it derives them from @ultimat3/ui's colorTokens at render time rather than keeping a copy: the copy it used to keep went stale through the WCAG retune and shipped line on surface-raised at 1.16:1. The barrel is reached by dynamic import(), for the same reason the panels reach introspection that way β€” /_x stays out of the production graph β†’ Theming.

Concern Behavior
Theme follows the OS; explicit localStorage choice wins; applied before first paint β†’ Theming
Strings every label through t(). labelKey fields in the permission and field tables exist so no view holds a sentence. Every key a declaration derives β€” branding, nav, titles, fields, sections, scopes, columns, action labels (labelKey, absent: admin.action.<name>) and their input fields β€” is admin.catalogKeys(); x verify's i18n step asks each app catalog for exactly that list (X_CATALOG_MISSING_KEYS) β†’ I18n
Timestamps the admin refuses to render a timestamp without an IANA zone; the actor's zone comes from the session β†’ Timezones and dates
Money columns { minor, currency } formatted with Intl.NumberFormat; the raw minor value on hover β†’ Money

Render modes

One shape for every generated route, from one call site β€” adminRouteConfig in packages/admin/src/routes.ts, never an author's choice, As of 2026-08.

Key Value Why
render ssr behind auth and with no SEO value, but it has to be correct on first byte. It was spa until 6.0.0 deleted that mode: nothing built the client bundle it preloaded and renderSpa never read the route's component, so every generated view served an empty <div id="x-root">
offline network-only an operator acting on stale operational data is worse than an error
hydrate never every control on a generated screen is a link or a native <form method="post"> β€” paging, search, the forms, row actions β€” so the page ships zero JavaScript and has nothing to boot. It also means a generated screen declares no island: an island rendered on a route at never is X_ISLAND_NOT_HYDRATED, whose fix is to remove the never
policy permissions[0], always the author never writes this defineRoute call, so the author cannot omit the guard; an empty permission list is X_ADMIN_PAGE_UNGUARDED at construction

Interactivity on an admin URL is an app file that claims the route β€” An admin page in your own shell β€” where hydrate derives from the islands it declares.

The request timeline and the live query inspector are not admin routes: they are /_x panels (panel-timeline.ts, panel-live.ts), mounted by devDashboard(), which throws rather than mounting when ROLE and the environment say production. The live panel reads registered live queries and their subscribers out of introspection β€” @ultimat3/admin does not depend on @ultimat3/realtime and subscribes to nothing.

See Routes and render modes.

Extending it

Own routes in your own app. Never fork the framework.

Want Do
A custom screen pages: [{ path, titleKey, permissions, component }] on defineAdmin β€” same route table, same guard, framed in the shell. x g admin:page <name> writes it beside the declaration, under apps/admin/app/admin/. A path the admin already serves (/jobs/* included) is X_ADMIN_PAGE_PATH_INVALID
A custom screen in your own shell, or with an island the pages: entry above, plus a page.tsx at its path whose config is claimAdminRoute(admin, path, …) β†’ An admin page in your own shell
A different control for one field resources: { <entity>: { fields: { <name>: { widget } } } } β€” one of the AdminWidget kinds in fields.ts; a new column kind is a row in that table, not an app hook
A custom operation, on one row or many actions: [{ name, permission, entity, input?, when?, batch?, matching?, readonly?, destructive?, handle }] on defineAdmin β€” a button on the detail and each list row, a row in the batch bar with batch, and an MCP tool admin.action.<name>. Grant its permission in the role map, or the policy step is X_PERMISSION_UNGRANTED
Different columns in a list resources: { <entity>: { listFields, labelField, columns } } β€” x g resource <name> --admin writes the override, with listFields read off the entity, and lists it in defineAdmin()
Tabs, or a second audience scopes: and rows: on the resource β€” never a second resource
A secret column .sealed() on the entity: never listed, shown, searched or prefilled; the form writes it through a password input, and an empty box on an edit leaves it unchanged
Branding defineTheme({ colors, radius, font }) β€” the one override seam, validated not escaped β†’ Theming
Hide an entity the entity's policy denies admin:read β€” visibility is authz, not configuration

No plugin API, in 1.x, 2.x, 3.x or 4.0.0 (axiom). The extension point is that the admin is your app.

An admin page in your own shell

claimAdminRoute(admin, path, options) β€” the one way an app file serves an admin path, As of 2026-10. For a console that keeps the app's own chrome (theme switch, language switch, an enrolment banner) or needs an island on a page: every other admin route ships no script.

// apps/admin/app/admin/credits/page.tsx β€” the file at the page's own path
import { type AdminApp, type AdminClaimData, claimAdminRoute } from '@ultimat3/admin';
import { island } from '@ultimat3/render';
import type { JSX } from 'solid-js';

declare const admin: AdminApp; // `import { admin } from '../admin'` β€” the defineAdmin() module
declare function AppShell(props: {
  readonly titleKey: string | null;
  readonly children: JSX.Element;
}): JSX.Element;

interface ShellData {
  readonly role: string | null;
  readonly orgId: string | null;
}

// Islands FIRST: the claim's defineRoute drains them, which is how the page hydrates.
const GrantForm = island({ src: './credits-grant.island.tsx', props: ['orgId'] });

export const config = claimAdminRoute(admin, `${admin.basePath}/credits`, {
  hydrate: 'idle',
  budget: { js: '60kb' },
  // The app's half of the data, read AFTER the screen decided β€” the refused included (`denied`).
  load: ({ ctx }): ShellData => ({
    role: ctx.actor.roles?.[0] ?? null,
    orgId: ctx.actor.orgId ?? null,
  }),
});

export function Page(props: { readonly data: AdminClaimData<ShellData> }): JSX.Element {
  return (
    <AppShell titleKey={props.data.denied ? null : props.data.titleKey}>
      {props.data.body}
      <GrantForm orgId={props.data.app.orgId} />
    </AppShell>
  );
}
The app passes The admin keeps
load β€” the shell's own data; withStatus(404, …) from it sets the page's status render: 'ssr', offline: 'network-only', private no-store
hydrate, budget, navigation policy β€” the route's admin:read, decided by the pipeline before load
the page component: the shell around data.body meta: the route's title, noindex, nofollow on every answer
load: the SAME screen the catch-all serves β€” the page's permission pair decided with auth.actor's actor, a refusal audited and answered 403 (data.denied, the author's component never called), the body without AdminLayout
  • Only a pages: entry or the dashboard (admin.basePath). A generated screen posts its forms back to its own URL, which only the admin answers: X_ADMIN_PAGE_PATH_INVALID, naming what may be claimed. A claimed dashboard leaves POST /admin (app-wide actions) to the admin.
  • Only the config claimAdminRoute issued, at the path it was issued for. A hand-written defineRoute on an admin path β€” or defineRoute({ ...claimed, load }) β€” is X_ROUTE_DUPLICATE, as before: the admin answers render's claimable(path, config) by identity.
  • An island inside the pages: component is rendered by the component, but declared by the claiming file (the island's src resolves against it): hand it down β€” a module-level slot the file binds, or a prop of the shell.
  • x routes lists the path once: the file, with defineAdmin() Β· <permissions>.

Deployment

Fact Detail
Role ROLE=web, the same process and image as the app β€” serve-web.ts mounts every declared admin beside the app's routes (adminMountRoutes)
Scaling the web role's: stateless, on RPS
Health the web role's /healthz, /readyz and SIGTERM drain
Isolation none of its own: the admin is a path prefix (basePath) on the app's host. Restricting it to a network or a hostname is the ingress's job
A batch past its threshold admin.batch jobs, run by the worker role; a worker process that never ran defineAdmin refuses the chunk with X_ADMIN_MOUNT_MISSING

See Deployment.

Rules

  • One declaration, mounted by the framework. A hand-written page for an admin URL is a second server for it; a file serves one only through claimAdminRoute, under the admin's guard.
  • One authz seam. The UI never shows what the call would refuse.
  • Destructive operations confirm and audit β€” enforced by the permission table, not by a view.
  • Read-only SQL, capped. No write console.
  • Keyset pagination only.
  • A list's state is its URL; a parameter the resource does not derive is refused, never ignored.
  • A sealed column is never a filter, a scope predicate, a label or a computed column's input.
  • Every write emits an audit diff with sensitive fields redacted.
  • MCP runs with the human's own permissions. Projecting an action into an app MCP surface is opt-in β€” mcp: { expose: true }, the one rule every other surface reads too; inside the admin's own catalog, and nowhere else, a registered action opts out with mcp: { expose: false }.
  • Extend with pages:, resources: and actions:, never by forking @ultimat3/admin.
  • No island: every control is a link or a native form, and the admin ships zero JavaScript.

Source: packages/admin/src

⚠️ **GitHub.com Fallback** ⚠️