[data-reveal]{opacity:1!important;transform:none!important}
Pages and Modules
Interfaces

Pages and Modules

Pages are controller classes registered into a navigation tree of modules and categories, with their own routes, layouts and extensions.

Overview

A page is a controller class. PageController(id, options, layout?) builds a base class bound to a URL, @RegisterPage() records it, and the components declared as static fields on the class become the page's serialized layout. Categories group pages into the sidebar tree, and modules group a whole set of pages and categories under their own root.

Building your first screen? Follow Pages & components first. This reference covers registration, identifiers, and lifecycle rules. The guide also shows page-extension placement in the running playground.

Everything on this page lives in @antelopejs/interface-dms/page.

Registration happens at import time: the @RegisterPage() decorator runs when the file is loaded, so the order in which you import page files determines the order in which pages, categories and their permissions are registered.

Declare a Page

PageController(id, options, layout?) returns a class extending Controller(fullSlug). Extend it, decorate with @RegisterPage(), and declare components as static fields.

import {
  Category,
  PageController,
  RegisterPage,
  pagesCategory,
} from "@antelopejs/interface-dms/page";
import { TableView } from "@antelopejs/interface-dms/base";
import { invoiceDataAPI } from "../data-api/invoices";

export const billing = Category("billing", {
  displayName: "Billing",
  icon: "i-ph-receipt",
  category: pagesCategory,
});

@RegisterPage()
export class InvoicesPage extends PageController("invoices", {
  displayName: "Invoices",
  icon: "i-ph-file-text",
  category: billing,
  order: 10,
}) {
  static table = TableView(invoiceDataAPI, { caption: "Invoices" });
}

PageController requires either category or module in its options and throws otherwise. Since the returned class extends Controller, route decorators from @antelopejs/interface-api work on it directly — a route declared on the page class is served under the page's slug.

The third argument is the page layout, a ComponentInfo produced by a layout builder such as DefaultLayout() from @antelopejs/interface-dms/base. When you omit it, the page uses its own layout option, then the layout option of the nearest category or parent page declaring one, then DefaultLayout().

OptionTypeDescription
displayNamestringLabel shown in the sidebar. Required.
categoryCategoryInfo | ControllerClassParent category. A CategoryInfo from Category/RootCategory/RegisterModule, or a controller class produced by PageController/RootPageController.
modulestringId of a module registered with RegisterModule. Attaches the page to that module's tree.
urlSlugstringURL segment for this entry. Defaults to id.
descriptionstringLonger description carried in the page metadata.
ordernumberSort order among siblings.
iconstringIcon name.
hiddenbooleanKeep the entry out of the menu. Inherited from the resolved category when that category is hidden.
permissionPartial<Permission> | ActionOverride the auto-derived page permission, or bind the page to an existing component Action. See Permissions.
publicAccessbooleanServe the page without authentication. Inherited from the resolved category.
authOnlybooleanRequire a logged-in user but no permission check. Inherited from the resolved category.
memberAccessboolean | "self"Every signed-in member holds the page, its components and their actions without a role grant: their permissions register as defaultGranted, so guards still run and the Roles editor leaves them out. true is inherited by what is declared under the entry, page or category. "self" opens the entry alone: the settings root declares it, so it opens itself, not the pages declared under it.
typeMenuItemType"label" or "link".
validationPageValidationPreconditions for opening the page.
setupIdstringId of a page setup function to run while the page is mounted.
noComponentPermissionsbooleanDo not register per-component permissions for this page.
queryMenuItemQueryQuery parameters carried by the entry's menu link.
variantMenuItemVariantSemantic look of the entry.
statusMenuItemStatusSemantic state dot rendered at the end of the entry.
badgestringShort count or label at the end of the entry (3, new); wins over status and over a counted navigation badge.
layoutComponentInfoDefault layout of the entry and of every page and sub-category declared under it that sets none of its own. The nearest declaring ancestor wins; a page's layout argument always overrides it.
bypassTenantAccessGatebooleanKeep the page reachable while a tenant access gate denies the tenant. Inherited by the pages of a flagged category.
livePageLiveOptionsGives the page a Live / Pause state over the realtime of its blocks: { maxRate?, slowRateAfter?, rememberKey? }. maxRate is the shortest interval in seconds between two refetches of one block, and the polling interval while the realtime stream is down; slowRateAfter (PageLiveSlowRate: { period: PageLivePeriod, maxRate }, period like "7d") a slower one once the period shown spans period; rememberKey a key sharing the remembered choice between pages. Read on pages only. A rate that is not a positive number, a period not reading like 30m / 24h / 7d, or an empty rememberKey throws at declaration.

