Adonia tenancy is fail-closed. A tenant panel needs both a resolver and an address source: a dynamic domain(':tenant.example.com') or tenantParam('tenant'). The tenant middleware runs before panel access and every controller. Returning null or throwing produces the panel 404.
Resolve the tenant
import { Panel } from '@adonia/core'
interface Tenant {
id: number
slug: string
name: string
}
declare function findTenant(slug: string): Promise<Tenant | null>
export const admin = Panel.make('admin')
.path('/admin')
.tenantParam('tenant')
.tenant((ctx) => findTenant(String(ctx.params.tenant)))
.tenantSwitcher((_ctx, tenant) => {
const current = tenant as Tenant
return [
{
id: current.id,
label: current.name,
url: `https://${current.slug}.example.com/admin`,
current: true,
},
]
})For a host-mounted panel, use .domain(':tenant.example.com') and read the dynamic route/domain parameter in the resolver. Switcher destinations must be absolute HTTP(S) URLs; Adonia rejects duplicate ids, unsafe protocols, and non-absolute destinations.
The resolved value is ctx.adonia.tenant. It reaches resource queries, descriptor compilation, brand/navigation callbacks, search, widgets, authorization, and validator metadata.
Scope every resource
On a tenant panel, every reachable resource must declare static tenantScope:
import { BaseResource, type SchemaBuilder, type SchemaComponent } from '@adonia/core'
import Post from '#models/post'
export default class PostResource extends BaseResource<typeof Post> {
static override model = Post
static override slug = 'posts'
static override tenantScope = 'tenantId'
override schema(_schema: SchemaBuilder): SchemaComponent[] {
return []
}
}The string form adds where('tenantId', tenant.id), stamps the key on create, and verifies ownership before update, delete, restore, and action execution. A function form is available for composite or indirect ownership:
import {
BaseResource,
type SchemaBuilder,
type SchemaComponent,
type TenantScope,
} from '@adonia/core'
import Post from '#models/post'
interface Tenant {
id: number
}
const scopePosts: TenantScope = (query, tenant) => {
query.where('tenantId', (tenant as Tenant).id)
}
export default class ScopedPostResource extends BaseResource<typeof Post> {
static override model = Post
static override slug = 'scoped-posts'
static override tenantScope = scopePosts
override schema(_schema: SchemaBuilder): SchemaComponent[] {
return []
}
}static tenantScope = false is an explicit global-resource opt-out and is logged at boot. Omitting the declaration is E_ADONIA_UNSCOPED_RESOURCE; do not use false to silence the doctor for tenant-owned data.
Validation and ownership
Framework unique/exists checks receive tenantId and edit recordId through validator metadata. Validator cache identity remains resource slug plus mode; never compile a tenant id into a cached Vine schema. Custom validation that reads the database must use the same resolved tenant and explicit allowlists.
Tenant scoping applies to lists, detail reads, relations, global search, widgets, actions, and JSON endpoints. Application policies still authorize the actor inside the tenant; scoping and authorization solve different problems.
Routing and cookies
Path example: /admin/acme/posts. Host example: https://acme.example.com/admin/posts. Cross-host links and switcher options must be absolute.
Host-only session cookies isolate subdomains by default. Configure an apex cookie such as .example.com only when cross-subdomain SSO is intended, and configure the XSRF cookie consistently. Dynamic local domains also need matching Vite server.allowedHosts; HMR may need an explicit public host. Run:
node ace adonia:doctor --only=cookie-domain --only=vite-allowed-hosts --only=shield-xsrf --only=unscoped-resourcesSee Deployment and security for proxy, cookie, cache, upload, queue, and secret requirements, and Nested resources for composing parent and tenant scopes.