@antelopejs/interface-dms 0.7.4 → 0.9.0

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 (239) hide show
  1. package/CHANGELOG.md +176 -0
  2. package/dist/auth/index.d.ts +3 -3
  3. package/dist/auth/index.js +11 -9
  4. package/dist/auth/index.js.map +1 -1
  5. package/dist/auth/internal/jwt-auth-decorator.d.ts +20 -0
  6. package/dist/auth/internal/jwt-auth-decorator.js +51 -0
  7. package/dist/auth/internal/jwt-auth-decorator.js.map +1 -0
  8. package/dist/auth/internal/request-authenticators.d.ts +12 -0
  9. package/dist/auth/internal/request-authenticators.js +22 -0
  10. package/dist/auth/internal/request-authenticators.js.map +1 -1
  11. package/dist/base/activity-feed.d.ts +6 -6
  12. package/dist/base/block-registry/types.d.ts +16 -0
  13. package/dist/base/block-types.d.ts +1 -0
  14. package/dist/base/block-types.js +1 -0
  15. package/dist/base/block-types.js.map +1 -1
  16. package/dist/base/chart-card.d.ts +15 -0
  17. package/dist/base/chart-card.js +4 -0
  18. package/dist/base/chart-card.js.map +1 -1
  19. package/dist/base/chart-schemas.d.ts +26 -26
  20. package/dist/base/code-block.d.ts +3 -3
  21. package/dist/base/data-types/compare-types.js +19 -13
  22. package/dist/base/data-types/compare-types.js.map +1 -1
  23. package/dist/base/data-types/core.d.ts +11 -0
  24. package/dist/base/data-types/core.js +14 -1
  25. package/dist/base/data-types/core.js.map +1 -1
  26. package/dist/base/data-types/default-types.d.ts +112 -2
  27. package/dist/base/data-types/default-types.js +90 -19
  28. package/dist/base/data-types/default-types.js.map +1 -1
  29. package/dist/base/data-types/field-types.d.ts +10 -4
  30. package/dist/base/data-types/field-types.js +12 -2
  31. package/dist/base/data-types/field-types.js.map +1 -1
  32. package/dist/base/data-types/internal/compare-types.d.ts +3 -0
  33. package/dist/base/data-types/internal/compare-types.js +7 -0
  34. package/dist/base/data-types/internal/compare-types.js.map +1 -1
  35. package/dist/base/empty-state.d.ts +4 -4
  36. package/dist/base/form-block-schema.d.ts +59 -3
  37. package/dist/base/form-block-schema.js +12 -2
  38. package/dist/base/form-block-schema.js.map +1 -1
  39. package/dist/base/form-schema.d.ts +57 -5
  40. package/dist/base/form-schema.js +22 -0
  41. package/dist/base/form-schema.js.map +1 -1
  42. package/dist/base/form-types.d.ts +82 -13
  43. package/dist/base/form-types.js +12 -4
  44. package/dist/base/form-types.js.map +1 -1
  45. package/dist/base/guard.d.ts +6 -6
  46. package/dist/base/helpers/archive-mode-helpers.js +6 -2
  47. package/dist/base/helpers/archive-mode-helpers.js.map +1 -1
  48. package/dist/base/index.d.ts +1 -0
  49. package/dist/base/index.js +1 -0
  50. package/dist/base/index.js.map +1 -1
  51. package/dist/base/internal/form-schema.js +5 -0
  52. package/dist/base/internal/form-schema.js.map +1 -1
  53. package/dist/base/internal/layouts.d.ts +15 -3
  54. package/dist/base/key-value-list.d.ts +12 -9
  55. package/dist/base/key-value-list.js +1 -1
  56. package/dist/base/key-value-list.js.map +1 -1
  57. package/dist/base/kpi-card.d.ts +39 -5
  58. package/dist/base/kpi-card.js +18 -6
  59. package/dist/base/kpi-card.js.map +1 -1
  60. package/dist/base/layouts.d.ts +24 -1
  61. package/dist/base/layouts.js.map +1 -1
  62. package/dist/base/live-toggle.d.ts +11 -0
  63. package/dist/base/live-toggle.js +8 -1
  64. package/dist/base/live-toggle.js.map +1 -1
  65. package/dist/base/page-toolbar.d.ts +158 -0
  66. package/dist/base/page-toolbar.js +198 -0
  67. package/dist/base/page-toolbar.js.map +1 -0
  68. package/dist/base/period-selector.d.ts +9 -2
  69. package/dist/base/period-selector.js.map +1 -1
  70. package/dist/base/placeholder.d.ts +2 -2
  71. package/dist/base/section.d.ts +2 -2
  72. package/dist/base/stack.d.ts +2 -2
  73. package/dist/base/stat-group.d.ts +18 -6
  74. package/dist/base/stat-group.js +9 -0
  75. package/dist/base/stat-group.js.map +1 -1
  76. package/dist/base/table-view/column-display.d.ts +9 -5
  77. package/dist/base/table-view/column-display.js +1 -1
  78. package/dist/base/table-view/column-display.js.map +1 -1
  79. package/dist/base/table-view/data-functions.d.ts +26 -7
  80. package/dist/base/table-view/data-functions.js +10 -6
  81. package/dist/base/table-view/data-functions.js.map +1 -1
  82. package/dist/base/table-view/factory.js +24 -13
  83. package/dist/base/table-view/factory.js.map +1 -1
  84. package/dist/base/table-view/internal/auth.d.ts +2 -0
  85. package/dist/base/table-view/internal/auth.js +3 -1
  86. package/dist/base/table-view/internal/auth.js.map +1 -1
  87. package/dist/base/table-view/internal/column-conditions.d.ts +15 -0
  88. package/dist/base/table-view/internal/column-conditions.js +94 -0
  89. package/dist/base/table-view/internal/column-conditions.js.map +1 -0
  90. package/dist/base/table-view/internal/factory-helpers.d.ts +5 -0
  91. package/dist/base/table-view/internal/factory-helpers.js +55 -11
  92. package/dist/base/table-view/internal/factory-helpers.js.map +1 -1
  93. package/dist/base/table-view/internal/guards.js +19 -2
  94. package/dist/base/table-view/internal/guards.js.map +1 -1
  95. package/dist/base/table-view/internal/realtime.js +17 -0
  96. package/dist/base/table-view/internal/realtime.js.map +1 -1
  97. package/dist/base/table-view/internal/resource-form.js +1 -0
  98. package/dist/base/table-view/internal/resource-form.js.map +1 -1
  99. package/dist/base/table-view/internal/row-actions-schema.d.ts +296 -0
  100. package/dist/base/table-view/internal/row-actions-schema.js +62 -0
  101. package/dist/base/table-view/internal/row-actions-schema.js.map +1 -0
  102. package/dist/base/table-view/internal/validation.d.ts +22 -3
  103. package/dist/base/table-view/internal/validation.js +92 -2
  104. package/dist/base/table-view/internal/validation.js.map +1 -1
  105. package/dist/base/table-view/meta.d.ts +25 -1
  106. package/dist/base/table-view/meta.js +36 -4
  107. package/dist/base/table-view/meta.js.map +1 -1
  108. package/dist/base/table-view/options.d.ts +92 -16
  109. package/dist/base/table-view/options.js.map +1 -1
  110. package/dist/base/table-view/realtime.d.ts +8 -0
  111. package/dist/base/table-view/realtime.js.map +1 -1
  112. package/dist/base/table-view/routes.js +8 -4
  113. package/dist/base/table-view/routes.js.map +1 -1
  114. package/dist/base/table-view/schema.d.ts +36 -30
  115. package/dist/base/table-view/schema.js +11 -57
  116. package/dist/base/table-view/schema.js.map +1 -1
  117. package/dist/base/table-view/source.d.ts +1 -1
  118. package/dist/base/table-view/source.js +4 -1
  119. package/dist/base/table-view/source.js.map +1 -1
  120. package/dist/base/table-view.d.ts +2 -2
  121. package/dist/base/table-view.js.map +1 -1
  122. package/dist/base/top-list-card.d.ts +5 -5
  123. package/dist/base/types/action-target.d.ts +77 -20
  124. package/dist/base/types/confirm-dialog.d.ts +5 -1
  125. package/dist/base/types/custom-button.d.ts +5 -3
  126. package/dist/base/types/field-config.d.ts +4 -4
  127. package/dist/base/types/guards.d.ts +10 -0
  128. package/dist/base/types/row-action.d.ts +6 -1
  129. package/dist/base/types/size.d.ts +3 -1
  130. package/dist/client-ip.d.ts +6 -2
  131. package/dist/client-ip.js +4 -2
  132. package/dist/client-ip.js.map +1 -1
  133. package/dist/component.d.ts +14 -0
  134. package/dist/component.js.map +1 -1
  135. package/dist/data-controllers/members.d.ts +0 -3
  136. package/dist/data-controllers/members.js +30 -8
  137. package/dist/data-controllers/members.js.map +1 -1
  138. package/dist/db/internal/tenantLifecycle.model.js +5 -2
  139. package/dist/db/internal/tenantLifecycle.model.js.map +1 -1
  140. package/dist/guards.js +2 -3
  141. package/dist/guards.js.map +1 -1
  142. package/dist/hooks.d.ts +54 -10
  143. package/dist/hooks.js +12 -4
  144. package/dist/hooks.js.map +1 -1
  145. package/dist/index.d.ts +3 -3
  146. package/dist/index.js +3 -2
  147. package/dist/index.js.map +1 -1
  148. package/dist/internal/invite-membership.d.ts +9 -0
  149. package/dist/internal/invite-membership.js +25 -7
  150. package/dist/internal/invite-membership.js.map +1 -1
  151. package/dist/internal/member-changes.d.ts +24 -0
  152. package/dist/internal/member-changes.js +66 -0
  153. package/dist/internal/member-changes.js.map +1 -0
  154. package/dist/internal/tenant-ownership.d.ts +4 -1
  155. package/dist/internal/tenant-ownership.js +4 -10
  156. package/dist/internal/tenant-ownership.js.map +1 -1
  157. package/dist/invite-extensions/internal/form-slot.js +1 -1
  158. package/dist/invite-extensions/internal/form-slot.js.map +1 -1
  159. package/dist/invite-extensions.d.ts +0 -9
  160. package/dist/invite-extensions.js +1 -36
  161. package/dist/invite-extensions.js.map +1 -1
  162. package/dist/invite-resolution.js +6 -2
  163. package/dist/invite-resolution.js.map +1 -1
  164. package/dist/invites.js +36 -22
  165. package/dist/invites.js.map +1 -1
  166. package/dist/layout-banners.d.ts +40 -4
  167. package/dist/layout-banners.js +25 -3
  168. package/dist/layout-banners.js.map +1 -1
  169. package/dist/notifications/internal.d.ts +2 -2
  170. package/dist/notifications/internal.js.map +1 -1
  171. package/dist/notifications/sendable.d.ts +3 -2
  172. package/dist/notifications/sendable.js +2 -1
  173. package/dist/notifications/sendable.js.map +1 -1
  174. package/dist/notifications/types.d.ts +9 -0
  175. package/dist/page/controllers.d.ts +44 -0
  176. package/dist/page/controllers.js +14 -1
  177. package/dist/page/controllers.js.map +1 -1
  178. package/dist/page/internal/layout-filter.d.ts +10 -4
  179. package/dist/page/internal/layout-filter.js +27 -14
  180. package/dist/page/internal/layout-filter.js.map +1 -1
  181. package/dist/page/internal/metadata-helpers.d.ts +7 -0
  182. package/dist/page/internal/metadata-helpers.js +13 -2
  183. package/dist/page/internal/metadata-helpers.js.map +1 -1
  184. package/dist/page/internal/page-record.d.ts +34 -0
  185. package/dist/page/internal/page-record.js +87 -0
  186. package/dist/page/internal/page-record.js.map +1 -0
  187. package/dist/page/internal/page-registration.d.ts +2 -0
  188. package/dist/page/internal/page-registration.js.map +1 -1
  189. package/dist/page/layout-filter.js +31 -10
  190. package/dist/page/layout-filter.js.map +1 -1
  191. package/dist/page/metadata.js +36 -26
  192. package/dist/page/metadata.js.map +1 -1
  193. package/dist/page/types.d.ts +39 -4
  194. package/dist/page/types.js.map +1 -1
  195. package/dist/page.d.ts +1 -1
  196. package/dist/page.js +2 -1
  197. package/dist/page.js.map +1 -1
  198. package/dist/permissions-resolver.d.ts +26 -3
  199. package/dist/permissions-resolver.js +2 -1
  200. package/dist/permissions-resolver.js.map +1 -1
  201. package/dist/permissions.d.ts +7 -3
  202. package/dist/permissions.js +8 -6
  203. package/dist/permissions.js.map +1 -1
  204. package/dist/realtime.d.ts +24 -1
  205. package/dist/realtime.js +7 -1
  206. package/dist/realtime.js.map +1 -1
  207. package/dist/request-tenant.d.ts +17 -13
  208. package/dist/request-tenant.js +54 -42
  209. package/dist/request-tenant.js.map +1 -1
  210. package/dist/tenant-members.d.ts +26 -0
  211. package/dist/tenant-members.js +29 -0
  212. package/dist/tenant-members.js.map +1 -0
  213. package/dist/tenant-ownership.d.ts +4 -1
  214. package/dist/tenant-ownership.js +12 -17
  215. package/dist/tenant-ownership.js.map +1 -1
  216. package/dist/tenant-provisioning.d.ts +15 -0
  217. package/dist/tenant-provisioning.js +27 -0
  218. package/dist/tenant-provisioning.js.map +1 -0
  219. package/dist/tenant-scoped-model.d.ts +2 -1
  220. package/dist/tenant-scoped-model.js +4 -3
  221. package/dist/tenant-scoped-model.js.map +1 -1
  222. package/docs/1.core/10.layout-banners.md +57 -2
  223. package/docs/1.core/2.pages-and-modules.md +20 -11
  224. package/docs/1.core/4.permissions.md +34 -1
  225. package/docs/1.core/5.tenancy.md +37 -6
  226. package/docs/1.core/7.hooks.md +24 -4
  227. package/docs/1.core/8.realtime.md +24 -3
  228. package/docs/2.auth/2.authentication.md +3 -3
  229. package/docs/3.base/1.introduction.md +1 -0
  230. package/docs/3.base/10.display-blocks.md +20 -2
  231. package/docs/3.base/2.types.md +7 -5
  232. package/docs/3.base/3.forms.md +28 -8
  233. package/docs/3.base/4.tables.md +27 -9
  234. package/docs/3.base/5.charts-and-widgets.md +8 -4
  235. package/docs/3.base/6.layout.md +47 -1
  236. package/docs/3.base/8.data-types.md +16 -9
  237. package/docs/4.notifications/3.sending.md +4 -3
  238. package/docs/5.html-render/1.introduction.md +5 -7
  239. package/package.json +7 -11
