This page documents the current panel builder and route contract.
A panel is the top-level unit of an Adonia install: a mount (a path, a domain,
or both), an auth guard, an access verdict, chrome, a navigation tree, and a
resource registry. The builder is pure configuration — it never touches the
router, the container, or a request. Three readers pick it back up: the
registrar turns it into routes, the adonia.panel-access middleware enforces
its gates, and the Inertia share hook projects it into the
panel envelope every page receives.
Inputs are validated at builder-call time, not at boot. A typo reports the
offending key with a path-qualified E_ADONIA_INVALID_CONFIG message
(panel(admin).brand.name must be a non-empty string) the moment the module is
imported.
Creating a panel
Panel.make(id) is the only constructor; the class constructor is private.
export default Panel.make('admin').path('/admin')The id is a route-name segment — every route of the panel is named
adonia.<id>.<suffix> — so it must match PANEL_ID_PATTERN:
/^[a-z][a-z0-9-]*$/Lowercase ASCII first, then lowercase, digits and hyphens. No underscores, no
uppercase, no leading digit, no dots. Anything else throws
InvalidConfigException at make() time:
[E_ADONIA_INVALID_CONFIG] panel id "Admin_Panel" must match /^[a-z][a-z0-9-]*$/ (e.g. 'admin', 'blog-admin')The id is also the fallback wordmark: with no brand({ name }), blog-admin
renders as Blog Admin.
Ids are unique per application, and so are mounts — see Registering the panel.
Mounting
Panel.make('admin').path('admin') // → mountPath '/admin'
Panel.make('root').path('/') // → mountPath '/'
Panel.make('tenant').domain('admin.acme.com') // → mountPath '/' by defaultpath() normalizes: a missing leading slash is added, trailing slashes are
stripped, and '/' survives as itself. domain() accepts an exact host or one
dynamic label such as :tenant.admin.example.com. Matching strips the port,
ranks exact hosts before dynamic patterns, then selects the longest matching
mount path. If a more-specific domain pattern misses the path, lower-ranked
matching patterns are still considered before path-only panels. No match
returns null. A panel that only sets domain() mounts at / on that host.
Paths derived from the mount are read back off the panel: pathTo(relative),
dashboardPath (the mount itself, and the post-login redirect target),
loginPath, logoutPath.
The route group
The registrar creates one group per panel and applies, in this order:
.domain(pattern) when domain-mounted, .prefix(path) when the mount is not
/, then the middleware stack. The stack is two nested groups rather than one
flat list, because §19 carves out exactly one exception — there are no
unauthenticated Adonia routes except login:
group( domain? · prefix? · [adonia.panel, share hook, error boundary, ...panel.middleware] )
├── login · login.attempt · logout ← reachable as a guest
└── group( [adonia.panel-access] ) ← guard + access verdict
└── dashboard · search · pages.show · resources.* · field.*Four slots run before anything of yours:
| Slot | Kind | Does |
|---|---|---|
adonia.panel |
inline closure | Publishes the resolved panel as ctx.adonia.panel. Inline because it is the one middleware that must close over a specific panel. |
| share hook | inline | Registers the §9.1 envelope provider on the request’s Inertia instance. Outside the gate: the login screen and the chromed 403/404 need the same chrome. |
| error boundary | inline | Renders a foreign error escaping a panel route as the panel-chromed page. Inside the share hook, outside everything below, so a throw from your middleware or the gate is chromed too. |
...panel.middleware |
yours | Everything passed to middleware(), in array order. |
adonia.panel-access |
named | Authenticates against the panel’s guard, then runs the access() verdict. Applied to the inner group only. |
The §5.2 stack names a separate guardMiddleware slot before the access gate.
There is no such middleware: adonia.panel-access performs the authentication
itself (ctx.auth.use(panel.guardName)) and then the verdict, so the effective
order is ['adonia.panel', ...panel.middleware, 'adonia.panel-access'] with
authentication folded into the last entry. Adonia parses its own
adonia.panel-access through router.named() inside the registrar, so a host
app needs no start/kernel.ts edit for panels to be guarded.
Every route receives an explicit .as('adonia.<panelId>.…') name. v7 auto-names
controller routes, and an auto-name can collide with the host app’s own when
Router.commit() runs — an explicit name is what keeps a panel from breaking an
application it was merely added to. Registration is idempotent per router
instance, so an in-process reboot cannot produce duplicate names.
Two panels must never resolve to the same (domain, path) pair; that is
rejected earlier, at registration time, with E_ADONIA_PANEL_CONFLICT. The
registrar never sees a conflicting pair.
The route table
Paths are relative to the mount; names are suffixes of adonia.<panelId>..
These three are registered first and sit outside the access gate, and only
when the login flow is enabled:
| Method | Path | Name suffix | Controller |
|---|---|---|---|
GET |
login |
login |
AuthController.showLogin |
POST |
login |
login.attempt |
AuthController.attempt |
POST |
logout |
logout |
AuthController.logout |
Everything else sits behind adonia.panel-access:
| Method | Path | Name suffix | Controller |
|---|---|---|---|
GET |
/ |
dashboard |
DashboardController.show |
GET |
search |
search |
GlobalSearchController.show |
GET |
pages/:page |
pages.show |
CustomPageController.show |
GET |
actions/status/:executionId |
actions.status |
ActionController.status |
GET |
widgets/:widget |
widgets.data |
WidgetController.data |
GET |
:resource |
resources.index |
ResourceController.index |
GET |
:resource/lens/:lens |
resources.lens |
ResourceController.lens |
GET |
:resource/create |
resources.create |
ResourceController.create |
POST |
:resource |
resources.store |
ResourceController.store |
GET |
:resource/field/:field/options |
field.options |
FieldCallbackController.options |
POST |
:resource/field/:field/upload |
field.upload |
FieldCallbackController.upload |
POST |
:resource/actions/:action |
actions.run |
ActionController.run |
GET |
:resource/:id/relations/:relation |
resources.relations.index |
RelationController.index |
GET |
:resource/:id/relations/:relation/create |
resources.relations.create |
RelationController.create |
POST |
:resource/:id/relations/:relation |
resources.relations.store |
RelationController.store |
GET |
:resource/:id/relations/:relation/attach/options |
resources.relations.attachOptions |
RelationController.attachOptions |
POST |
:resource/:id/relations/:relation/attach |
resources.relations.attach |
RelationController.attach |
GET |
:resource/:id/relations/:relation/:child/edit |
resources.relations.edit |
RelationController.edit |
PUT |
:resource/:id/relations/:relation/:child |
resources.relations.update |
RelationController.update |
GET |
:resource/:id |
resources.show |
ResourceController.show |
GET |
:resource/:id/edit |
resources.edit |
ResourceController.edit |
PUT |
:resource/:id |
resources.update |
ResourceController.update |
DELETE |
:resource/:id |
resources.destroy |
ResourceController.destroy |
POST |
:resource/:id/restore |
resources.restore |
ResourceController.restore |
Row order is part of the contract, not presentation. The static rows precede
/:resource, or /admin/pages/reports would resolve as resource pages,
record reports; :resource/create precedes :resource/:id, or
/admin/posts/create would be a resources.show with id = 'create'. v7’s
matcher prefers static segments and is more forgiving than that, but the table
is asserted in the stricter order so it stays correct if it is ever handed to a
different matcher.
Authentication and access
Panel.make('admin')
.path('/admin')
.guard('web')
.login({ model: () => import('#models/user') })guard(name) names a guard from config/auth.ts. The access gate calls
ctx.auth.use(name).check(); the login flow calls .login(user) on the same
guard.
Why login() needs a model
§5.1 declares login(opts?: { enabled?, page? }). That is not enough. Laravel
guards verify credentials (Auth::attempt); AdonisJS guards do not —
SessionGuard exposes login(user), check(), authenticate(), logout()
and nothing that turns an email plus a password into a user. The credential
check lives on the model, as withAuthFinder’s static verifyCredentials,
and the guard’s user provider — the only object that knows which model backs the
guard — is private. A library cannot derive the model from guard('web') alone,
so the panel asks for it once:
| Option | Type | Default |
|---|---|---|
enabled |
boolean |
true once guard() is set |
page |
string |
'adonia/login' (DEFAULT_LOGIN_PAGE) |
model |
() => Promise<{ default: CredentialsVerifiable }> |
— |
verify |
(ctx, { uid, password }) => user | null |
— |
uidField |
string |
'email' |
verify is the escape hatch for apps that do not use withAuthFinder — LDAP, an
SSO exchange, a custom hash. Return the user to sign in, or null for a
mismatch; never throw for a bad password, because a throw propagates as a 500.
Whatever you return goes straight to auth.use(guard).login(user), so it must be
what that guard’s provider accepts.
Panel.make('admin')
.guard('web')
.login({
uidField: 'username',
verify: (_ctx, { uid, password }) => verifyAgainstDirectory(uid, password),
})A panel whose login flow is enabled but configured with neither model nor
verify still registers GET login — the gate needs somewhere to redirect
guests to — and throws E_ADONIA_INVALID_CONFIG naming the missing option on
POST login. Nothing silently accepts or rejects a password.
The packaged flow is deliberately dull. attempt reads body[uidField] and
body.password; an empty one, or a mismatch, flashes inputErrorsBag under the
uid field and redirects back with the same message either way — a per-field
“unknown email” is an account-enumeration oracle. On success it signs the user
in, regenerates the session id after the guard has written its payload (which
closes the fixation window without losing the stored user id), and redirects to
dashboardPath. logout signs out, clears and regenerates the session, and
returns to the login screen.
Set login({ enabled: false }) for a panel behind SSO or an app-owned login
page: no /login, /logout or POST /login is registered at all.
access()
guard() decides who is signed in; access() decides who may enter this panel.
Panel.make('admin')
.guard('web')
.access((ctx) => ctx.auth.user instanceof User && ctx.auth.user.role === 'admin')A panel without an access() callback admits every authenticated user. A false
verdict is a 403: the panel-chromed adonia/error page for browsers,
{ code, message } for JSON endpoints. A guest is a redirect to loginPath, or
a 401 body on a JSON endpoint. Both are coarse gates — what a user may do
inside the panel is authorization, resolved per resource.
Brand and theme
Panel.make('admin')
.brand({
name: 'Blog Admin',
logo: '/admin/logo.svg',
favicon: '/favicon.ico',
accent: '#c2410c',
})
.theme({
darkMode: 'class',
cssVariables: { '--adonia-radius': '0.25rem' },
})| Option | Type | Default |
|---|---|---|
brand.name |
non-empty string, required |
title-cased id (blog-admin → Blog Admin) |
brand.logo |
non-empty string |
null |
brand.favicon |
non-empty string |
null |
brand.accent |
non-empty string |
DEFAULT_PANEL_ACCENT, oklch(54.1% 0.17 265) |
theme.darkMode |
'system' | 'class' | 'off' |
'system' |
theme.cssVariables |
Record<`--adonia-${string}`, string> |
{} |
Both are read back resolved — every optional replaced by a value or null —
so the envelope serializes without re-deriving defaults
on the client. Repeated calls merge per key rather than replacing the bag.
Token names are checked against PANEL_CSS_VARIABLE_PATTERN
(/^--adonia-[a-z0-9-]+$/) and rejected otherwise: the Tailwind preset maps
Adonia tokens only, so an app-specific custom property here would silently do
nothing. What the tokens are, how the accent seeds the OKLCH ramp, and what each
dark-mode strategy does on the client is Theming.
Resources
Panel.make('admin').path('/admin').resources({
posts: () => import('#adonia/resources/post_resource'),
})A registry is slug → () => Promise<{ default: ResourceClass }> — the shape of
the generated .adonisjs/adonia/resources.ts, which default-exports an object
literal satisfies ResourceRegistryInput with one lazy import per resource,
sorted by slug. The scaffolded panel imports it relatively
(import registry from '../../../.adonisjs/adonia/resources.js') because the
generated file sits outside the app’s # subpath map; because the file uses
satisfies rather than an annotation, registry.posts keeps its precise type
and a subset stays typo-proof.
// The whole generated registry…
Panel.make('admin').path('/admin').resources(registry)
// …or a subset, for a second panel that should see less.
Panel.make('editor')
.path('/editor')
.resources({ posts: registry.posts, categories: registry.categories })The registry is copied on the way in, and a second resources() call
replaces it rather than merging.
Nothing validates slugs at definition time, because the registry is generated
and the URL is not. A URL slug the registry does not carry throws
UnknownResourceException (E_ADONIA_UNKNOWN_RESOURCE) at request time: the
chromed 404 page, or { code, message } on a JSON endpoint. A slug named in
navigation() is the opposite case — that one is authored, so it is a
definition-time throw.
§6.2 sketches per-panel presentation overrides
(panel.resources(registry, { posts: { navigationGroup: 'Content' } })). That
second argument does not exist: resources() takes the registry only, and a
resource presents the same statics in every panel it is registered on.
Writing the resources themselves is Resources.
Navigation
Without navigation() the sidebar is derived from the registry. Each
resource contributes one item, labelled by labels.plural (or derived from the
slug), iconed by navigationIcon, linked to pathTo(slug), nested under
navigationGroup, ordered by navigationSort, and dropped entirely when
static hidden is set.
Ordering is one comparison for items and groups alike — a group weighs as much
as its lightest child — so navigationSort orders a group against a loose item
instead of sorting the two in separate passes. Ties keep registry order. An item
is active when the current path equals its URL or descends from it, compared
by segment, so /admin/post-tags never lights up for /admin/posts.
navigation() replaces that tree; it does not decorate it. The builder starts
empty, so the order on screen is the order written:
Panel.make('admin')
.path('/admin')
.resources(registry)
.navigation((nav) => {
nav.group('Content', ['posts', 'categories'])
nav.item('Docs', { url: 'https://acme.dev/docs', icon: 'book' })
nav.group('System', (child) => {
child.resource('tags')
child.item('Health', { url: '/admin/health', active: false })
})
nav.resources() // everything not placed above
})| Method | Effect |
|---|---|
item(label, { url, icon?, active? }) |
A free-form link: a custom page, an external URL, a report. icon serializes as null when omitted; active defaults to the segment match. |
group(label, slugs | (nav) => void) |
A group from a slug list, or from a nested builder for mixed contents. |
resource(slug) |
One registry resource. |
resources() |
Every visible resource not yet placed, in derived order. |
Call nav.resources() at the end unless you mean to hide what you did not
place: it is what keeps a newly generated resource from silently vanishing from
a curated sidebar. Placement is tracked across nested builders, so a resource
placed by hand is never repeated.
Two rules govern the output, and both are omission rules:
- A resource the viewer cannot
viewListis absent. Notvisible: false— absent. There is no hidden node on the wire to enumerate (protocol §2 rule 3). - A group left with no visible children disappears with them, rather than rendering an empty heading.
Visibility arrives as an injected predicate — buildNavigation({ panel, currentPath, canViewList }) — so the tree is built per request against the real
abilities of the user asking for it.
Two shapes ARCHITECTURE §7.1 sketches do not exist. Groups have no icon and no
URL: protocol v1 froze the group node as { type, label, children }. And
there is no nav.item(...).route(...): item() takes a path or an absolute
URL, while framework-built resource, tenant, and panel links use the
domain/tenant-aware URL builders.
A slug that is not in the registry at all is an authoring mistake, and
nav.resource('postz') throws immediately:
[E_ADONIA_INVALID_CONFIG] navigation references resource "postz", which is not in the panel's registryA slug that is registered but hidden, or invisible to this user, is skipped in silence — that is the omission rule, not a typo.
Middleware
import { middleware } from '#start/kernel'
Panel.make('admin').path('/admin').middleware([middleware.auth()])middleware() takes values, not app middleware names. §5.2 declares
middleware(names: string[]); that cannot work. router.named(collection) in
v7 is “not registered anywhere, but instead converted in a new collection of
functions you can apply on the routes” — the factories it returns are local to
the module that called it, normally start/kernel.ts. There is no global name →
middleware registry a library can consult, so 'auth' is not resolvable from
@adonia/core. Accepting it and dropping it would disable an auth middleware an
app believed it had applied, so an unresolvable name fails at panel-definition
time with a message pointing at the working form.
An entry is one of three things:
| Entry | Example |
|---|---|
| One of Adonia’s own names | 'adonia.panel', 'adonia.panel-access' |
| A middleware function | async (ctx, next) => next() |
| A parsed named middleware | middleware.auth() |
ADONIA_MIDDLEWARE_NAMES is that first set, exported as a const tuple:
import { ADONIA_MIDDLEWARE_NAMES } from '@adonia/core'
const names: readonly string[] = ADONIA_MIDDLEWARE_NAMES // ['adonia.panel', 'adonia.panel-access']They are accepted for symmetry and then dropped by the registrar, which
applies them regardless — applying adonia.panel-access twice would run the
guard and the access verdict twice per request.
Custom pages, dashboards, plugins, defaults
pages() registers custom panel pages, and GET /pages/:page serves them
today:
Panel.make('admin').path('/admin').pages([() => import('#adonia/pages/reports')])Each entry must be a lazy import; the controller reads three statics off the default export, structurally:
| Static | Required | Default |
|---|---|---|
slug |
yes | — (the :page segment) |
component |
no | 'adonia/page' |
title |
no | the slug |
A page module without a usable slug is skipped rather than throwing — one
malformed page must not take the whole panel’s pages.show route down. A slug
no page claims is E_ADONIA_UNKNOWN_PAGE, a 404. Custom pages retain their
structural slug, component, and title contract; resource actions, lenses,
and dashboard widgets do not change it.
defaults() overrides framework behaviour for one panel. Repeated calls stack
as layers: a later call wins per key, and unspecified keys keep falling through
to config/adonia.ts and then to the framework defaults. Values are validated
against the same rules as the config file, with panel(<id>).defaults.* message
paths.
Panel.make('admin')
.path('/admin')
.defaults({ perPage: 50, pagination: 'cursor' })
.defaults({ redirectAfterCreate: 'index' })dashboard(builder) is consumed by GET /: widgets are resolved through one
grouped deferred prop, rendered on the twelve-column dashboard grid, and
refetched through widgets.data when a range changes:
import { ValueMetric } from '@adonia/core'
Panel.make('admin').dashboard((dashboard) => {
dashboard.widget(
ValueMetric.make('users', async () => ({ value: 42, previous: 38 }))
.label('Users')
.placement(4)
.ranges([{ key: '30d', label: '30 days' }])
.cacheFor(5)
)
})plugins([...]) installs named plugins into the ordered server hook bus during panel
boot. Tenant panels use tenant() with a dynamic domain or tenantParam() address
source; see the complete fail-closed resolver, scoping and switcher contract in
Tenancy.
Registering the panel
A panel lives in app/adonia/panels/*.ts and is the module’s default export.
Nothing imports it by hand:
- With
panelsset inconfig/adonia.ts, each entry is imported as a module specifier, in the order listed. - With
panelsleftundefined— the default — the provider readsapp/adonia/panels/, keeps.tsand.jsfiles, sorts them by name, and imports each as a file URL. A missing directory means “no panels yet”, not an error.
A module whose default export is not a Panel instance fails boot with
E_ADONIA_INVALID_CONFIG naming the specifier. PanelManager.register then
rejects a duplicate id, or a second panel resolving to the same
(domain, path) mount, with E_ADONIA_PANEL_CONFLICT. Registration order is
preserved.
Routes are registered in the provider’s boot(), never ready(). In the
web environment AdonisJS v7 commits the route store inside
Application.start() — after preloads, before provider ready() hooks — and
Router.commit() is one-shot, so a route added during ready() would silently
never reach the matcher and every panel URL would 404 under
node ace serve --hmr. boot() runs before that commit in every environment
(web, test, console), and the router binding already exists by then: the
framework’s app provider registers it during the register phase, which precedes
all boot() hooks. TECH_SPEC §2.1 still says registration happens “during
ready”; the code is right and the spec is superseded.
With no panel registered the provider skips route wiring entirely.
The whole surface, as one file — app/adonia/panels/admin_panel.ts, with
registry imported from the generated .adonisjs/adonia/resources.ts:
import { Panel } from '@adonia/core'
export default Panel.make('admin')
.path('/admin')
.guard('web')
.login({ model: () => import('#models/user') })
.access((ctx) => ctx.auth.user !== undefined)
.brand({ name: 'Blog Admin' })
.theme({ darkMode: 'system' })
.resources(registry)
.navigation((nav) => {
nav.group('Content', ['posts', 'categories'])
nav.resources()
})Everything this produces on the client — brand, navigation, flash, user, abilities — travels in one shared object: the panel envelope.