---
title: "The adonia page envelope"
image: "https://adonia.pages.dev/og.png"
version: "next"
---

> Documentation Index
> Fetch the complete documentation index at: https://adonia.pages.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# The adonia page envelope

The shared panel envelope follows
[protocol v1 §1](/protocol/v1) (frozen; contract fixture
[`fixtures/protocol/envelope.json`](https://github.com/rikoriswandha/adonia/blob/main/fixtures/protocol/envelope.json)).

Every Inertia page rendered inside a panel receives one shared prop:

```ts
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.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:

```ts
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 `interface`s, 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.

Source: https://adonia.pages.dev/panel-envelope/index.mdx
