The shared panel envelope follows
protocol v1 §1 (frozen; contract fixture
fixtures/protocol/envelope.json).
Every Inertia page rendered inside a panel receives one shared prop:
import type { AdoniaEnvelope } from '@adonia/core'
const props = {
adonia: {
protocolVersion: 1,
panel: {
id: 'admin',
brand: { name: 'Acme CMS', logoUrl: '/admin/assets/logo.svg', accent: 'violet' },
// Resolved §13.5 theming; `darkMode: 'class'` enables the topbar toggle.
theme: { darkMode: 'system', cssVariables: {} },
// §1 nav tree, already filtered by `viewList`.
navigation: [
{ type: 'item', label: 'Posts', icon: 'file-text', url: '/admin/posts', active: true },
],
user: { id: 1, name: 'Ada Lovelace', email: 'ada@acme.test', avatarUrl: null },
urls: { dashboard: '/admin', search: '/admin/search', logout: '/admin/logout' },
},
// Absent keys are OMITTED, never serialized as `undefined`.
flash: { success: 'Post published.' },
},
} satisfies AdoniaEnvelopePage-specific props (descriptor, records, record, state) sit beside
adonia, never inside it.
Where it comes from
packages/core/src/http/middleware/inertia_middleware.ts — AdoniaInertiaMiddleware.
The route registrar applies it to every panel route group, outside the
adonia.panel-access gate, so the login screen and the panel-chromed
403/404 pages carry the same chrome as everything else. A host app registers
nothing: no kernel edit, no stub.
It shares a thunk, not an object: the envelope is built only when a page actually renders, so the JSON endpoints of §10.1 pay nothing for it.
What each block is derived from
| Block | Source | Degrades to |
|---|---|---|
panel.id, panel.brand |
Panel.make(id).brand({...}) (§5.1) |
title-cased id, logoUrl: null, the default accent |
panel.theme |
panel.theme({ darkMode, cssVariables }) (§13.5) |
{ darkMode: 'system', cssVariables: {} } |
panel.navigation |
buildNavigation() over the resource registry + the panel’s navigation() callback |
resources the user may not viewList are omitted, and groups left empty vanish |
panel.user |
the panel’s guard(), else the default guard |
null for guests, absent auth, unknown guard names, or a model without a scalar id |
panel.urls |
route names adonia.<panelId>.{dashboard,search,logout} through router.urlBuilder.urlFor (domain-mounted panels use the <domain>@<name> identifier) |
panel.pathTo(...) when the route name is not registered or no router is reachable |
flash |
session.flashMessages keys success / error |
{} — a key with no string value is omitted, never undefined |
Navigation visibility comes from the container binding adonia.authorizer
(ADONIA_AUTHORIZER_BINDING). While no authorizer is bound the predicate
allows everything, which matches the Phase-1 all-true ability projection of
the descriptor compiler.
Defensive by contract
A share provider runs during rendering, including error pages rendered
before the middleware stack finished. Missing ctx.session, ctx.auth,
ctx.inertia, ctx.containerResolver, a guard that throws, or a user model
whose accessors throw all produce a smaller envelope — never an exception. An
unresolved ctx.adonia.panel shares nothing at all ({}), because a page
outside a panel is not an Adonia page.
Using it from an app-owned Inertia middleware
Apps that prefer to merge the envelope into their own share() (rather than
relying on the group middleware) can drive the same class directly:
import type { HttpContext } from '@adonisjs/core/http'
import BaseInertiaMiddleware from '@adonisjs/inertia/inertia_middleware'
import type { PageProps } from '@adonisjs/inertia/types'
import AdoniaInertiaMiddleware from '@adonia/core/middleware/inertia'
const adonia = new AdoniaInertiaMiddleware()
export default class InertiaMiddleware extends BaseInertiaMiddleware {
async share(ctx: HttpContext): Promise<PageProps> {
const envelope = await adonia.share(ctx)
return { ...envelope, errors: this.getValidationErrors(ctx) } as unknown as PageProps
}
}share(ctx) returns Partial<AdoniaEnvelope>; spreading it twice is
harmless because it is rebuilt per call and contains no functions.
The double assertion is a nominal-typing artifact, not a data problem.
Inertia’s PageProps is Record<string, JSONDataTypes>, and TypeScript
grants an implicit index signature to type aliases only — PanelEnvelope
and SerializedPanelUser are interfaces, so they fail that constraint even
though every member is JSON by construction (protocol §2, the Inertia page contract). The
group middleware never meets it: it hands the thunk to inertia.share()
through Adonia’s own structural slice of the renderer, which is not bound by
PageProps.
Client side
@adonia/ui’s PanelShell consumes AdoniaEnvelope and hard-fails on a
protocolVersion mismatch (protocol §1). packages/core/src/types.ts and
packages/ui/src/types.ts carry the same shape; the fixture test in
packages/core/tests/inertia_share.spec.ts asserts the server half against
the frozen fixture.
Tenant chrome (panel.tenant, §5.3) is Phase 3 and deliberately absent.