@antelopejs/interface-dms 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +70 -68
  3. package/dist/auth/db/models/sessions.model.d.ts +1 -0
  4. package/dist/auth/db/models/userExternalIdentities.model.d.ts +1 -0
  5. package/dist/auth/db/models/users.model.d.ts +1 -0
  6. package/dist/base/block-registry/index.d.ts +1 -0
  7. package/dist/base/block-registry/index.js +1 -0
  8. package/dist/base/block-registry/index.js.map +1 -1
  9. package/dist/base/block-registry/presentation.d.ts +21 -0
  10. package/dist/base/block-registry/presentation.js +108 -0
  11. package/dist/base/block-registry/presentation.js.map +1 -0
  12. package/dist/base/block-types.d.ts +1 -0
  13. package/dist/base/block-types.js +1 -0
  14. package/dist/base/block-types.js.map +1 -1
  15. package/dist/base/live-toggle.d.ts +2 -2
  16. package/dist/base/period-selector.d.ts +2 -2
  17. package/dist/base/placeholder.js +2 -2
  18. package/dist/base/placeholder.js.map +1 -1
  19. package/dist/base/table-view/schema.d.ts +4 -4
  20. package/dist/db/internal/exportJobs.model.d.ts +1 -0
  21. package/dist/db/internal/inviteResolutions.model.d.ts +1 -0
  22. package/dist/db/internal/tenantLifecycle.model.d.ts +1 -0
  23. package/dist/db/models/roles.model.d.ts +1 -0
  24. package/dist/db/models/tenantMembers.model.d.ts +1 -0
  25. package/dist/db/models/tenants.model.d.ts +1 -0
  26. package/dist/db/models/user_invites.model.d.ts +1 -0
  27. package/docs/.navigation.yml +2 -0
  28. package/docs/1.core/.navigation.yml +2 -0
  29. package/docs/1.core/1.introduction.md +169 -0
  30. package/docs/1.core/10.layout-banners.md +133 -0
  31. package/docs/1.core/2.pages-and-modules.md +649 -0
  32. package/docs/1.core/3.components.md +287 -0
  33. package/docs/1.core/4.permissions.md +279 -0
  34. package/docs/1.core/5.tenancy.md +358 -0
  35. package/docs/1.core/6.invites.md +239 -0
  36. package/docs/1.core/7.hooks.md +354 -0
  37. package/docs/1.core/8.realtime.md +147 -0
  38. package/docs/1.core/9.quick-actions.md +115 -0
  39. package/docs/2.auth/.navigation.yml +2 -0
  40. package/docs/2.auth/1.introduction.md +75 -0
  41. package/docs/2.auth/2.authentication.md +222 -0
  42. package/docs/2.auth/3.tokens-and-sessions.md +352 -0
  43. package/docs/2.auth/4.users.md +256 -0
  44. package/docs/2.auth/5.external-identities.md +122 -0
  45. package/docs/3.base/.navigation.yml +2 -0
  46. package/docs/3.base/1.introduction.md +202 -0
  47. package/docs/3.base/10.display-blocks.md +136 -0
  48. package/docs/3.base/2.types.md +402 -0
  49. package/docs/3.base/3.forms.md +349 -0
  50. package/docs/3.base/4.tables.md +608 -0
  51. package/docs/3.base/5.charts-and-widgets.md +466 -0
  52. package/docs/3.base/6.layout.md +437 -0
  53. package/docs/3.base/8.data-types.md +361 -0
  54. package/docs/3.base/9.helpers.md +414 -0
  55. package/docs/4.notifications/.navigation.yml +2 -0
  56. package/docs/4.notifications/1.introduction.md +81 -0
  57. package/docs/4.notifications/2.categories-and-subjects.md +214 -0
  58. package/docs/4.notifications/3.sending.md +285 -0
  59. package/docs/5.html-render/.navigation.yml +2 -0
  60. package/docs/5.html-render/1.introduction.md +386 -0
  61. package/docs/5.html-render/2.built-in-emails.md +157 -0
  62. package/docs/images/emails-admin-invite.png +0 -0
  63. package/docs/images/emails-change-notice.png +0 -0
  64. package/docs/images/emails-change-verification.png +0 -0
  65. package/docs/images/emails-digest-logo-dark.png +0 -0
  66. package/docs/images/emails-digest-logo-light.png +0 -0
  67. package/docs/images/emails-digest.png +0 -0
  68. package/docs/images/emails-export-partial.png +0 -0
  69. package/docs/images/emails-export-ready.png +0 -0
  70. package/docs/images/emails-reset-password.png +0 -0
  71. package/docs/images/emails-two-factor.png +0 -0
  72. package/docs/images/emails-user-validation.png +0 -0
  73. package/docs/index.md +109 -0
  74. package/docs/tsconfig.json +11 -0
  75. package/package.json +25 -18