MenuItemType is "label" | "link".

PageValidation has three optional fields: requiredQueryParams (a string[] of query parameters the page needs), customFunctionId (the id of a registered validation function) and redirectTo (where a page failing its validation sends the user instead of answering "Page not found": a dashboard path, with the page URL's {{params.X}} / {{query.X}} tokens; see Page setup and validation).

Menu Entry Appearance

Three types describe how an entry looks, so a consumer never ships raw classes:

type MenuItemVariant = "default" | "accent";
type MenuItemStatus = "success" | "warning" | "error" | "neutral";
type MenuItemQuery = Record<string, string>;

accent renders the entry in the theme's primary color, for a call-to-action entry such as "New project". status maps to the theme's semantic colors.

query appends parameters to the entry's link, which is what lets a single page serve many entries — declare it with validation.requiredQueryParams, then point several entries at it:

@RegisterPage()
export class ProjectPage extends PageController("project", {
  displayName: "Project",
  category: pagesCategory,
  validation: { requiredQueryParams: ["project"] },
  query: { project: "acme-website" },
}) {}

Reaching a Page Behind a Tenant Gate

bypassTenantAccessGate is the page-level mirror of the guard option of the same name (Permissions). It covers page visibility, menu visibility and the page's own layout route, and a flagged category passes it to its pages. Permission checks still apply — unlike authOnly, which drops them entirely.

It does not cover the data routes feeding the page: a data API is not owned by a single page, so it opts out through its own guard, or through TableViewOptions.bypassTenantAccessGate for a table view. Reserve the flag for recovery surfaces — billing, a payment portal — that a blocked tenant must still reach in order to unblock itself.

Group Pages with Categories

Category(id, options) returns a CategoryInfo you pass as another entry's category. Categories nest, and they require a category or a module of their own.

import {
  Category,
  pagesCategory,
} from "@antelopejs/interface-dms/page";

export const crm = Category("crm", {
  displayName: "CRM",
  icon: "i-ph-users",
  category: pagesCategory,
});

export const contacts = Category("contacts", {
  displayName: "Contacts",
  category: crm,
});

Creating a category registers a permission keyed to its fullId, unless the category sits inside a module — see Permissions.

Root Categories

RootCategory(id, options) creates a top-level group next to the built-in ones. Its options are MenuOptions without category and module. type defaults to "label". The ids pages, modules and settings are reserved and throw.

import { RootCategory } from "@antelopejs/interface-dms/page";

export const catalogCategory = RootCategory("catalog", {
  displayName: "Catalog",
  icon: "i-ph-books",
  order: 9,
});

The interface exports three built-in roots:

ExportKindURL slug
pagesCategoryCategoryInfo/
modulesCategorycontroller class (RootPageController)/modules
settingsCategorycontroller class (RootPageController)/settings

settingsCategory declares layout: SettingsLayout(), so every page under it renders in the settings frame unless it passes a layout of its own. workspaceSettingsCategory (Category("workspace", …) under settingsCategory, full id settings.workspace, URL /settings/workspace) holds the workspace settings — members, invitations, roles — and is where a project adds its own (Settings pages).

modulesCategory is the module root: every module registered with RegisterModule attaches under it. A page or category that declares a module cannot resolve to a category under settingsCategory; that combination throws.

RootPageController

RootPageController(id, options, layout?) builds a page controller at the root of the tree. Its options are MenuOptions without category and module, plus an optional isModuleRoot boolean. Unlike PageController it does not require a parent, which is what makes it usable for a top-level landing screen.

Identifiers a Page Carries

Category, PageController and RootPageController compute two identifiers from the entry's own id and its parent chain:

  • fullId — the dot-joined chain of ids, starting at the root category (pages.crm.contacts). This is the page's permission id and the key realtime topics are registered under.
  • fullSlug — the joined URL slugs (/crm/contacts).

CategoryInfo extends MenuOptions with category redeclared as a required CategoryInfo | ControllerClass | undefined holding the resolved parent, and adds id, fullId, fullSlug and an optional isModuleRoot. PageInfo extends CategoryInfo and adds layoutUrl, the URL the page's component tree is served from — /dms/pages, the page's fullSlug, then /pagelayout.

A page controller (PageController, RootPageController) serves its routes under PAGE_ROUTE_PREFIX (/dms/pages), not at the page's own path: the frontend server answers that path with the page and proxies only the backend prefixes declared to it, /api/** and /dms/** for the DMS. @Get("/sessions") on the page /settings/user/security is GET /dms/pages/settings/user/security/sessions, which pageRoutePath("/settings/user/security", "/sessions") spells out. A plain Controller a module mounts for its frontend sits under /api/<module>/ instead, or under a prefix it declares (backendProxies, below).

GetPermissionId(target) returns the permission id recorded for a page controller class or a Component, or undefined when the target was never registered. A child component resolves to its full positional path. When the same component instance is mounted more than once, every position registers a permission but the instance resolves to its first registered position, because the instance alone cannot identify which mount the caller means.

import { GetPermissionId } from "@antelopejs/interface-dms/page";

const pagePermission = GetPermissionId(InvoicesPage); // "pages.billing.invoices"

Declare a Module

A module is a self-contained feature area with its own root in the sidebar. RegisterModule(info) creates that root under modulesCategory and returns its CategoryInfo.

import { RegisterModule } from "@antelopejs/interface-dms/page";

RegisterModule({
  id: "automation",
  title: "$dms_automation.module.title",
  description: "$dms_automation.module.description",
  icon: "i-ph-flow-arrow",
  defaultCategory: { displayName: "$dms_automation.module.pages", order: 0 },
});

Call RegisterModule before any page that references the module id: a page resolves its module synchronously inside the PageController call of its class declaration, before @RegisterPage() runs, and an unknown module id throws. Registering the same id twice throws as well.

ModuleInfo

OptionTypeDescription
idstringModule id. Also the URL segment under /modules.
titlestringDisplay name of the module root.
descriptionstringDescription carried on the module root.
iconstringIcon name.
landingPagestringId of the page the module opens on; /modules/<id> redirects to it, query and hash kept. Defaults to the first page by order.
catalogCategorystringCategory the module is filed under in the module catalog ("Content", "Developer"…), may be a $ i18n key; without one it goes under "Other". Unrelated to menu categories (MenuOptions.category, defaultCategory). Formerly category.
defaultCategory{ displayName?, icon?, order?, urlSlug? }Groups the module's pages that declare module without an explicit category. Without it, those pages attach directly to the module root.

The generated default category uses urlSlug "pages" unless you override it, and falls back to the label "$menu.section.pages" when displayName is empty or omitted.

Attaching Pages to a Module

Set module on the page options. category stays available to group pages inside the module:

import {
  PageController,
  RegisterPage,
} from "@antelopejs/interface-dms/page";
import "./module"; // ensures RegisterModule ran first

@RegisterPage()
export class OverviewPage extends PageController("overview", {
  displayName: "$dms_automation.overview.title",
  icon: "i-ph-gauge",
  module: "automation",
  order: 0,
}) {}

When a page declares both module and an explicit category, the category must be a descendant of that module's root; otherwise registration throws.

Module URLs and Detection

MODULE_URL_PREFIX is "/modules". Module pages resolve under /modules/<moduleId>/... because the module root's urlSlug is the module id and its parent is modulesCategory. Their ids follow the same chain: the module root's fullId is modules.<moduleId>, and a page in it is modules.<moduleId>.<pageId>.

isInsideModule(entry) answers whether an entry belongs to a module. It accepts anything carrying module, category and isModuleRoot — a CategoryInfo or PageInfo qualifies — and walks the category chain looking for a module root.

import { isInsideModule } from "@antelopejs/interface-dms/page";

if (isInsideModule(pageInfo)) {
  // module pages are gated to platform owners
}

Permissions belonging to a module entry are marked module-scoped instead of being registered in the grantable permission tree. Permissions covers what that implies.

Contribute Menu Entries at Request Time

Everything above declares the menu at import time. A dynamic menu provider adds entries resolved per request instead — one entry per project, per workspace, per whatever the tenant happens to own.

import { RegisterDynamicMenuProvider } from "@antelopejs/interface-dms/page";

RegisterDynamicMenuProvider("pages.projects", async (user, tenantId) => {
  const projects = await listProjects(tenantId);
  return projects.map((project, index) => ({
    id: project.slug,
    displayName: project.name,
    fullSlug: "/projects/:projectId",
    params: { projectId: project.id },
    icon: "i-ph-folder",
    order: index,
  }));
});
function RegisterDynamicMenuProvider(
  categoryFullId: string,
  resolver: DynamicMenuResolver,
): () => void;

type DynamicMenuResolver = (
  user: User | undefined,
  tenantId: string,
  permissions: Set<string>,
) => MaybePromise<DynamicMenuItem[]>;

A registration is a DynamicMenuProviderInfo — the categoryFullId and the resolver, paired. The resolver runs during /sitelayout, and its entries are grafted onto that request's copy of the tree only — the global navigation tree is never touched, so one tenant's entries can never reach another's site layout. Dynamic and static children of the category sort together by order.

permissions is the caller's effective permission set as the parent category sees it: empty while a tenant access gate denies the tenant, unless the category is flagged bypassTenantAccessGate, in which case the real set is kept. user is undefined for an unauthenticated request.

A resolver that throws is skipped with a warning and the rest of the menu still resolves. The registration is removed when the registering module unloads; the returned function drops it earlier, for the rare case a module stops providing while staying loaded.

DynamicMenuItem

An item is not a page. It links to an already registered page, which is why one page — typically one whose slug has :name segments, or one with validation.requiredQueryParams — can back any number of entries.

params fills the :name segments of fullSlug, URL-encoding each value, and the entry links to the result: fullSlug: "/projects/:projectId" with params: { projectId: "acme" } links to /projects/acme. The values must fill every segment and nothing else. An entry that leaves a segment empty, or names a parameter the slug does not have, is skipped with a warning rather than linking to a literal :projectId.

FieldTypeDescription
idstringUnique within the provider's category, and becomes the entry's fullId suffix. Dots separate fullId levels, so an id containing one is skipped with a warning. Required.
displayNamestringEntry label. Required.
fullSlugstringFull slug of the page the entry links to, :name segments included. Required.
paramsMenuItemParamsValues for the :name segments of fullSlug.
queryMenuItemQueryQuery parameters narrowing that page.
descriptionstringLonger description.
iconstringIcon name.
ordernumberSort order among the category's children, dynamic and static alike.
variantMenuItemVariantSemantic look.
statusMenuItemStatusSemantic state dot.
permissionIdstringPermission required to see the entry, as a raw id. Unset means always visible.

An entry is listed only when the caller can access the page it points at, so it never offers a link its target would refuse.

permissionId is a raw id here, unlike MenuOptions.permission, which takes a whole permission or an Action. It is matched against the permission set the caller's browser actually receives, the way the browser matches it — the wildcard or a literal grant, never a defaultGranted fallback — so the sidebar never offers an entry whose controls would be inert. While a gate denies the tenant that set holds only the surfaces flagged bypassTenantAccessGate, so a recovery entry declares its permission under its target page, as in <recoveryPageFullId>.settle.

NotifyMenuChanged

const NotifyMenuChanged: (tenantId?: string) => void;

Call it when the data behind a provider changes — a project created or deleted — so connected clients resync. The signal carries no data: it is an invalidation, and each client re-fetches its own /sitelayout, resolved against its own permissions.

Pass the tenantId whose data changed so only that tenant's sessions re-fetch. Omitting it invalidates the menu for every connected session, which is meant for changes that are not tenant-scoped.

Extend a Page from Another Module

@RegisterPageExtension(targetPageId) injects components into a page owned by someone else. The decorated class is not a page: every static component field on it becomes a component of the target page, keyed by its field name.

import { RegisterPageExtension } from "@antelopejs/interface-dms/page";
import { CustomComponent } from "@antelopejs/interface-dms/base/custom";

@RegisterPageExtension("settings.workspace.members")
export class SeatQuotaExtension {
  static seatQuota = CustomComponent("SeatQuotaBanner")
    .meta({ name: "Seat quota" })
    .before("table");
}

The target is named by its page id — the fullId the page registered under, which is also its permission id: the dotted path of its categories followed by its own id (settings.workspace.members for the DMS members page). Nothing of the owning module is imported, so an extending module depends on @antelopejs/interface-dms alone and never owns another module's registrations. GetRegisteredPageIds() lists the ids currently registered, and GetPermissionId(ThePageClass) returns one from inside the module that owns the page.

Because the field name is the component key, an injected component is permissioned as <target fullId>.<field name> in the target page's own permission tree — exactly like a component the target declares itself. The target's own fields and every extension of it share that one namespace.

Placement is declared on the component with .before(anchor) / .after(anchor), where the anchor is a component key of the target page — "table" for a static field of the page, "content.tasks" for a position nested under one. Without one, the component is appended after the page's own components. When several land at the same spot .order(n) decides, and equal orders fall back to the extension class name and then to declaration order — never to module start order. Components covers those three builder methods.

The injection applies as soon as the target page has finished registering — it is held until then whichever module starts first, so anchors always resolve against the page's complete component set — and is removed when the extending module stops. Its components are prepared as the extending module — whichever module's registration triggers it, and again each time the target page registers anew — so what they register (their permissions, native upload fields, form sub-pages, realtime topics) belongs to the extending module: it survives a reload of the DMS or of the target's module, and goes away with the extending module. An extension class declaring no component field is a warning, not an error, and injects nothing; an empty target id throws.

Nothing about the target can be checked when the decorator runs, so everything is checked when the injection applies: a page id no module registers keeps the extension pending and is reported as a warning once every module has started, while a key the page (or another extension) already uses and an anchor the page does not declare are reported at error level — with the extension name, the target id and the offending key — and that extension alone is skipped.

interface PageExtensionInfo {
  extensionName: string;
  targetFullId: string;
  components: PageExtensionComponent[];
}

interface PageExtensionComponent {
  key: string;
  component: Component;
  side: PlacementSide;
  anchorKey?: string;
  order: number;
}

PageExtensionInfo is the registration handle: it is the object that gets unregistered when the extending module stops. key is the static field name the component was declared under, and also the last segment of its permission id on the target page.

Page Layouts

A page layout is the serialized description of the page's components:

interface PageLayout<T = unknown> {
  components: Record<string, ComponentInfoSerialized<T>>;
  layout?: ComponentInfo<T>;
}

components is keyed by the static field name on the page class; each value is the serialized form of the component (Components). layout is the layout component passed as PageController's third argument.

Registering a page installs a PageLayoutHandler for its fullSlug:

type PageLayoutHandler = (
  user: User | undefined,
  memberModel: TenantMemberModel,
  roleModel: RoleModel,
  tenantId: string,
) => Promise<PageLayout>;

The handler drops the components the caller has no permission for and applies each component's own filter. Native upload tokens are bound to the exact page/component mount during page registration, not stamped by this handler; upload and metadata requests still check live authorization (File storage). A page with publicAccess, authOnly or noComponentPermissions skips the filtering step and returns every component. Look a handler up with GetPageLayoutBySlug(slug), which returns undefined for an unknown slug:

import { GetModel } from "@antelopejs/interface-database-decorators";
import {
  type PageLayout,
  GetPageLayoutBySlug,
} from "@antelopejs/interface-dms/page";
import {
  RoleModel,
  TenantMemberModel,
} from "@antelopejs/interface-dms/db";
import type { User } from "@antelopejs/interface-dms/auth/db";

async function renderLayout(
  slug: string,
  user: User | undefined,
  tenantId: string,
): Promise<PageLayout | undefined> {
  const handler = GetPageLayoutBySlug(slug);
  return handler?.(
    user,
    GetModel(TenantMemberModel, tenantId),
    GetModel(RoleModel, tenantId),
    tenantId,
  );
}

ClearPageLayoutBySlug(slug) drops the handler again. A page unregisters through it: the handler is reachable by slug from /dms/pagelayout, so leaving it behind keeps serving the layout of a page that is already gone from the registry, the navigation tree and its own route — an unloaded module's screens would still answer.

PageMetadata

PageMetadata is the per-controller record the decorator fills in. GetMetadata(controllerClass, PageMetadata) returns it. It holds pageInfo, layout and components, and exposes:

MemberDescription
SetInfo(id, fullSlug, menuOptions, pageLayout?)Compute and store the PageInfo and the layout.
SetComponent(id, component)Attach a component under a key.
inherit(parent)Merge another PageMetadata's info, layout and components into this one.
Register()Register the page: permissions, layout handler, /pagelayout route and auth decorators.
targetThe controller class the metadata belongs to.

@RegisterPage() does this for you: it scans the class's own static properties, records every Component it finds under its property name, and calls Register(). Static properties whose name starts with _ are skipped, which is how you keep a helper value on the class without turning it into a layout entry. Reach for PageMetadata directly only when you generate pages programmatically.

Registration also runs each component's onCreated callback with this record, and registers a permission per component and per component action unless the page opts out (Permissions).

Page Access

Registering a page installs an auth decorator on the controller, chosen from the options:

OptionsDecorator installed
publicAccess: truenone — the page is served to anonymous callers
authOnly: trueAuthUser from @antelopejs/interface-dms/auth: a logged-in user, no permission check
page under /modules/AuthOwnerOnly from @antelopejs/interface-dms/auth: module pages are platform-owner-only
defaultAuthUserWithPermission(pageClass) — the page's own permission (Permissions)

On a page that is neither publicAccess nor authOnly, registration also binds roleModel, memberModel and tenantId properties on the controller, scoped to the request's tenant (Tenancy). Routes you declare on the page class inherit that gating: a route under a default page already requires the page's permission.

Declaring the injected property on the class is enough to read it in a handler — the provider runs per request whether or not the handler reads it:

import { Get } from "@antelopejs/interface-api";
import {
  PageController,
  RegisterPage,
} from "@antelopejs/interface-dms/page";
import type { User } from "@antelopejs/interface-dms/auth/db";
import { crm } from "./categories";

@RegisterPage()
export class MyContactsPage extends PageController("mine", {
  displayName: "My contacts",
  category: crm,
}) {
  declare user: User;

  @Get("/count")
  async count() {
    return { owner: this.user._id };
  }
}

Page Setup Functions

setupId names a function that runs while the page is mounted. The backend only carries the id; the function itself is registered on the frontend side and receives a PageSetupContext.

type PageSetupFunction = (
  context: PageSetupContext,
) => MaybePromise<PageSetupCleanup | void>;
Member of PageSetupContextDescription
pageInfoThe PageInfo of the page being mounted.
permissionsSet<string> of the caller's effective permission ids.
on(component, event, handler)Subscribe to a component event. Returns an unsubscribe function.
emit(component, event, data?)Dispatch an event on the shared event bus.

PageSetupCleanup is () => void, returned by the setup function to release what it allocated when the page unmounts. MaybePromise<T> is T | Promise<T>, re-exported here from @antelopejs/interface-dms/types.

import {
  PageController,
  RegisterPage,
} from "@antelopejs/interface-dms/page";
import { billing } from "./category";

@RegisterPage()
export class LifecyclePage extends PageController("lifecycle", {
  displayName: "Lifecycle",
  category: billing,
  setupId: "billing:invoice-lifecycle",
}) {}

Ship an Inertia/Vue Frontend Module

AddFrontendModule(config) registers a frontend module your module ships. Call it from your module's construct().

import path from "node:path";
import { AddFrontendModule } from "@antelopejs/interface-dms/page";

export async function construct(): Promise<void> {
  await AddFrontendModule({
    name: "@acme/dms-billing",
    sourcePath: path.join(__dirname, "../frontend-vue"),
    renderer: { name: "vue", version: "3" },
    configKey: "dmsBilling",
    priority: 0,
    options: { prefix: "Billing" },
  });
}

AddFrontendModuleOptions

OptionTypeDescription
namestringLogical frontend module name. Registry identity also includes renderer name and major version. Required.
sourcePathstringAbsolute path to the frontend module directory. Required.
rendererFrontendRendererMetadataFramework the module is written for, as a name and a version compared by major only. Use { name: "vue", version: "3" } for the Inertia/Vue frontend. The loader releases the module runs on are declared in the package.json of sourcePath instead (Frontend layer).
optionsRecord<string, any>Values handed to the layer's configuration. Defaults to {}.
privateOptionsRecord<string, any>A second set of values handed to the layer, kept separate from options. Defaults to {}.
prioritynumberOrdering among the registered layers. Defaults to 0.
configKeystringConfiguration key the layer is published under.
authEstablishEndpointsstring[]Backend routes (/api/…) the frontend server may open a session from on the module's behalf (its /auth/establish route).
publicEndpointsFrontendPublicEndpoint[]Backend routes the module serves to callers outside the dashboard — a provider's webhook. See below.
backendProxiesFrontendBackendProxy[]Backend prefixes outside /api/** and /dms/** the browser reaches through the frontend server ({ path: "/media/**", auth: "none" }); auth is "session" (the default, the session's token added server-side) or "none". A path is /x, /x/* or /x/**, its first segment literal; a malformed entry fails the registration.

Public endpoints

The frontend server's proxy asks every state-changing request for a same-site Origin and forwards it with the signed-in user's session, relaying only a few headers. A webhook a mail or payment provider calls through the dashboard's public URL fails both checks. Declare such a route public:

await AddFrontendModule({
  name: "@acme/dms-mailing",
  sourcePath: path.join(__dirname, "../frontend-vue"),
  renderer: { name: "vue", version: "3" },
  publicEndpoints: [
    {
      path: "/api/mailing/events/*/*",
      methods: ["POST"],
      headers: ["x-mailing-webhook-secret"],
    },
  ],
});

