Skip to content

The adonia page envelope

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 AdoniaEnvelope

Page-specific props (descriptor, records, record, state) sit beside adonia, never inside it.

Where it comes from

packages/core/src/http/middleware/inertia_middleware.tsAdoniaInertiaMiddleware. 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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close