@@ -0,0 +1,649 @@
1
+ ---
2
+ title: "Pages and Modules"
3
+ description: "Pages are controller classes registered into a navigation tree of modules and categories, with their own routes, layouts and extensions."
4
+ ---
5
+
6
+ # Pages and Modules
7
+
8
+ ## Overview
9
+
10
+ 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.
11
+
12
+ Building your first screen? Follow [Pages & components](/docs/building/pages-and-components) first. This reference covers registration, identifiers, and lifecycle rules. The guide also shows [page-extension placement](/docs/building/pages-and-components#extending-another-modules-page) in the running playground.
13
+
14
+ Everything on this page lives in `@antelopejs/interface-dms/page`.
15
+
16
+ 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.
17
+
18
+ ## Declare a Page
19
+
20
+ `PageController(id, options, layout?)` returns a class extending `Controller(fullSlug)`. Extend it, decorate with `@RegisterPage()`, and declare components as static fields.
21
+
22
+ ```typescript
23
+ import {
24
+ Category,
25
+ PageController,
26
+ RegisterPage,
27
+ pagesCategory,
28
+ } from "@antelopejs/interface-dms/page";
29
+ import { TableView } from "@antelopejs/interface-dms/base";
30
+ import { invoiceDataAPI } from "../data-api/invoices";
31
+
32
+ export const billing = Category("billing", {
33
+ displayName: "Billing",
34
+ icon: "i-ph-receipt",
35
+ category: pagesCategory,
36
+ });
37
+
38
+ @RegisterPage()
39
+ export class InvoicesPage extends PageController("invoices", {
40
+ displayName: "Invoices",
41
+ icon: "i-ph-file-text",
42
+ category: billing,
43
+ order: 10,
44
+ }) {
45
+ static table = TableView(invoiceDataAPI, { caption: "Invoices" });
46
+ }
47
+ ```
48
+
49
+ `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.
50
+
51
+ 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()`.
52
+
53
+ ### `MenuOptions`
54
+
55
+ | Option | Type | Description |
56
+ | ------ | ---- | ----------- |
57
+ | `displayName` | `string` | Label shown in the sidebar. Required. |
58
+ | `category` | `CategoryInfo \| ControllerClass` | Parent category. A `CategoryInfo` from `Category`/`RootCategory`/`RegisterModule`, or a controller class produced by `PageController`/`RootPageController`. |
59
+ | `module` | `string` | Id of a module registered with `RegisterModule`. Attaches the page to that module's tree. |
60
+ | `urlSlug` | `string` | URL segment for this entry. Defaults to `id`. |
61
+ | `description` | `string` | Longer description carried in the page metadata. |
62
+ | `order` | `number` | Sort order among siblings. |
63
+ | `icon` | `string` | Icon name. |
64
+ | `hidden` | `boolean` | Keep the entry out of the menu. Inherited from the resolved category when that category is hidden. |
65
+ | `permission` | `Partial<Permission> \| Action` | Override the auto-derived page permission, or bind the page to an existing component `Action`. See [Permissions](/docs/interfaces/core/permissions). |
66
+ | `publicAccess` | `boolean` | Serve the page without authentication. Inherited from the resolved category. |
67
+ | `authOnly` | `boolean` | Require a logged-in user but no permission check. Inherited from the resolved category. |
68
+ | `memberAccess` | `boolean \| "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. |
69
+ | `type` | `MenuItemType` | `"label"` or `"link"`. |
70
+ | `validation` | `PageValidation` | Preconditions for opening the page. |
71
+ | `setupId` | `string` | Id of a page setup function to run while the page is mounted. |
72
+ | `noComponentPermissions` | `boolean` | Do not register per-component permissions for this page. |
73
+ | `query` | `MenuItemQuery` | Query parameters carried by the entry's menu link. |
74
+ | `variant` | `MenuItemVariant` | Semantic look of the entry. |
75
+ | `status` | `MenuItemStatus` | Semantic state dot rendered at the end of the entry. |
76
+ | `badge` | `string` | Short count or label at the end of the entry (`3`, `new`); wins over `status` and over a counted navigation badge. |
77
+ | `layout` | `ComponentInfo` | Default 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. |
78
+ | `bypassTenantAccessGate` | `boolean` | Keep the page reachable while a tenant access gate denies the tenant. Inherited by the pages of a flagged category. |
79
+ | `live` | `PageLiveOptions` | Gives 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. |
80
+
81
+ `MenuItemType` is `"label" | "link"`.
82
+
83
+ `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](/docs/building/pages-and-components#page-setup-and-validation)).
84
+
85
+ ### Menu Entry Appearance
86
+
87
+ Three types describe how an entry looks, so a consumer never ships raw classes:
88
+
89
+ ```typescript
90
+ type MenuItemVariant = "default" | "accent";
91
+ type MenuItemStatus = "success" | "warning" | "error" | "neutral";
92
+ type MenuItemQuery = Record<string, string>;
93
+ ```
94
+
95
+ `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.
96
+
97
+ `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:
98
+
99
+ ```typescript
100
+ @RegisterPage()
101
+ export class ProjectPage extends PageController("project", {
102
+ displayName: "Project",
103
+ category: pagesCategory,
104
+ validation: { requiredQueryParams: ["project"] },
105
+ query: { project: "acme-website" },
106
+ }) {}
107
+ ```
108
+
109
+ ### Reaching a Page Behind a Tenant Gate
110
+
111
+ `bypassTenantAccessGate` is the page-level mirror of the guard option of the same name ([Permissions](/docs/interfaces/core/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.
112
+
113
+ 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.
114
+
115
+ ## Group Pages with Categories
116
+
117
+ `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.
118
+
119
+ ```typescript
120
+ import {
121
+ Category,
122
+ pagesCategory,
123
+ } from "@antelopejs/interface-dms/page";
124
+
125
+ export const crm = Category("crm", {
126
+ displayName: "CRM",
127
+ icon: "i-ph-users",
128
+ category: pagesCategory,
129
+ });
130
+
131
+ export const contacts = Category("contacts", {
132
+ displayName: "Contacts",
133
+ category: crm,
134
+ });
135
+ ```
136
+
137
+ Creating a category registers a permission keyed to its `fullId`, unless the category sits inside a module — see [Permissions](/docs/interfaces/core/permissions).
138
+
139
+ ### Root Categories
140
+
141
+ `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.
142
+
143
+ ```typescript
144
+ import { RootCategory } from "@antelopejs/interface-dms/page";
145
+
146
+ export const catalogCategory = RootCategory("catalog", {
147
+ displayName: "Catalog",
148
+ icon: "i-ph-books",
149
+ order: 9,
150
+ });
151
+ ```
152
+
153
+ The interface exports three built-in roots:
154
+
155
+ | Export | Kind | URL slug |
156
+ | ------ | ---- | -------- |
157
+ | `pagesCategory` | `CategoryInfo` | `/` |
158
+ | `modulesCategory` | controller class (`RootPageController`) | `/modules` |
159
+ | `settingsCategory` | controller class (`RootPageController`) | `/settings` |
160
+
161
+ `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](/docs/building/settings-pages)).
162
+
163
+ `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.
164
+
165
+ ### `RootPageController`
166
+
167
+ `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.
168
+
169
+ ## Identifiers a Page Carries
170
+
171
+ `Category`, `PageController` and `RootPageController` compute two identifiers from the entry's own id and its parent chain:
172
+
173
+ - `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.
174
+ - `fullSlug` — the joined URL slugs (`/crm/contacts`).
175
+
176
+ `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`.
177
+
178
+ 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).
179
+
180
+ `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.
181
+
182
+ ```typescript
183
+ import { GetPermissionId } from "@antelopejs/interface-dms/page";
184
+
185
+ const pagePermission = GetPermissionId(InvoicesPage); // "pages.billing.invoices"
186
+ ```
187
+
188
+ ## Declare a Module
189
+
190
+ 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`.
191
+
192
+ ```typescript
193
+ import { RegisterModule } from "@antelopejs/interface-dms/page";
194
+
195
+ RegisterModule({
196
+ id: "automation",
197
+ title: "$dms_automation.module.title",
198
+ description: "$dms_automation.module.description",
199
+ icon: "i-ph-flow-arrow",
200
+ defaultCategory: { displayName: "$dms_automation.module.pages", order: 0 },
201
+ });
202
+ ```
203
+
204
+ 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.
205
+
206
+ ### `ModuleInfo`
207
+
208
+ | Option | Type | Description |
209
+ | ------ | ---- | ----------- |
210
+ | `id` | `string` | Module id. Also the URL segment under `/modules`. |
211
+ | `title` | `string` | Display name of the module root. |
212
+ | `description` | `string` | Description carried on the module root. |
213
+ | `icon` | `string` | Icon name. |
214
+ | `landingPage` | `string` | Id of the page the module opens on; `/modules/<id>` redirects to it, query and hash kept. Defaults to the first page by `order`. |
215
+ | `catalogCategory` | `string` | Category 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`. |
216
+ | `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. |
217
+
218
+ 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.
219
+
220
+ ### Attaching Pages to a Module
221
+
222
+ Set `module` on the page options. `category` stays available to group pages inside the module:
223
+
224
+ ```typescript
225
+ import {
226
+ PageController,
227
+ RegisterPage,
228
+ } from "@antelopejs/interface-dms/page";
229
+ import "./module"; // ensures RegisterModule ran first
230
+
231
+ @RegisterPage()
232
+ export class OverviewPage extends PageController("overview", {
233
+ displayName: "$dms_automation.overview.title",
234
+ icon: "i-ph-gauge",
235
+ module: "automation",
236
+ order: 0,
237
+ }) {}
238
+ ```
239
+
240
+ When a page declares both `module` and an explicit `category`, the category must be a descendant of that module's root; otherwise registration throws.
241
+
242
+ ### Module URLs and Detection
243
+
244
+ `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>`.
245
+
246
+ `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.
247
+
248
+ ```typescript
249
+ import { isInsideModule } from "@antelopejs/interface-dms/page";
250
+
251
+ if (isInsideModule(pageInfo)) {
252
+ // module pages are gated to platform owners
253
+ }
254
+ ```
255
+
256
+ Permissions belonging to a module entry are marked module-scoped instead of being registered in the grantable permission tree. [Permissions](/docs/interfaces/core/permissions) covers what that implies.
257
+
258
+ ## Contribute Menu Entries at Request Time
259
+
260
+ 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.
261
+
262
+ ```typescript
263
+ import { RegisterDynamicMenuProvider } from "@antelopejs/interface-dms/page";
264
+
265
+ RegisterDynamicMenuProvider("pages.projects", async (user, tenantId) => {
266
+ const projects = await listProjects(tenantId);
267
+ return projects.map((project, index) => ({
268
+ id: project.slug,
269
+ displayName: project.name,
270
+ fullSlug: "/projects/:projectId",
271
+ params: { projectId: project.id },
272
+ icon: "i-ph-folder",
273
+ order: index,
274
+ }));
275
+ });
276
+ ```
277
+
278
+ ```typescript
279
+ function RegisterDynamicMenuProvider(
280
+ categoryFullId: string,
281
+ resolver: DynamicMenuResolver,
282
+ ): () => void;
283
+
284
+ type DynamicMenuResolver = (
285
+ user: User | undefined,
286
+ tenantId: string,
287
+ permissions: Set<string>,
288
+ ) => MaybePromise<DynamicMenuItem[]>;
289
+ ```
290
+
291
+ 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`.
292
+
293
+ `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.
294
+
295
+ 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.
296
+
297
+ ### `DynamicMenuItem`
298
+
299
+ 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.
300
+
301
+ `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`.
302
+
303
+ | Field | Type | Description |
304
+ | ----- | ---- | ----------- |
305
+ | `id` | `string` | Unique 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. |
306
+ | `displayName` | `string` | Entry label. Required. |
307
+ | `fullSlug` | `string` | Full slug of the page the entry links to, `:name` segments included. Required. |
308
+ | `params` | `MenuItemParams` | Values for the `:name` segments of `fullSlug`. |
309
+ | `query` | `MenuItemQuery` | Query parameters narrowing that page. |
310
+ | `description` | `string` | Longer description. |
311
+ | `icon` | `string` | Icon name. |
312
+ | `order` | `number` | Sort order among the category's children, dynamic and static alike. |
313
+ | `variant` | `MenuItemVariant` | Semantic look. |
314
+ | `status` | `MenuItemStatus` | Semantic state dot. |
315
+ | `permissionId` | `string` | Permission required to see the entry, as a raw id. Unset means always visible. |
316
+
317
+ 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.
318
+
319
+ `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`.
320
+
321
+ ### `NotifyMenuChanged`
322
+
323
+ ```typescript
324
+ const NotifyMenuChanged: (tenantId?: string) => void;
325
+ ```
326
+
327
+ 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.
328
+
329
+ 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.
330
+
331
+ ## Extend a Page from Another Module
332
+
333
+ `@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.
334
+
335
+ ```typescript
336
+ import { RegisterPageExtension } from "@antelopejs/interface-dms/page";
337
+ import { CustomComponent } from "@antelopejs/interface-dms/base/custom";
338
+
339
+ @RegisterPageExtension("settings.workspace.members")
340
+ export class SeatQuotaExtension {
341
+ static seatQuota = CustomComponent("SeatQuotaBanner")
342
+ .meta({ name: "Seat quota" })
343
+ .before("table");
344
+ }
345
+ ```
346
+
347
+ 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.
348
+
349
+ 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.
350
+
351
+ 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](/docs/interfaces/core/components) covers those three builder methods.
352
+
353
+ 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.
354
+
355
+ 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.
356
+
357
+ ```typescript
358
+ interface PageExtensionInfo {
359
+ extensionName: string;
360
+ targetFullId: string;
361
+ components: PageExtensionComponent[];
362
+ }
363
+
364
+ interface PageExtensionComponent {
365
+ key: string;
366
+ component: Component;
367
+ side: PlacementSide;
368
+ anchorKey?: string;
369
+ order: number;
370
+ }
371
+ ```
372
+
373
+ `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.
374
+
375
+ ## Page Layouts
376
+
377
+ A page layout is the serialized description of the page's components:
378
+
379
+ ```typescript
380
+ interface PageLayout<T = unknown> {
381
+ components: Record<string, ComponentInfoSerialized<T>>;
382
+ layout?: ComponentInfo<T>;
383
+ }
384
+ ```
385
+
386
+ `components` is keyed by the static field name on the page class; each value is the serialized form of the component ([Components](/docs/interfaces/core/components)). `layout` is the layout component passed as `PageController`'s third argument.
387
+
388
+ Registering a page installs a `PageLayoutHandler` for its `fullSlug`:
389
+
390
+ ```typescript
391
+ type PageLayoutHandler = (
392
+ user: User | undefined,
393
+ memberModel: TenantMemberModel,
394
+ roleModel: RoleModel,
395
+ tenantId: string,
396
+ ) => Promise<PageLayout>;
397
+ ```
398
+
399
+ 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](/docs/components/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:
400
+
401
+ ```typescript
402
+ import { GetModel } from "@antelopejs/interface-database-decorators";
403
+ import {
404
+ type PageLayout,
405
+ GetPageLayoutBySlug,
406
+ } from "@antelopejs/interface-dms/page";
407
+ import {
408
+ RoleModel,
409
+ TenantMemberModel,
410
+ } from "@antelopejs/interface-dms/db";
411
+ import type { User } from "@antelopejs/interface-dms/auth/db";
412
+
413
+ async function renderLayout(
414
+ slug: string,
415
+ user: User | undefined,
416
+ tenantId: string,
417
+ ): Promise<PageLayout | undefined> {
418
+ const handler = GetPageLayoutBySlug(slug);
419
+ return handler?.(
420
+ user,
421
+ GetModel(TenantMemberModel, tenantId),
422
+ GetModel(RoleModel, tenantId),
423
+ tenantId,
424
+ );
425
+ }
426
+ ```
427
+
428
+ `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.
429
+
430
+ ### `PageMetadata`
431
+
432
+ `PageMetadata` is the per-controller record the decorator fills in. `GetMetadata(controllerClass, PageMetadata)` returns it. It holds `pageInfo`, `layout` and `components`, and exposes:
433
+
434
+ | Member | Description |
435
+ | ------ | ----------- |
436
+ | `SetInfo(id, fullSlug, menuOptions, pageLayout?)` | Compute and store the `PageInfo` and the layout. |
437
+ | `SetComponent(id, component)` | Attach a component under a key. |
438
+ | `inherit(parent)` | Merge another `PageMetadata`'s info, layout and components into this one. |
439
+ | `Register()` | Register the page: permissions, layout handler, `/pagelayout` route and auth decorators. |
440
+ | `target` | The controller class the metadata belongs to. |
441
+
442
+ `@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.
443
+
444
+ 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](/docs/interfaces/core/permissions)).
445
+
446
+ ## Page Access
447
+
448
+ Registering a page installs an auth decorator on the controller, chosen from the options:
449
+
450
+ | Options | Decorator installed |
451
+ | ------- | ------------------- |
452
+ | `publicAccess: true` | none — the page is served to anonymous callers |
453
+ | `authOnly: true` | `AuthUser` from `@antelopejs/interface-dms/auth`: a logged-in user, no permission check |
454
+ | page under `/modules/` | `AuthOwnerOnly` from `@antelopejs/interface-dms/auth`: module pages are platform-owner-only |
455
+ | default | `AuthUserWithPermission(pageClass)` — the page's own permission ([Permissions](/docs/interfaces/core/permissions)) |
456
+
457
+ 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](/docs/interfaces/core/tenancy)). Routes you declare on the page class inherit that gating: a route under a default page already requires the page's permission.
458
+
459
+ 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:
460
+
461
+ ```typescript
462
+ import { Get } from "@antelopejs/interface-api";
463
+ import {
464
+ PageController,
465
+ RegisterPage,
466
+ } from "@antelopejs/interface-dms/page";
467
+ import type { User } from "@antelopejs/interface-dms/auth/db";
468
+ import { crm } from "./categories";
469
+
470
+ @RegisterPage()
471
+ export class MyContactsPage extends PageController("mine", {
472
+ displayName: "My contacts",
473
+ category: crm,
474
+ }) {
475
+ declare user: User;
476
+
477
+ @Get("/count")
478
+ async count() {
479
+ return { owner: this.user._id };
480
+ }
481
+ }
482
+ ```
483
+
484
+ ## Page Setup Functions
485
+
486
+ `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`.
487
+
488
+ ```typescript
489
+ type PageSetupFunction = (
490
+ context: PageSetupContext,
491
+ ) => MaybePromise<PageSetupCleanup | void>;
492
+ ```
493
+
494
+ | Member of `PageSetupContext` | Description |
495
+ | ---------------------------- | ----------- |
496
+ | `pageInfo` | The `PageInfo` of the page being mounted. |
497
+ | `permissions` | `Set<string>` of the caller's effective permission ids. |
498
+ | `on(component, event, handler)` | Subscribe to a component event. Returns an unsubscribe function. |
499
+ | `emit(component, event, data?)` | Dispatch an event on the shared event bus. |
500
+
501
+ `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`.
502
+
503
+ ```typescript
504
+ import {
505
+ PageController,
506
+ RegisterPage,
507
+ } from "@antelopejs/interface-dms/page";
508
+ import { billing } from "./category";
509
+
510
+ @RegisterPage()
511
+ export class LifecyclePage extends PageController("lifecycle", {
512
+ displayName: "Lifecycle",
513
+ category: billing,
514
+ setupId: "billing:invoice-lifecycle",
515
+ }) {}
516
+ ```
517
+
518
+ ## Ship an Inertia/Vue Frontend Module
519
+
520
+ `AddFrontendModule(config)` registers a frontend module your module ships. Call it from your module's `construct()`.
521
+
522
+ ```typescript
523
+ import path from "node:path";
524
+ import { AddFrontendModule } from "@antelopejs/interface-dms/page";
525
+
526
+ export async function construct(): Promise<void> {
527
+ await AddFrontendModule({
528
+ name: "@acme/dms-billing",
529
+ sourcePath: path.join(__dirname, "../frontend-vue"),
530
+ renderer: { name: "vue", version: "3" },
531
+ configKey: "dmsBilling",
532
+ priority: 0,
533
+ options: { prefix: "Billing" },
534
+ });
535
+ }
536
+ ```
537
+
538
+ ### `AddFrontendModuleOptions`
539
+
540
+ | Option | Type | Description |
541
+ | ------ | ---- | ----------- |
542
+ | `name` | `string` | Logical frontend module name. Registry identity also includes renderer name and major version. Required. |
543
+ | `sourcePath` | `string` | Absolute path to the frontend module directory. Required. |
544
+ | `renderer` | `FrontendRendererMetadata` | Framework 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](/docs/building/frontend-layer#declaring-the-loader-releases-a-module-supports)). |
545
+ | `options` | `Record<string, any>` | Values handed to the layer's configuration. Defaults to `{}`. |
546
+ | `privateOptions` | `Record<string, any>` | A second set of values handed to the layer, kept separate from `options`. Defaults to `{}`. |
547
+ | `priority` | `number` | Ordering among the registered layers. Defaults to `0`. |
548
+ | `configKey` | `string` | Configuration key the layer is published under. |
549
+ | `authEstablishEndpoints` | `string[]` | Backend routes (`/api/…`) the frontend server may open a session from on the module's behalf (its `/auth/establish` route). |
550
+ | `publicEndpoints` | `FrontendPublicEndpoint[]` | Backend routes the module serves to callers outside the dashboard — a provider's webhook. See below. |
551
+ | `backendProxies` | `FrontendBackendProxy[]` | 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. |
552
+
553
+ #### Public endpoints
554
+
555
+ 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:
556
+
557
+ ```typescript
558
+ await AddFrontendModule({
559
+ name: "@acme/dms-mailing",
560
+ sourcePath: path.join(__dirname, "../frontend-vue"),
561
+ renderer: { name: "vue", version: "3" },
562
+ publicEndpoints: [
563
+ {
564
+ path: "/api/mailing/events/*/*",
565
+ methods: ["POST"],
566
+ headers: ["x-mailing-webhook-secret"],
567
+ },
568
+ ],
569
+ });
570
+ ```
571
+
572
+ 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.
573
+
574
+ Within one logical name, renderer, and major version, the highest-priority
575
+ registration wins. Equal-priority registrations use the latest registration.
576
+ The manifest and archive endpoints select one renderer with the paired
577
+ `renderer` and `rendererVersion` query parameters. The version is matched by
578
+ major version; unsupported selections return 404 rather than mixing renderer
579
+ sources. Omitting both parameters remains an alias for Vue 3 clients.
580
+
581
+ ## Stamp Upload Tokens
582
+
583
+ `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.
584
+
585
+ ```typescript
586
+ import { StampUploadFieldTokens } from "@antelopejs/interface-dms/uploads";
587
+
588
+ const components = await StampUploadFieldTokens(layout.components);
589
+ ```
590
+
591
+ 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.
592
+
593
+ ## Public URLs
594
+
595
+ `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.
596
+
597
+ ```typescript
598
+ import { GetApiBaseUrl } from "@antelopejs/interface-dms/api-base-url";
599
+ import { GetClientBaseUrl } from "@antelopejs/interface-dms/client-base-url";
600
+
601
+ const apiBase = await GetApiBaseUrl(); // "https://api.example.com"
602
+ const downloadUrl = `${apiBase}/i18n/site/fr.json`;
603
+ const inviteUrl = `${await GetClientBaseUrl()}/auth/signup`;
604
+ ```
605
+
606
+ Both return `string | undefined`: `undefined` only when the configuration clears the value.
607
+
608
+ ## Client address
609
+
610
+ `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](/docs/building/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.
611
+
612
+ ```typescript
613
+ import { Context, Controller, Post, type RequestContext } from "@antelopejs/interface-api";
614
+ import { getClientIp } from "@antelopejs/interface-dms/client-ip";
615
+
616
+ export class CollectController extends Controller("/api/collect") {
617
+ @Context()
618
+ declare requestContext: RequestContext;
619
+
620
+ @Post("/")
621
+ async collect(): Promise<void> {
622
+ const address = await getClientIp(this.requestContext); // "109.132.199.8"
623
+ }
624
+ }
625
+ ```
626
+
627
+ 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.
628
+
629
+ ## `SaveComponentFiles`
630
+
631
+ `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](/docs/components/file-storage) covers the form/upload flow and storage authorization.
632
+
633
+ `AttachmentSaveRequest` contains:
634
+
635
+ | Field | Type | Contract |
636
+ | --- | --- | --- |
637
+ | `context` | `RequestContext` | The authorized server request. |
638
+ | `componentIds` | `string[]` | Trusted component identities, not ids supplied by the client. |
639
+ | `fields` | `AttachmentField[]` | Server-declared attachment fields. |
640
+ | `submitted` | `Record<string, unknown>` | The submitted values. |
641
+ | `before` | `Record<string, unknown>` | Previously persisted values, loaded by the server. |
642
+
643
+ 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`.
644
+
645
+ 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.
646
+
647
+ ## Next Steps
648
+
649
+ [Components](/docs/interfaces/core/components) covers the builders that produce the values you assign to those static fields.