A request to a declared route needs no Origin, carries the declared headers, and is never sent the session, even from a signed-in browser: a cross-site form posting to it acts as nobody, and the route authenticates its caller itself (a shared secret, a signature). path is absolute under /api/, its first segment literal (the module's own prefix, never *), each later * matching exactly one segment; methods defaults to ["POST"]; headers names the module's own x- headers, those a proxy or the DMS gives a meaning to (x-forwarded-*, x-real-ip, x-dms-*, x-inertia*, method overrides) being refused. A malformed entry fails the registration. The declarations reach the frontend server through the manifest it is built from (an authenticated build only), and its DMS_PUBLIC_ENDPOINTS adds routes no module declares. It needs @antelopejs/dms-frontend with public endpoint support; an older one ignores the field.

Within one logical name, renderer, and major version, the highest-priority registration wins. Equal-priority registrations use the latest registration. The manifest and archive endpoints select one renderer with the paired renderer and rendererVersion query parameters. The version is matched by major version; unsupported selections return 404 rather than mixing renderer sources. Omitting both parameters remains an alias for Vue 3 clients.

Stamp Upload Tokens

StampUploadFieldTokens(options) takes a component's serialized options and returns them with a signed upload token stamped into every file and image field — the component's own fields, and the forms it embeds (a relation field's inline add form, a table view's new / edit / view forms) — binding each field to the storage and path it declares so a client cannot presign an upload elsewhere. The builders that carry upload fields attach it through transformOptions, and page registration stamps the authoritative declaration, so pages you declare with @RegisterPage() are covered already. Call it yourself only when you serialize and serve components outside that path: a token produced outside a registered page stays unbound and cannot authorize an upload.

import { StampUploadFieldTokens } from "@antelopejs/interface-dms/uploads";

const components = await StampUploadFieldTokens(layout.components);

Options holding no upload field come back untouched; otherwise the return value is a deep clone, since serialization runs again on a hot reload and each pass must start from the untransformed declaration.

Public URLs

GetApiBaseUrl() and GetClientBaseUrl() answer the public origins of the DMS API and of the dashboard, from the DMS configuration (apiBaseUrl, clientBaseUrl); under ajs dms dev they follow the dev server's origins. Build an absolute link on them rather than on the request's headers: behind a reverse proxy that forwards no X-Forwarded-Host, the request only knows the internal host.

import { GetApiBaseUrl } from "@antelopejs/interface-dms/api-base-url";
import { GetClientBaseUrl } from "@antelopejs/interface-dms/client-base-url";

const apiBase = await GetApiBaseUrl(); // "https://api.example.com"
const downloadUrl = `${apiBase}/i18n/site/fr.json`;
const inviteUrl = `${await GetClientBaseUrl()}/auth/signup`;

Both return string | undefined: undefined only when the configuration clears the value.

Client address

getClientIp(context) answers the address a request came from, resolved the way the DMS resolves the one it records on a session: behind the auth.trustedProxies reverse proxies of its configuration (Client address behind proxies), each trusted hop vouching for the x-forwarded-for entry it appended and nothing left of it, and an IPv4-mapped address (::ffff:10.0.0.1) written as plain IPv4. Key a rate limit or an anonymous visitor on it rather than on x-forwarded-for read by hand: the operator declares the proxies once, for the DMS and every module.

import { Context, Controller, Post, type RequestContext } from "@antelopejs/interface-api";
import { getClientIp } from "@antelopejs/interface-dms/client-ip";

export class CollectController extends Controller("/api/collect") {
  @Context()
  declare requestContext: RequestContext;

  @Post("/")
  async collect(): Promise<void> {
    const address = await getClientIp(this.requestContext); // "109.132.199.8"
  }
}

It answers an empty string when the request carries no address. ResolveClientIp({ socketAddress, forwardedFor }) is the same resolution on values a module already holds, a request it queued for later say.

SaveComponentFiles

SaveComponentFiles<T>(request: AttachmentSaveRequest, save: (promoted: Record<string, unknown>) => Promise<AttachmentSaveResult<T>>): Promise<T> comes from @antelopejs/interface-dms/attachments. Call it from an authorized write route to validate native-file provenance, promote staging keys, and persist through save. The file-storage guide covers the form/upload flow and storage authorization.

AttachmentSaveRequest contains:

FieldTypeContract
contextRequestContextThe authorized server request.
componentIdsstring[]Trusted component identities, not ids supplied by the client.
fieldsAttachmentField[]Server-declared attachment fields.
submittedRecord<string, unknown>The submitted values.
beforeRecord<string, unknown>Previously persisted values, loaded by the server.

Each AttachmentField has id (matching the form's attachmentField), key (the document field), and kind: "file" | "image". Optional storage, visibility: "private" | "public", and constraints: UploadConstraints select the provider and upload policy; UploadConstraints comes from @antelopejs/interface-file-storage.

The callback returns AttachmentSaveResult<T>: { document, result }, where document contains the actual saved values used for cleanup, and result is returned to the caller. Removed native files are cleaned only after successful persistence; cleanup failures are logged. Failed saves leave promoted objects intact because another save may reference them. Cleanup is not reference-counted: callers must avoid unsafe sharing of file keys. Internal attachment validators and TableView save proxies are not consumer entry points.

Next Steps

Components covers the builders that produce the values you assign to those static fields.