@@ -7,7 +7,7 @@ description: "A layout banner is a global message the backend adds under the das
7
7
 
8
8
  ## Overview
9
9
 
10
- A layout banner is a global message the backend adds under the dashboard header: a colored strip carrying a line of text and its link buttons, or a component of your frontend layer. It renders on every page of the dashboard layout — tenant pages and the module back-office pages under `/modules` alike — and banners stack by `order`.
10
+ A layout banner is a global message the backend adds under the dashboard header: a colored strip carrying a line of text and its link buttons, or a component of your frontend layer. It renders on the pages its [`scope`](#scope) selects — by default the application's pages, leaving the module area under `/modules` out — and banners stack by `order`.
11
11
 
12
12
  Whether a banner shows is decided per request, on the server, while the site layout is built. The answer is part of the server-rendered page, so a banner never flashes in or out after hydration.
13
13
 
@@ -92,6 +92,60 @@ RegisterLayoutBanner({
92
92
 
93
93
  Re-registering a `key` replaces the banner.
94
94
 
95
+ ## Scope
96
+
97
+ A banner is about the application being built — eighteen overdue invoices, a stock alert — so by default it shows on the application's pages only, and stays out of the **module area**: every page under `MODULE_URL_PREFIX` (`/modules`), that is the modules page, the module store and each module's own back-office pages. A banner about the workspace itself, which must follow the user into a module — billing past due, a suspended workspace, a maintenance window — sets `scope: LayoutBannerScope.EVERYWHERE`.
98
+
99
+ `scope` is a plain option of the banner declaration, the same on the three kinds of banner — a text banner, a resolved banner and a component banner — with the same values and the same `"app"` default:
100
+
101
+ ```typescript
102
+ import {
103
+ LayoutBannerScope,
104
+ LayoutBannerVariant,
105
+ RegisterLayoutBanner,
106
+ } from "@antelopejs/interface-dms/layout-banners";
107
+
108
+ // A text banner about the application: the default scope, written out.
109
+ RegisterLayoutBanner({
110
+ key: "my-app:overdue-invoices",
111
+ variant: LayoutBannerVariant.WARNING,
112
+ scope: LayoutBannerScope.APP,
113
+ text: "$my_app.banners.overdue_invoices",
114
+ actions: [{ label: "$my_app.banners.review", to: "/finance/invoices" }],
115
+ visible: async ({ tenantId }) => hasOverdueInvoices(tenantId),
116
+ });
117
+
118
+ // A resolved banner about the workspace: it follows the user into /modules.
119
+ RegisterLayoutBanner({
120
+ key: "saas:past-due",
121
+ variant: LayoutBannerVariant.ERROR,
122
+ scope: LayoutBannerScope.EVERYWHERE,
123
+ resolve: pastDueContent,
124
+ });
125
+
126
+ // A component banner about the workspace, also shown inside /modules.
127
+ RegisterLayoutBanner({
128
+ key: "my-app:maintenance",
129
+ variant: LayoutBannerVariant.INFO,
130
+ scope: LayoutBannerScope.EVERYWHERE,
131
+ component: "MyAppMaintenanceBanner",
132
+ props: { statusUrl: "https://status.example.com" },
133
+ visible: async () => isMaintenanceScheduled(),
134
+ });
135
+ ```
136
+
137
+ | `scope` | Shows on |
138
+ | ------- | -------- |
139
+ | `"app"` (default) | Every page outside the module area. |
140
+ | `"everywhere"` | Every page of the dashboard layout, module area included. |
141
+ | `"modules"` | The module area only. |
142
+
143
+ The scope is decided by the page, not by the request: the site layout is fetched once and shared by every page, so each banner carries its scope and the dashboard applies it against the current route — in the server-rendered page, so a banner never flashes in or out after hydration, and again on each client-side navigation, so moving in or out of `/modules` adds or removes it without re-fetching anything. A banner's `visible` or `resolve` therefore does not see the route; the scope is the only per-page filter. A dismissal is per key, whatever page the banner was closed on.
144
+
145
+ ::warning
146
+ **Behaviour change.** Before `scope` existed every banner showed on every page, module area included. A banner registered without `scope` now stays out of `/modules`: give `scope: LayoutBannerScope.EVERYWHERE` to the ones reporting a workspace-wide state.
147
+ ::
148
+
95
149
  ## `LayoutBannerInfo`
96
150
 
97
151
  | Field | Type | Description |
@@ -105,6 +159,7 @@ Re-registering a `key` replaces the banner.
105
159
  | `order` | `number` | Stack order, ascending from the top. Defaults to `0`; ties keep the registration order. |
106
160
  | `icon` | `string` | Leading icon. Defaults to one matching the variant. |
107
161
  | `dismissible` | `boolean` | Whether the user can close the banner. Defaults to `false`. |
162
+ | `scope` | `LayoutBannerScope` | `"app"`, `"everywhere"` or `"modules"`: the pages the banner shows on. Defaults to `"app"`, every page outside `/modules`. See [Scope](#scope). |
108
163
  | `visible` | `LayoutBannerVisibility` | Decides per request whether a `text` or `component` banner shows. Absent means always. |
109
164
  | `resolve` | `LayoutBannerResolver` | Builds `{ text, actions? }` per request, or `undefined` to hide the banner. Exclusive with `text`, `component` and `visible`. |
110
165
 
@@ -128,6 +183,6 @@ The resolvers run whenever a client fetches its site layout — on a full page l
128
183
 
129
184
  ## Rendering
130
185
 
131
- Banners render under the header of the dashboard layout, full width, in `order`, a text banner's `actions` on its right. Each one is its own live region: `error` banners carry `role="alert"`, `info` and `warning` banners `role="status"`.
186
+ Banners render under the header of the dashboard layout, on the pages their scope covers, full width, in `order`, a text banner's `actions` on its right. Each one is its own live region: `error` banners carry `role="alert"`, `info` and `warning` banners `role="status"`.
132
187
 
133
188
  A dismissible banner shows a close button. The dismissal lasts for the browser session and is kept in the `dms-dismissed-banners` cookie, which the server reads too, so a dismissed banner is already absent from the server-rendered page. A banner that is not dismissible ignores any dismissal recorded under its key.
@@ -62,6 +62,7 @@ The third argument is the page layout, a `ComponentInfo` produced by a layout bu
62
62
  | `order` | `number` | Sort order among siblings. |
63
63
  | `icon` | `string` | Icon name. |
64
64
  | `hidden` | `boolean` | Keep the entry out of the menu. Inherited from the resolved category when that category is hidden. |
65
+ | `parent` | `ControllerClass` | The page this one is a detail of: the breadcrumb shows it before this page ("Operations / Projects / <record>"), its route parameters filled from this page's own. Resolved at registration, served as `parentSlug`. Pages only. |
65
66
  | `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
67
  | `publicAccess` | `boolean` | Serve the page without authentication. Inherited from the resolved category. |
67
68
  | `authOnly` | `boolean` | Require a logged-in user but no permission check. Inherited from the resolved category. |
@@ -212,7 +213,7 @@ Call `RegisterModule` before any page that references the module id: a page reso
212
213
  | `description` | `string` | Description carried on the module root. |
213
214
  | `icon` | `string` | Icon name. |
214
215
  | `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
+ | `catalogCategory` | `string` | Category the module is filed under in the module catalog (`"Content"`, `"Developer"`…), may be a `$` i18n key. The Modules page and the store both file the module under it; without one, a store module takes the category of the store catalog, and any other goes under "Other". Unrelated to menu categories (`MenuOptions.category`, `defaultCategory`). Formerly `category`. |
216
217
  | `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
 
218
219
  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.
@@ -388,12 +389,20 @@ interface PageLayout<T = unknown> {
388
389
  Registering a page installs a `PageLayoutHandler` for its `fullSlug`:
389
390
 
390
391
  ```typescript
391
- type PageLayoutHandler = (
392
- user: User | undefined,
393
- memberModel: TenantMemberModel,
394
- roleModel: RoleModel,
395
- tenantId: string,
396
- ) => Promise<PageLayout>;
392
+ interface PageLayoutRequest {
393
+ user: User | undefined;
394
+ memberModel: TenantMemberModel;
395
+ roleModel: RoleModel;
396
+ tenantId: string;
397
+ /** The page URL's query, for header-button availability. */
398
+ query?: Readonly<Record<string, string>>;
399
+ /** The page URL's route parameters (`:id`), likewise. */
400
+ params?: Readonly<Record<string, string>>;
401
+ /** Passed to the permissions resolvers: `{ preview }` when a role preview reads the layout. */
402
+ resolverContext?: PermissionsResolverContext;
403
+ }
404
+
405
+ type PageLayoutHandler = (request: PageLayoutRequest) => Promise<PageLayout>;
397
406
  ```
398
407
 
399
408
  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:
@@ -416,12 +425,12 @@ async function renderLayout(
416
425
  tenantId: string,
417
426
  ): Promise<PageLayout | undefined> {
418
427
  const handler = GetPageLayoutBySlug(slug);
419
- return handler?.(
428
+ return handler?.({
420
429
  user,
421
- GetModel(TenantMemberModel, tenantId),
422
- GetModel(RoleModel, tenantId),
430
+ memberModel: GetModel(TenantMemberModel, tenantId),
431
+ roleModel: GetModel(RoleModel, tenantId),
423
432
  tenantId,
424
- );
433
+ });
425
434
  }
426
435
  ```
427
436
 
@@ -45,6 +45,8 @@ RegisterPermission("reports.export", {
45
45
  | `dependencies` | `string[]` | Permission ids this one needs to be of any use. The roles editor grants them with it (and takes it away with them), and a role is saved with them and the ids they sit under. `HasPermission` requires them, like the ids above: a registered dependency the set lacks makes the permission count for nothing. An unregistered one counts as held. |
46
46
  | `defaultGranted` | `boolean` | Treat the permission as held by every caller, without a role grant. |
47
47
 
48
+ A table view's actions declare their `dependencies` themselves: `edit` and `export` need `view`, and every action only reached from the rows — `delete`, `archive`, `viewArchived`, `export`, and `add`/`edit`/`view` when their form opens over the table — needs `list`; `restore` needs `viewArchived`. A form opening as its own page and `select` need no `list`.
49
+
48
50
  `RegisterPermission(id, permission)` takes the id twice — once as the registry key, once inside the record — so keep them equal.
49
51
 
50
52
  `UnregisterPermission(id)` drops a permission from the grantable tree again. It exists for registrations undone while the process keeps running — a page extension whose module stopped, for instance. A permission left behind after its surface is gone stays offered in the roles editor and stays grantable, so anything that registers a permission conditionally should unregister it on the same condition.
@@ -174,11 +176,42 @@ type PermissionsResolverFn = (
174
176
  userId: string,
175
177
  tenantId: string,
176
178
  currentPermissions: Set<string>,
179
+ context: PermissionsResolverContext,
177
180
  ) => Promise<Set<string>> | Set<string>;
181
+
182
+ interface PermissionsResolverContext {
183
+ /** Set when the call previews a role rather than computing a member's set. */
184
+ preview?: PermissionsResolverPreview;
185
+ }
186
+
187
+ interface PermissionsResolverPreview {
188
+ /** The previewed role's permissions, before any resolver reshaped them. */
189
+ rolePermissions: ReadonlySet<string>;
190
+ }
178
191
  ```
179
192
 
180
193
  `PermissionsResolverInfo` carries `id`, `resolver` and `order`.
181
194
 
195
+ ### Role previews
196
+
197
+ The roles editor's **Preview as role** runs the resolvers too, over the previewed role's set, and tells them so with `context.preview`. The `userId` of such a call is the person previewing. A resolver that grants or withholds by the user's standing (an owner handed every permission of the plan, a screen kept for owners) must check the flag, and shape the set as for a member holding only that role:
198
+
199
+ ```typescript
200
+ RegisterPermissionsResolver({
201
+ id: "my_app.plan-limits",
202
+ order: 100,
203
+ resolver: async (userId, tenantId, currentPermissions, context) => {
204
+ const allowed = await loadPlanPermissions(tenantId);
205
+ if (!context.preview && (await isWorkspaceOwner(userId, tenantId))) {
206
+ return allowed;
207
+ }
208
+ return new Set([...currentPermissions].filter((p) => allowed.has(p)));
209
+ },
210
+ });
211
+ ```
212
+
213
+ The DMS always passes `context`: `{}` outside a preview.
214
+
182
215
  Semantics the interface guarantees:
183
216
 
184
217
  - Resolvers run in ascending `order`, each seeing the previous one's result; equal `order` runs in registration order, which is module load order.
@@ -187,7 +220,7 @@ Semantics the interface guarantees:
187
220
  - The registration is removed when the registering module unloads; there is no unregister call.
188
221
  - Register in `construct()` so the resolver is active before the API serves requests.
189
222
 
190
- `ApplyPermissionsResolvers(userId, tenantId, basePermissions)` runs the pipeline and resolves to the effective set. Prefer `GetEffectiveUserPermissions`, which computes the base set for you and calls it.
223
+ `ApplyPermissionsResolvers(userId, tenantId, basePermissions, context?)` runs the pipeline, handing `context` (`{}` when omitted) to every resolver, and resolves to the effective set. Prefer `GetEffectiveUserPermissions`, which computes the base set for you and calls it.
191
224
 
192
225
  A resolver shapes a permission set. It cannot deny a surface that consults no permission set, and it cannot revoke a `defaultGranted` permission, which `HasPermission` grants after the resolvers ran. To cut a tenant off from the product entirely, register a tenant access gate instead ([Tenancy](/docs/interfaces/core/tenancy)).
193
226
 
@@ -127,7 +127,7 @@ Table `roles` in `dms-tenant` — the tenant's roles.
127
127
 
128
128
  ## Resolve the Request's Tenant
129
129
 
130
- `getRequestTenantId(ctx)` reads the `tenantId` claim of the request's bearer token and returns `DEFAULT_TENANT_ID` when there is no token or no claim.
130
+ `getRequestTenantId(ctx)` returns the tenant of the request's **verified** credential, bound by the DMS auth decorators (`AuthUser`, `AuthRawUser`, `IfAuthUser` with a valid token, `AuthOwnerOnly`, `AuthUserWithPermission`, the auth a `PageController` installs), the tenant guards or `authenticateRequestUser`. Without a verified credential (anonymous request, invalid or forged token) it returns `DEFAULT_TENANT_ID`; a token is never read for its tenant before its signature, user and session are checked.
131
131
 
132
132
  ```typescript
133
133
  import {
@@ -148,11 +148,11 @@ export class WorkspaceController extends Controller("/api/workspace") {
148
148
  }
149
149
  ```
150
150
 
151
- The returned id is **decoded, not verified**: the function does not check the token's signature. It is safe behind a decorator that already authenticated the request — the guards from [Permissions](/docs/interfaces/core/permissions), `AuthUser` from `@antelopejs/interface-dms/auth`, or the auth a `PageController` installs. Do not authorize on the id in a route that no such decorator gates.
151
+ Call it in the handler, after the route's auth decorator ran. A parameter or property provider that may run before it uses `resolveRequestTenantId(ctx)` (async), which verifies a bearer token itself and binds its tenant; `TenantScopedModel` does. Resolving a tenant is still not authorization: check membership and permissions for the operation.
152
152
 
153
153
  ## Scope a Model to the Request's Tenant
154
154
 
155
- `TenantScopedModel(ModelClass)` is a parameter and property decorator that injects `GetModel(ModelClass, getRequestTenantId(ctx))`.
155
+ `TenantScopedModel(ModelClass)` is a parameter and property decorator that injects `GetModel(ModelClass, await resolveRequestTenantId(ctx))`: the tenant of the verified credential, `DEFAULT_TENANT_ID` without one.
156
156
 
157
157
  ```typescript
158
158
  import { Controller, Get } from "@antelopejs/interface-api";
@@ -279,7 +279,7 @@ await applyTenantOwnership(GetModel(UserModel), userId, tenantId, {
279
279
  | `isTenantOwner` | `boolean` | Ownership flag after the write. |
280
280
  | `invitedBy` | `string \| null` | Recorded on insert. Ignored when the membership row already exists. |
281
281
 
282
- `applyTenantOwnership` fires `Hook.MEMBER_BEING_CHANGED` before any write, so a module can veto it. When the user is not yet a member, it also fires `Hook.MEMBER_BEING_ADDED` before the write and `Hook.MEMBER_ADDED` after it. For an existing member it updates `roleIds` and `isTenantOwner` and fires neither of those two ([Hooks](/docs/interfaces/core/hooks)).
282
+ `applyTenantOwnership` goes through [`changeTenantMembers`](#changetenantmembers), which fires `Hook.MEMBER_BEING_CHANGED` before any write, so a module can veto it. When the user is not yet a member, it also fires `Hook.MEMBER_BEING_ADDED` before the write and `Hook.MEMBER_ADDED` after it. For an existing member it updates `roleIds` and `isTenantOwner` and fires neither of those two ([Hooks](/docs/interfaces/core/hooks)).
283
283
 
284
284
  Two helpers keep the legacy platform-owner flag on the user row aligned with tenant ownership:
285
285
 
@@ -313,9 +313,40 @@ The registration is removed when the registering module unloads, and the mode st
313
313
 
314
314
  Membership and invite producers coordinate with tenant deletion through `@antelopejs/interface-dms/tenant-lifecycle`. This is a write-lifecycle contract, not an authorization check or a transaction: authorize the caller separately.
315
315
 
316
+ ### `changeTenantMembers`
317
+
318
+ Every insert, update or delete of a `TenantMemberModel` or `UserInviteModel` row goes through one entry point, `changeTenantMembers` from `@antelopejs/interface-dms/tenant-members`. The DMS uses it for every membership and invitation write it makes: adding a member, changing a member's roles or ownership (from the members table or the owner route), removing a member, moving the holders of a deleted role, leaving each tenant when an account is deleted, and creating, editing, resending, cancelling, expiring and accepting invitations. A module writing these rows itself must use it too; there is no other supported write path, and a raw `GetModel(...).insert/update/delete` on them skips both the barrier and the policy hook.
319
+
320
+ ```typescript
321
+ changeTenantMembers<T>(
322
+ tenantId: string,
323
+ plan: () => Promise<{ changes: MemberChange[]; write: () => Promise<T> }>,
324
+ ): Promise<T>
325
+ ```
326
+
327
+ It runs, in order:
328
+
329
+ 1. `runTenantLifecycleOperation(tenantId, ...)`: refused at once, before anything is read, while a [barrier](#pausing-admission) is held or once admission is closed;
330
+ 2. `plan()`, inside the admission, which reads the current rows and describes the change;
331
+ 3. `Hook.MEMBER_BEING_CHANGED` once per `MemberChange`, with `{ tenantId, ...change }`. A throwing handler refuses the write, replays of an invitation acceptance included;
332
+ 4. `write()`, then the admission is released as `runTenantLifecycleOperation` describes.
333
+
334
+ A `MemberChange` has a `kind`:
335
+
336
+ | `kind` | Fields | When |
337
+ | ------ | ------ | ---- |
338
+ | `added` | `userId`, `isTenantOwner`, `roleIds`, `deliveryId?` | A user joins: direct add, or an invitation accepted (`deliveryId` set) |
339
+ | `removed` | `userId`, `isTenantOwner` | A member is removed, or leaves by deleting their account |
340
+ | `roles` | `userId`, `roleIds`, `previousRoleIds` | A member's roles change, including when a deleted role is reassigned |
341
+ | `ownership` | `userId`, `isTenantOwner`, `previousIsTenantOwner` | A member is given or loses the owner role |
342
+
343
+ One edit setting both roles and ownership announces two changes. A write that changes no membership (an invitation created, edited or retired) passes `changes: []`: it is still admitted, and invitation creation keeps its own `Hook.INVITE_BEING_CREATED`. Do not call `changeTenantMembers` from inside another `changeTenantMembers` or `runTenantLifecycleOperation` callback.
344
+
345
+ Deleting an account makes one call per tenant the user belongs to, in turn. A tenant holding a barrier refuses its call, which stops the deletion there: the account and its remaining memberships stay, and the user can retry once the barrier is released.
346
+
316
347
  ### `runTenantLifecycleOperation`
317
348
 
318
- `runTenantLifecycleOperation<T>(tenantId: string, work: () => Promise<T>): Promise<T>` admits one invocation before calling `work`, then returns its result after releasing that invocation's admission. Closed admission rejects without running `work`. Built-in membership and invite entry points already participate; custom producers must use this entry point and await every effect, with no detached writer.
349
+ `runTenantLifecycleOperation<T>(tenantId: string, work: () => Promise<T>): Promise<T>` admits one invocation before calling `work`, then returns its result after releasing that invocation's admission. Closed admission rejects without running `work`. Membership and invitation writes use it through `changeTenantMembers`; call it directly only for other work that must coordinate with tenant deletion, and await every effect, with no detached writer.
319
350
 
320
351
  A successful callback releases its admission. A deliberate HTTP 4xx refusal also attempts release and rethrows the original refusal; other failures retain admission because their effects may be indeterminate. Retrying is a new invocation and never retires the failed one's identity. There is no automatic expiry or reconciliation API.
321
352
 
@@ -329,7 +360,7 @@ A module that must validate a tenant's inventory with no membership writer in fl
329
360
  | `pauseTenantLifecycleAdmission(tenantId, barrierId, expectedRevision)` | Publishes the barrier with one compare-and-set on `expectedRevision`. Resolves `false` when the revision moved, a barrier is held or admission is closed. |
330
361
  | `resumeTenantLifecycleAdmission(tenantId, barrierId)` | Releases this barrier; with no barrier held, it invalidates the revision so a delayed `pause` fails. Resolves `false` for another owner's barrier or closed admission. |
331
362
 
332
- While a barrier is held, `runTenantLifecycleOperation` rejects new work without running it; admitted work keeps running. Observe the revision before checking your own business intent, pause with that revision, recheck the intent, then wait for an observation with no active attempt before validating. A `false` from `pause` needs a new observation, not a blind retry. Use a unique `barrierId` per phase, and do not observe or pause from inside a `runTenantLifecycleOperation` callback: the caller would wait for itself.
363
+ While a barrier is held, `runTenantLifecycleOperation` rejects new work without running it, with an `HTTPResult` 409 (`Tenant lifecycle admission is paused`): a deliberate refusal with nothing written and no admission retained, which the caller may retry once the barrier is released. Admitted work keeps running. Observe the revision before checking your own business intent, pause with that revision, recheck the intent, then wait for an observation with no active attempt before validating. A `false` from `pause` needs a new observation, not a blind retry. Use a unique `barrierId` per phase, and do not observe or pause from inside a `runTenantLifecycleOperation` callback: the caller would wait for itself.
333
364
 
334
365
  ### `closeTenantLifecycleAdmission`
335
366
 
@@ -76,7 +76,7 @@ UnregisterHook(Hook.MEMBER_ADDED, onMemberAdded);
76
76
  | `Hook.INVITE_CREATED` | `"invite:created"` | `InviteCreatedHookPayload` | `undefined` |
77
77
  | `Hook.INVITE_DELETED` | `"invite:deleted"` | `InviteDeletedHookPayload` | `undefined` |
78
78
  | `Hook.MEMBER_BEING_ADDED` | `"member:being-added"` | `MemberHookPayload` | `undefined` |
79
- | `Hook.MEMBER_BEING_CHANGED` | `"member:being-changed"` | `MemberChangeHookPayload` | `undefined` — fires under tenant lifecycle admission before a membership is created or its ownership changes; throwing vetoes the write |
79
+ | `Hook.MEMBER_BEING_CHANGED` | `"member:being-changed"` | `MemberChangeHookPayload` | `undefined` — fired by `changeTenantMembers` under tenant lifecycle admission before every membership write (`kind`: `added`, `removed`, `roles`, `ownership`); throwing vetoes the write |
80
80
  | `Hook.MEMBER_ADDED` | `"member:added"` | `MemberHookPayload` | `undefined` |
81
81
  | `Hook.MEMBER_REMOVED` | `"member:removed"` | `MemberRemovedHookPayload` | `undefined` |
82
82
  | `Hook.USER_REGISTERED` | `"user:registered"` | `UserRegisteredHookPayload` | `undefined` |
@@ -89,7 +89,7 @@ The `*_BEING_*` members fire before the write they precede; the past-tense membe
89
89
  ```typescript
90
90
  interface TenantProvisioningHookPayload {
91
91
  tenantId: string;
92
- userId: string;
92
+ userId?: string; // absent for the `default` tenant
93
93
  extras: Record<string, unknown>;
94
94
  }
95
95
 
@@ -141,7 +141,17 @@ interface UserDeletedHookPayload {
141
141
 
142
142
  The shapes are not uniform, and the difference between the addition and removal payloads is deliberate: `MEMBER_BEING_ADDED` and `MEMBER_ADDED` describe one member with `userId`, while `MEMBER_REMOVED` carries `userIds`, because a removal can cover several members at once. Let the handler's inferred parameter type guide you.
143
143
 
144
- `MemberChangeHookPayload` extends `MemberHookPayload` with `previousIsTenantOwner`, `null` for a new membership. An invitation acceptance carries its `deliveryId` and is vetoed before the acceptance is persisted, including on replay: a `deliveryId` is not a policy exemption.
144
+ `MemberChangeHookPayload` is `{ tenantId } & MemberChange`, a union discriminated by `kind`:
145
+
146
+ ```typescript
147
+ type MemberChange =
148
+ | { kind: "added"; userId: string; isTenantOwner: boolean; roleIds: string[]; deliveryId?: string }
149
+ | { kind: "removed"; userId: string; isTenantOwner: boolean }
150
+ | { kind: "roles"; userId: string; roleIds: string[]; previousRoleIds: string[] }
151
+ | { kind: "ownership"; userId: string; isTenantOwner: boolean; previousIsTenantOwner: boolean };
152
+ ```
153
+
154
+ Read `kind` first and return early for the kinds your policy does not limit. An invitation acceptance is an `added` change carrying its `deliveryId`, vetoed before the acceptance is persisted, including on replay: a `deliveryId` is not a policy exemption. See [`changeTenantMembers`](/docs/interfaces/core/tenancy#changetenantmembers) for which writes announce which kind.
145
155
 
146
156
  `UserDeletedHookPayload.tenantIds` lists the workspaces the user was a member of, all left before the deletion; delete the per-user rows your module keeps in them. `reason` is `"self"` today, the user deleting their own account.
147
157
 
@@ -253,7 +263,17 @@ The flip side is the usual one for a `*_BEING_*` hook: a handler that throws for
253
263
 
254
264
  `extras` is opaque by design. The DMS reads no key of it, validates nothing, and stores nothing — it carries the object from the caller of the provisioning flow to the handlers, and each handler writes what it recognises. The shape is a contract between that caller and that handler, one the DMS is deliberately not a party to, so a product can start collecting a new field without waiting on a DMS release.
255
265
 
256
- No DMS code fires this hook: provisioning belongs to the signup flow of a SaaS module such as **`dms-saas`**. The DMS declares the contract; the emitter honours it. Without such a module, nothing invokes it.
266
+ It fires once per tenant, when the tenant is created, and never again on a later start. The DMS fires it for the `default` tenant, on the start that creates it, once every `DATABASE_INITIALIZED` handler has finished, so a module's tables and settings are ready; that payload has no `userId`, since the `default` tenant has no owner until onboarding. A SaaS module such as **`dms-saas`** fires it for each workspace its signup flow creates.
267
+
268
+ This is where a module writes the **default data** a tenant starts with — templates, folders, a sender, a demo history. Written once, that data belongs to the tenant: the module does not write it again on a later start, does not compare it with what members changed since, and does not wait for the tenant from `start()` or `DATABASE_INITIALIZED`.
269
+
270
+ ```typescript
271
+ RegisterHook(Hook.TENANT_BEING_PROVISIONED, async ({ tenantId }) => {
272
+ await writeDefaultFolders(tenantId);
273
+ });
274
+ ```
275
+
276
+ An emitter fires the hook through `ProvisionTenant` from `@antelopejs/interface-dms/tenant-provisioning`, never with a bare `ExecuteHooks`. It registers the tenant as an instance of the `dms-tenant` schema first, then runs the handlers, so no handler runs before the instance exists, whatever order the modules registered in.
257
277
 
258
278
  ## Contribute to a Tenant Data Export
259
279
 
@@ -17,7 +17,7 @@ Subscription is derived, not requested by the publisher:
17
17
 
18
18
  1. A page — or a component on it — calls `RegisterPageTopic(pageId, topic)` at registration time. `pageId` is the page's `fullId` ([Pages and Modules](/docs/interfaces/core/pages-and-modules)), not its slug.
19
19
  2. A client opens a realtime session and reports which page it is on. The backend checks that the user may access that page, then subscribes the session to every topic registered for that page id, and drops the topics that are no longer wanted.
20
- 3. `PublishMessage(topic, type)` delivers to the sessions currently subscribed to `topic`.
20
+ 3. `PublishMessage(topic, type)` delivers to the sessions currently subscribed to `topic` — with a `tenantId`, only to those of that tenant (see [Tenant-scoped messages](#tenant-scoped-messages)).
21
21
 
22
22
  So a publisher never addresses a page or a user. It addresses a topic, and the page-topic registry decides who is listening.
23
23
 
@@ -86,7 +86,7 @@ PublishMessage(
86
86
  | --------- | ----------- |
87
87
  | `topic` | The topic to publish on. Subscribers are the sessions on a page that registered this topic. |
88
88
  | `type` | The event type carried with the message, for subscribers that distinguish several kinds of event on one topic. |
89
- | `options` | Optional payload and actor. |
89
+ | `options` | Optional payload, actor and tenant. |
90
90
 
91
91
  ### `PublishMessageOptions`
92
92
 
@@ -94,12 +94,32 @@ PublishMessage(
94
94
  | ------ | ---- | ----------- |
95
95
  | `payload` | `Record<string, unknown>` | Data carried with the event. |
96
96
  | `actorId` | `string` | Identifies who caused the event, so a client can ignore an event it triggered itself. |
97
+ | `tenantId` | `string` | The tenant the event is about: only the sessions of that tenant receive it. Omitted, the message is global and reaches every tenant. |
97
98
 
98
99
  `PublishMessage` resolves once the message has been handed to the broker. It does not report how many sessions received it, and publishing on a topic no page registered is not an error — it simply reaches nobody.
99
100
 
101
+ ### Tenant-scoped messages
102
+
103
+ Page topics are shared by every tenant: a page registers the same topic whichever workspace views it. A message published without a `tenantId` therefore reaches the sessions of **every** workspace on that page — each one refetches, and a paused live page counts the message in its "N new" banner. Publish a workspace's data with the tenant it belongs to:
104
+
105
+ ```typescript
106
+ import { getRequestTenantId } from "@antelopejs/interface-dms/request-tenant";
107
+
108
+ await PublishMessage(CAMPAIGN_TOPIC, "campaign.sent", {
109
+ payload: { count: sent.length },
110
+ tenantId: getRequestTenantId(ctx), // or the tenant a job runs for
111
+ });
112
+ ```
113
+
114
+ A session is bound to the tenant of the request that opened it, and receives the messages of that tenant plus the global ones. When the browser switches workspace (or account), the client opens a new session in the new one, and the server ends a session whose page requests come from a workspace or account other than its own, so the previous workspace's messages stop with it. A session on the connection a browser's tabs share is ended alone: the other tabs' sessions on it stay. Keep messages without a tenant for what is the same for every workspace: a platform-wide setting, a catalogue shared by all tenants.
115
+
116
+ A session on a platform page (a page of a module, which only the platform owner opens) receives the messages of **every** tenant on that page's topics, whichever workspace the owner has open: such a page lists the data of all of them.
117
+
118
+ The table views of the [Base](/docs/interfaces/base/tables#realtime) domain publish their row changes and presence for the writer's tenant, or for every tenant when their rows live in the shared schema. Messages to one user (notifications) and the menu invalidations already name their audience in the topic.
119
+
100
120
  ## Subscribe from the Backend
101
121
 
102
- `SubscribeMessage` receives every message published on a topic, whichever instance published it: the subscription rides the same broker as `PublishMessage`, so what one instance publishes reaches subscribers on every other.
122
+ `SubscribeMessage` receives every message published on a topic, whichever instance and whichever tenant published it (a handler serving one tenant filters on `message.tenantId` itself): the subscription rides the same broker as `PublishMessage`, so what one instance publishes reaches subscribers on every other.
103
123
 
104
124
  ```typescript
105
125
  import { SubscribeMessage } from "@antelopejs/interface-dms/realtime";
@@ -132,6 +152,7 @@ type UnsubscribeMessage = () => void;
132
152
  | `type` | `string` | Event type given to `PublishMessage`. |
133
153
  | `payload` | `Record<string, unknown>` | Data carried with the event, when the publisher sent any. |
134
154
  | `actorId` | `string` | Who caused the event, when the publisher named an actor. |
155
+ | `tenantId` | `string` | The tenant the message was published for; absent on a global message. |
135
156
  | `ts` | `number` | Publication timestamp. |
136
157
 
137
158
  Paired with `PublishMessage`, this turns the DMS's broker into one a module can use for realtime channels of its own — its own endpoint subscribes here and the DMS carries the messages — instead of bringing a second broker to do the same job.
@@ -7,7 +7,7 @@ description: "Authentication turns an incoming HTTP request into a `User` row."
7
7
 
8
8
  ## Overview
9
9
 
10
- Authentication turns an incoming HTTP request into a `User` row. The interface exposes one plain function, `authenticateRequestUser`, and four decorators built with `CreateAuthDecorator` from `@antelopejs/interface-auth`: `AuthRawUser`, `AuthUser`, `IfAuthUser` and `AuthOwnerOnly`.
10
+ Authentication turns an incoming HTTP request into a `User` row. The interface exposes one plain function, `authenticateRequestUser`, and four decorators with the `CreateAuthDecorator` contract of `@antelopejs/interface-auth`: `AuthRawUser`, `AuthUser`, `IfAuthUser` and `AuthOwnerOnly`. Each of them, and `authenticateRequestUser`, binds the verified user and tenant to the request, which [`getRequestTenantId`](/docs/interfaces/core/tenancy#resolve-the-requests-tenant) then answers.
11
11
 
12
12
  All of them read the bearer token from the request's `Authorization` header (`Authorization: Bearer <token>`) and run the same two steps:
13
13
 
@@ -113,7 +113,7 @@ export class MaintenanceController extends Controller("/api/maintenance") {
113
113
 
114
114
  ## Gate Every Route on a Controller
115
115
 
116
- `AuthRawUser`, `AuthUser` and `IfAuthUser` are built with `CreateAuthDecorator`, so they apply to a parameter, to a property, or to the controller class itself. A property provider is a computed property of the controller, resolved on every request the controller handles whether or not the handler reads it — so a decorated property gates the whole controller and gives the handlers a typed `this.user`.
116
+ `AuthRawUser`, `AuthUser` and `IfAuthUser` follow the `CreateAuthDecorator` contract, so they apply to a parameter, to a property, or to the controller class itself. A property provider is a computed property of the controller, resolved on every request the controller handles whether or not the handler reads it — so a decorated property gates the whole controller and gives the handlers a typed `this.user`.
117
117
 
118
118
  ```typescript
119
119
  import { Controller, Get, JSONBody, Post } from "@antelopejs/interface-api";
@@ -206,7 +206,7 @@ export const AuthBetaTester = MakeParameterAndPropertyDecorator(
206
206
 
207
207
  This is how the DMS builds its tenant guards: `AuthTenantOwner` and `AuthTenantMember` in `@antelopejs/interface-dms/guards` both start from `authenticateRequestUser`, then resolve the request's tenant and check the caller's membership on top of it.
208
208
 
209
- A provider is also the way to add a requirement without losing the built-in one. The `CreateAuthDecorator` contract lets a call site pass a replacement validator — `@AuthUser(callback)` — but that callback replaces the whole validation step, signature check included, and its return type is constrained to `boolean | User`. This interface exports no token-verification primitive, so a replacement validator cannot redo the signature check it removes.
209
+ A provider is also the way to add a requirement without losing the built-in one. The `CreateAuthDecorator` contract lets a call site pass a replacement validator — `@AuthUser(callback)` — but that callback replaces the whole validation step, signature check included, and its return type is constrained to `boolean | User`; such a call binds no tenant to the request. This interface exports no token-verification primitive, so a replacement validator cannot redo the signature check it removes.
210
210
 
211
211
  ## Decorator Reference
212
212
 
@@ -30,6 +30,7 @@ The DMS documentation carries task-shaped chapters on the same components, organ
30
30
  | `top-list-card` | `TopListCard` | yes | [Charts and Widgets](/docs/interfaces/base/charts-and-widgets) |
31
31
  | `period-selector` | `PeriodSelector` | yes | [Charts and Widgets](/docs/interfaces/base/charts-and-widgets) |
32
32
  | `live-toggle` | `LiveToggle` | yes | [Display Blocks](/docs/interfaces/base/display-blocks#live-toggle) |
33
+ | `page-toolbar` | `PageToolbar`, `pageToolbar` | yes | [Display Blocks](/docs/interfaces/base/display-blocks#page-toolbar) |
33
34
  | `grid` | `Grid`, `GridRow` | yes | [Layout and Containers](/docs/interfaces/base/layout) |
34
35
  | `stack` | `HStack`, `VStack`, `Spacer` | yes | [Layout and Containers](/docs/interfaces/base/layout) |
35
36
  | `tab` | `Tab` | yes | [Layout and Containers](/docs/interfaces/base/layout) |
@@ -72,7 +72,7 @@ A block that can drop its frame does so with `card?: boolean` (`KeyValueList`, `
72
72
 
73
73
  | Props | Own options |
74
74
  | ------------------- | --------------------------------------------------------- |
75
- | `StatGroupProps` | `items`, `layout` (`StatGroupLayout`), `columns`, `label` |
75
+ | `StatGroupProps` | `items`, `layout` (`StatGroupLayout`), `columns`, `columnsFrom` (`StatGroupColumnsFrom`: `sm` / `md` / `lg` / `xl`, the group width from which all `columns` sit side by side), `label` |
76
76
  | `KeyValueListProps` | `items`, `title`, `card`, `dense`, `columns`, `currency` |
77
77
  | `NavCardGridProps` | `items`, `title`, `description`, `columns` |
78
78
 
@@ -127,10 +127,28 @@ Card({ title: "Connectors", count: 4 })
127
127
 
128
128
  ## Live Toggle
129
129
 
130
- `LiveToggle(options?)` (subpath `live-toggle`, block type `LiveToggle`, component `dms-live-toggle-block`, group `data`) is the Live / Pause switch of a page declared with `live` (`PageLiveOptions` from `@antelopejs/interface-dms/page`). `LiveToggleProps` extends `BaseComponentProps`: `align` (`LIVE_TOGGLE_ALIGNS`: `left`, `center`, `right`; default `right`) `showUpdatedAt` (default `true`), the time since the page last read its data, counted in the browser, `variant` (`LIVE_TOGGLE_VARIANTS`: `segmented`, `pill`; default `segmented`) and `missedLabel`, an i18n key or text given `{count}` naming the missed events while paused. The state belongs to the page, not to the block; on a page without `live` the block renders nothing.
130
+ `LiveToggle(options?)` (subpath `live-toggle`, block type `LiveToggle`, component `dms-live-toggle-block`, group `data`) is the Live / Pause switch of a page declared with `live` (`PageLiveOptions` from `@antelopejs/interface-dms/page`). `LiveToggleProps` extends `BaseComponentProps`: `align` (`LIVE_TOGGLE_ALIGNS`: `left`, `center`, `right`; default `right`) `showUpdatedAt` (default `true`), the time since the page last read its data, counted in the browser, `variant` (`LIVE_TOGGLE_VARIANTS`: `segmented`, `pill`; default `segmented`), `size` (`LIVE_TOGGLE_SIZES`: `xs`, `sm`, `md`; default the holding `PageToolbar`'s, else `sm`) and `missedLabel`, an i18n key or text given `{count}` naming the missed events while paused. The state belongs to the page, not to the block; on a page without `live` the block renders nothing.
131
131
 
132
132
  ```typescript
133
133
  import { LiveToggle } from "@antelopejs/interface-dms/base/live-toggle";
134
134
 
135
135
  static toolbar = HStack().child("spacer", Spacer()).child("live", LiveToggle());
136
136
  ```
137
+
138
+ ## Page Toolbar
139
+
140
+ `PageToolbar(options?)` (subpath `page-toolbar`, block type `PageToolbar`, component `dms-page-toolbar-block`, group `data`, a container) is the context bar of a page with a period or live data, first in its body. `PageToolbarProps` extends `BaseComponentProps`: `size` (`PAGE_TOOLBAR_SIZES`: `xs`, `sm`, `md`; default `sm`), the size of the `LiveToggle` and `PeriodSelector` it holds unless they set one, and `showUpdatedAt` and `refresh` (both default `true`), the "updated N s ago" label and the refresh button it shows where no `LiveToggle` is on screen — a page without `live` — counted from the page's last read. Its children render in one order whatever order they were added in: the other blocks, a spacer, the updated label or the `LiveToggle`, then the `PeriodSelector`.
141
+
142
+ `pageToolbar(options?)` builds one: `PageToolbarOptions` extends `PageToolbarProps` with `leading` (`Record<string, Component>`, the blocks on the left by child id), `live` (`LiveToggleProps | false`, child `live`, over `PAGE_TOOLBAR_LIVE_DEFAULTS`) and `period` (`PeriodSelectorProps | false`, child `period`, over `PAGE_TOOLBAR_PERIOD_DEFAULTS`: segmented `last-hour` / `last-24h` / `last-7-days` / `last-30-days`, `defaultPreset: "last-24h"`, `rangeLabel: "relative"`, right aligned). Unless `period` sets `comparisonControl`, it is `"menu"` when the period offers more than one comparison (`none` included; the selector's default list when `comparisons` is unset), on `defaultComparison` or else the first one besides `none`, and `"hidden"` with a single comparison or none.
143
+
144
+ ```typescript
145
+ import { pageToolbar } from "@antelopejs/interface-dms/base/page-toolbar";
146
+
147
+ static toolbar = pageToolbar({
148
+ leading: { scope: scopeChip },
149
+ live: { missedLabel: "$api.live.new_requests" },
150
+ period: { id: "api", rememberKey: "api", comparisons: ["previous-period"] },
151
+ });
152
+ ```
153
+
154
+ A module's Vue component may also draw the toolbar itself, around controls of its own, as `<DmsPageToolbarBlock>` holding a `<DmsLiveToggleBlock>`: outside the page's block tree, neither needs a `componentId` or `pageId`.
@@ -90,7 +90,7 @@ import {
90
90
 
91
91
  `Size` maps readable names onto the scale tokens: `tiny` → `"xs"`, `small` → `"sm"`, `medium` → `"md"`, `large` → `"lg"`, `huge` → `"xl"`. `SizeValue` widens it the same way `ColorValue` widens `Color`.
92
92
 
93
- `ModalSize` is a separate, wider union used only for modal action targets: `"sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl"`.
93
+ `ModalSize` is a separate, wider union for modal and drawer action targets: `"sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl" | "full"`: a modal's width, and the width of a side drawer given that size, `full` the whole window.
94
94
 
95
95
  ## Buttons
96
96
 
@@ -145,9 +145,9 @@ import type { ActionTarget } from "@antelopejs/interface-dms/base/types/action-t
145
145
 
146
146
  | `type` | Fields |
147
147
  | --- | --- |
148
- | `"drawer"` | `component: Component`, `title?`, `description?`, `direction?: DrawerDirection` |
148
+ | `"drawer"` | `component: Component`, `title?`, `description?`, `direction?: DrawerDirection`, plus `DrawerTargetOptions`: `size?: ModalSize`, `rowHeader?`, `rowTitle?`, `positionLabel?`, `deepLink?`, `notFound?` |
149
149
  | `"modal"` | `component: Component`, `size?: ModalSize`, `title?`, `description?` |
150
- | `"sidePanel"` | `component: Component`, plus `SidePanelTargetOptions`: `title?`, `rowTitle?`, `positionLabel?`, `deepLink?`, `notFound?` |
150
+ | `"sidePanel"` | `component: Component`, plus `SidePanelTargetOptions`: `title?`, `rowTitle?`, `positionLabel?`, `deepLink?`, `notFound?` — for global, page-independent panels; a detail panel is a `drawer` |
151
151
  | `"page"` | `url: string`: the page URL's `{{params.X}}` / `{{query.X}}` (a query pair without a value left out), then on a row action the row's `{{row.field}}`; see [Template tokens](/docs/building/pages-and-components#template-tokens) |
152
152
  | `"external"` | `url: string`, `newTab?: boolean`; the same tokens as `page` |
153
153
  | `"download"` | `url: string`, `filename?: string` (`DownloadActionTarget`); the same tokens as `page`, the file name taking them as written. Fetched with the user's session and saved under `filename`, else the server's `Content-Disposition` name, else the URL's last segment; a failure is toasted |
@@ -155,9 +155,11 @@ import type { ActionTarget } from "@antelopejs/interface-dms/base/types/action-t
155
155
  | `"api"` | `url: string`, `method?: "GET" \| "POST" \| "PUT" \| "DELETE" \| "PATCH"`, `body?`, `copy?`, `successMessage: string`; the same tokens as `page` |
156
156
  | `"exportJob"` | `url: string`, `method?: "GET" \| "POST"`, `statusUrl?`, `downloadUrl?`, `labels?`; the same tokens as `page`, plus `{{job.id}}` in `statusUrl` and `downloadUrl` |
157
157
 
158
- `SidePanelTargetOptions` sets the header the DMS draws above a `sidePanel` target's component: `title` (the action's label by default; `$`: an i18n key), `rowTitle`, the title while a row is open, an i18n key or a literal receiving the row's fields (`"{method} {path}"`), `positionLabel`, an i18n key receiving `{ index }` (from 1) and `{ total }` for the row's position among the rows the table shows (the DMS's "{index} of {total}" by default), and `deepLink`, which keeps the open row in the URL like the action's own `deepLink` — on by default for a side panel, `false` opting out (the action's own `deepLink` wins when set), and `notFound` (`SidePanelNotFoundTexts`: `title?`, `description?`), the state the panel draws with a close button when the row a link names answers 404. The panel is non-modal and docked on the right, one at a time with a module's side panels.
158
+ `SidePanelTargetOptions` sets the header the DMS draws above a `sidePanel` target's component: `title` (the action's label by default; `$`: an i18n key), `rowTitle`, the title while a row is open, an i18n key or a literal receiving the row's fields (`"{method} {path}"`), `positionLabel`, an i18n key receiving `{ index }` (from 1) and `{ total }` for the row's position among all the rows the table lists, its count rather than the page loaded (the DMS's "{index} of {total}" by default), and `deepLink`, which keeps the open row in the URL like the action's own `deepLink` — on by default for a side panel, `false` opting out (the action's own `deepLink` wins when set), and `notFound` (`SidePanelNotFoundTexts`: `title?`, `description?`), the state the panel draws with a close button when the row a link names answers 404. The panel is non-modal and docked on the right, one at a time with a module's side panels. It is meant for global, page-independent panels (an assistant dock): a detail or record panel opens in a `drawer`.
159
159
 
160
- `DrawerDirection` is `"top" | "right" | "bottom" | "left"`, the edge the drawer slides in from: `bottom` by default, while `left` and `right` open a side sheet 440px wide (a side panel's default width) spanning the window's height.
160
+ `DrawerTargetOptions` gives a `drawer` target opened on a row the same options, so a detail panel moves from `sidePanel` to `drawer` without losing any: `rowTitle`, `positionLabel` and `notFound` as above; `rowHeader`, the DMS drawing the row header (the title naming the row, its position, previous and next buttons next to the close button) — on when `rowTitle` or `positionLabel` is set, `false` leaving the drawer's own title, `true` drawing it with the defaults; `deepLink`, off by default for a drawer (the action's own `deepLink` wins when set); and `size` (`ModalSize`), the width a `left` / `right` drawer opens at from `lg`, a modal's width at that size. J and K step through the rows in place whatever the header. A drawer shows its not-found state only once its target sets `notFound`; otherwise a deep link to a missing row is a toast, as before.
161
+
162
+ `DrawerDirection` is `"top" | "right" | "bottom" | "left"`, the edge the drawer slides in from: `bottom` by default, while `left` and `right` open a side sheet spanning the window's height, 600px wide from `lg` without a `size` (capped to the window) and 440px below it. From `lg` the user resizes every drawer by its inner edge (width or height), and the drawer opens again at the size it was left at.
161
163
 
162
164
  A target carries no confirmation: the action does (`CustomButton.confirm`, `CustomRowAction.confirm`, `RowActionConfig.confirm`), with one type, `ActionConfirm = ConfirmDialog | ConfirmDialogFrom` (see below). The `labels` object on `exportJob` accepts `title`, `exporting`, `downloading`, `successTitle`, `successMessage`, `errorTitle` and `retry`, all optional strings.
163
165