@antelopejs/interface-dms 0.7.4 → 0.8.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 (227) hide show
  1. package/CHANGELOG.md +107 -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 +46 -1
  40. package/dist/base/form-schema.js.map +1 -1
  41. package/dist/base/form-types.d.ts +56 -13
  42. package/dist/base/form-types.js +12 -4
  43. package/dist/base/form-types.js.map +1 -1
  44. package/dist/base/guard.d.ts +6 -6
  45. package/dist/base/helpers/archive-mode-helpers.js +6 -2
  46. package/dist/base/helpers/archive-mode-helpers.js.map +1 -1
  47. package/dist/base/index.d.ts +1 -0
  48. package/dist/base/index.js +1 -0
  49. package/dist/base/index.js.map +1 -1
  50. package/dist/base/internal/form-schema.js +2 -0
  51. package/dist/base/internal/form-schema.js.map +1 -1
  52. package/dist/base/internal/layouts.d.ts +4 -0
  53. package/dist/base/key-value-list.d.ts +12 -9
  54. package/dist/base/key-value-list.js +1 -1
  55. package/dist/base/key-value-list.js.map +1 -1
  56. package/dist/base/kpi-card.d.ts +39 -5
  57. package/dist/base/kpi-card.js +18 -6
  58. package/dist/base/kpi-card.js.map +1 -1
  59. package/dist/base/layouts.d.ts +24 -0
  60. package/dist/base/layouts.js.map +1 -1
  61. package/dist/base/live-toggle.d.ts +11 -0
  62. package/dist/base/live-toggle.js +8 -1
  63. package/dist/base/live-toggle.js.map +1 -1
  64. package/dist/base/page-toolbar.d.ts +158 -0
  65. package/dist/base/page-toolbar.js +198 -0
  66. package/dist/base/page-toolbar.js.map +1 -0
  67. package/dist/base/period-selector.d.ts +9 -2
  68. package/dist/base/period-selector.js.map +1 -1
  69. package/dist/base/placeholder.d.ts +2 -2
  70. package/dist/base/section.d.ts +2 -2
  71. package/dist/base/stack.d.ts +2 -2
  72. package/dist/base/stat-group.d.ts +18 -6
  73. package/dist/base/stat-group.js +9 -0
  74. package/dist/base/stat-group.js.map +1 -1
  75. package/dist/base/table-view/column-display.d.ts +9 -5
  76. package/dist/base/table-view/column-display.js +1 -1
  77. package/dist/base/table-view/column-display.js.map +1 -1
  78. package/dist/base/table-view/data-functions.d.ts +26 -7
  79. package/dist/base/table-view/data-functions.js +10 -6
  80. package/dist/base/table-view/data-functions.js.map +1 -1
  81. package/dist/base/table-view/factory.js +24 -13
  82. package/dist/base/table-view/factory.js.map +1 -1
  83. package/dist/base/table-view/internal/auth.d.ts +2 -0
  84. package/dist/base/table-view/internal/auth.js +3 -1
  85. package/dist/base/table-view/internal/auth.js.map +1 -1
  86. package/dist/base/table-view/internal/factory-helpers.d.ts +5 -0
  87. package/dist/base/table-view/internal/factory-helpers.js +55 -11
  88. package/dist/base/table-view/internal/factory-helpers.js.map +1 -1
  89. package/dist/base/table-view/internal/guards.js +19 -2
  90. package/dist/base/table-view/internal/guards.js.map +1 -1
  91. package/dist/base/table-view/internal/realtime.js +17 -0
  92. package/dist/base/table-view/internal/realtime.js.map +1 -1
  93. package/dist/base/table-view/internal/resource-form.js +1 -0
  94. package/dist/base/table-view/internal/resource-form.js.map +1 -1
  95. package/dist/base/table-view/internal/row-actions-schema.d.ts +296 -0
  96. package/dist/base/table-view/internal/row-actions-schema.js +62 -0
  97. package/dist/base/table-view/internal/row-actions-schema.js.map +1 -0
  98. package/dist/base/table-view/internal/validation.d.ts +15 -3
  99. package/dist/base/table-view/internal/validation.js +70 -2
  100. package/dist/base/table-view/internal/validation.js.map +1 -1
  101. package/dist/base/table-view/meta.d.ts +8 -0
  102. package/dist/base/table-view/meta.js +25 -3
  103. package/dist/base/table-view/meta.js.map +1 -1
  104. package/dist/base/table-view/options.d.ts +90 -16
  105. package/dist/base/table-view/options.js.map +1 -1
  106. package/dist/base/table-view/realtime.d.ts +8 -0
  107. package/dist/base/table-view/realtime.js.map +1 -1
  108. package/dist/base/table-view/routes.js +5 -2
  109. package/dist/base/table-view/routes.js.map +1 -1
  110. package/dist/base/table-view/schema.d.ts +36 -30
  111. package/dist/base/table-view/schema.js +11 -57
  112. package/dist/base/table-view/schema.js.map +1 -1
  113. package/dist/base/table-view/source.d.ts +1 -1
  114. package/dist/base/table-view/source.js +4 -1
  115. package/dist/base/table-view/source.js.map +1 -1
  116. package/dist/base/table-view.d.ts +2 -2
  117. package/dist/base/table-view.js.map +1 -1
  118. package/dist/base/top-list-card.d.ts +5 -5
  119. package/dist/base/types/action-target.d.ts +77 -20
  120. package/dist/base/types/confirm-dialog.d.ts +5 -1
  121. package/dist/base/types/custom-button.d.ts +5 -3
  122. package/dist/base/types/field-config.d.ts +4 -4
  123. package/dist/base/types/guards.d.ts +10 -0
  124. package/dist/base/types/row-action.d.ts +6 -1
  125. package/dist/base/types/size.d.ts +3 -1
  126. package/dist/client-ip.d.ts +6 -2
  127. package/dist/client-ip.js +4 -2
  128. package/dist/client-ip.js.map +1 -1
  129. package/dist/component.d.ts +8 -0
  130. package/dist/component.js.map +1 -1
  131. package/dist/data-controllers/members.d.ts +0 -3
  132. package/dist/data-controllers/members.js +30 -8
  133. package/dist/data-controllers/members.js.map +1 -1
  134. package/dist/db/internal/tenantLifecycle.model.js +5 -2
  135. package/dist/db/internal/tenantLifecycle.model.js.map +1 -1
  136. package/dist/guards.js +2 -3
  137. package/dist/guards.js.map +1 -1
  138. package/dist/hooks.d.ts +41 -5
  139. package/dist/hooks.js +5 -1
  140. package/dist/hooks.js.map +1 -1
  141. package/dist/index.d.ts +3 -3
  142. package/dist/index.js +3 -2
  143. package/dist/index.js.map +1 -1
  144. package/dist/internal/invite-membership.d.ts +9 -0
  145. package/dist/internal/invite-membership.js +25 -7
  146. package/dist/internal/invite-membership.js.map +1 -1
  147. package/dist/internal/member-changes.d.ts +24 -0
  148. package/dist/internal/member-changes.js +66 -0
  149. package/dist/internal/member-changes.js.map +1 -0
  150. package/dist/internal/tenant-ownership.d.ts +4 -1
  151. package/dist/internal/tenant-ownership.js +4 -10
  152. package/dist/internal/tenant-ownership.js.map +1 -1
  153. package/dist/invite-extensions.d.ts +0 -9
  154. package/dist/invite-extensions.js +1 -36
  155. package/dist/invite-extensions.js.map +1 -1
  156. package/dist/invite-resolution.js +6 -2
  157. package/dist/invite-resolution.js.map +1 -1
  158. package/dist/invites.js +36 -22
  159. package/dist/invites.js.map +1 -1
  160. package/dist/layout-banners.d.ts +40 -4
  161. package/dist/layout-banners.js +25 -3
  162. package/dist/layout-banners.js.map +1 -1
  163. package/dist/notifications/internal.d.ts +2 -2
  164. package/dist/notifications/internal.js.map +1 -1
  165. package/dist/notifications/sendable.d.ts +3 -2
  166. package/dist/notifications/sendable.js +2 -1
  167. package/dist/notifications/sendable.js.map +1 -1
  168. package/dist/notifications/types.d.ts +9 -0
  169. package/dist/page/controllers.d.ts +44 -0
  170. package/dist/page/controllers.js +14 -1
  171. package/dist/page/controllers.js.map +1 -1
  172. package/dist/page/internal/layout-filter.d.ts +2 -1
  173. package/dist/page/internal/layout-filter.js +3 -1
  174. package/dist/page/internal/layout-filter.js.map +1 -1
  175. package/dist/page/internal/metadata-helpers.d.ts +7 -0
  176. package/dist/page/internal/metadata-helpers.js +13 -2
  177. package/dist/page/internal/metadata-helpers.js.map +1 -1
  178. package/dist/page/internal/page-registration.d.ts +2 -0
  179. package/dist/page/internal/page-registration.js.map +1 -1
  180. package/dist/page/layout-filter.js +31 -10
  181. package/dist/page/layout-filter.js.map +1 -1
  182. package/dist/page/metadata.js +29 -24
  183. package/dist/page/metadata.js.map +1 -1
  184. package/dist/page/types.d.ts +39 -4
  185. package/dist/page/types.js.map +1 -1
  186. package/dist/page.d.ts +1 -1
  187. package/dist/page.js +2 -1
  188. package/dist/page.js.map +1 -1
  189. package/dist/permissions-resolver.d.ts +26 -3
  190. package/dist/permissions-resolver.js +2 -1
  191. package/dist/permissions-resolver.js.map +1 -1
  192. package/dist/permissions.d.ts +7 -3
  193. package/dist/permissions.js +8 -6
  194. package/dist/permissions.js.map +1 -1
  195. package/dist/realtime.d.ts +24 -1
  196. package/dist/realtime.js +7 -1
  197. package/dist/realtime.js.map +1 -1
  198. package/dist/request-tenant.d.ts +17 -13
  199. package/dist/request-tenant.js +54 -42
  200. package/dist/request-tenant.js.map +1 -1
  201. package/dist/tenant-members.d.ts +26 -0
  202. package/dist/tenant-members.js +29 -0
  203. package/dist/tenant-members.js.map +1 -0
  204. package/dist/tenant-ownership.d.ts +4 -1
  205. package/dist/tenant-ownership.js +12 -17
  206. package/dist/tenant-ownership.js.map +1 -1
  207. package/dist/tenant-scoped-model.d.ts +2 -1
  208. package/dist/tenant-scoped-model.js +4 -3
  209. package/dist/tenant-scoped-model.js.map +1 -1
  210. package/docs/1.core/10.layout-banners.md +57 -2
  211. package/docs/1.core/2.pages-and-modules.md +20 -11
  212. package/docs/1.core/4.permissions.md +34 -1
  213. package/docs/1.core/5.tenancy.md +37 -6
  214. package/docs/1.core/7.hooks.md +12 -2
  215. package/docs/1.core/8.realtime.md +24 -3
  216. package/docs/2.auth/2.authentication.md +3 -3
  217. package/docs/3.base/1.introduction.md +1 -0
  218. package/docs/3.base/10.display-blocks.md +20 -2
  219. package/docs/3.base/2.types.md +7 -5
  220. package/docs/3.base/3.forms.md +21 -6
  221. package/docs/3.base/4.tables.md +14 -9
  222. package/docs/3.base/5.charts-and-widgets.md +8 -4
  223. package/docs/3.base/6.layout.md +38 -1
  224. package/docs/3.base/8.data-types.md +14 -7
  225. package/docs/4.notifications/3.sending.md +4 -3
  226. package/docs/5.html-render/1.introduction.md +5 -7
  227. package/package.json +3 -7
@@ -1,58 +1,70 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.getRequestTenantId = getRequestTenantId;
4
- const jsonwebtoken_1 = require("jsonwebtoken");
4
+ exports.resolveRequestTenantId = resolveRequestTenantId;
5
+ const auth_1 = require("./auth");
5
6
  const request_authenticators_1 = require("./auth/internal/request-authenticators");
6
7
  const constants_1 = require("./constants");
7
- const BEARER_PREFIX = "Bearer ";
8
- function extractBearerToken(authHeader) {
9
- if (typeof authHeader !== "string")
10
- return undefined;
11
- if (!authHeader.startsWith(BEARER_PREFIX))
12
- return undefined;
13
- return authHeader.slice(BEARER_PREFIX.length);
8
+ const BEARER_SCHEME = /^Bearer\s+(\S+)$/i;
9
+ const pendingResolutions = new WeakMap();
10
+ /**
11
+ * The tenant of the request's verified credential: the one bound by the DMS
12
+ * auth decorators (`AuthUser`, `AuthUserWithPermission`, `IfAuthUser` with a
13
+ * valid token…), the tenant guards or `authenticateRequestPrincipal`.
14
+ * Without a verified credential, `DEFAULT_TENANT_ID`: a token is never read
15
+ * for its tenant before its signature, user and session are checked.
16
+ *
17
+ * Call it once the route's auth decorator has run (in the handler). A
18
+ * parameter or property provider resolved alongside the auth decorator uses
19
+ * {@link resolveRequestTenantId} instead.
20
+ *
21
+ * @throws HTTPResult 401 when the request's authentication failed or its
22
+ * credential changed since
23
+ */
24
+ function getRequestTenantId(ctx) {
25
+ return (0, request_authenticators_1.getAuthenticatedRequestTenantId)(ctx) ?? constants_1.DEFAULT_TENANT_ID;
14
26
  }
15
- function readTenantIdFromToken(token) {
27
+ async function verifyRequestTenantId(ctx, token) {
16
28
  try {
17
- const payload = (0, jsonwebtoken_1.decode)(token);
18
- if (payload &&
19
- typeof payload.tenantId === "string" &&
20
- payload.tenantId.length > 0) {
21
- return payload.tenantId;
22
- }
29
+ const data = await auth_1.internal.IfAuthUserAuthenticator(token);
30
+ if (!data)
31
+ return constants_1.DEFAULT_TENANT_ID;
32
+ const user = await auth_1.internal.AuthRawUserValidator(data);
33
+ if (!user || typeof user === "boolean")
34
+ return constants_1.DEFAULT_TENANT_ID;
35
+ const tenantId = data.tenantId || constants_1.DEFAULT_TENANT_ID;
36
+ (0, request_authenticators_1.bindVerifiedRequestPrincipal)(ctx, { userId: user._id, tenantId });
37
+ return tenantId;
23
38
  }
24
39
  catch {
25
- return undefined;
40
+ return constants_1.DEFAULT_TENANT_ID;
26
41
  }
27
- return undefined;
28
42
  }
29
43
  /**
30
- * Prefer the tenant bound by authenticateRequestPrincipal or the tenant guards.
31
- * An authentication failure or changed credential makes this accessor throw.
32
- * Without a bound principal, preserve legacy JWT decoding / DEFAULT_TENANT_ID.
33
- *
34
- * The legacy value is only *decoded*: this function does NOT verify the
35
- * JWT signature. It is safe when the request has already been authenticated
36
- * upstream (auth decorators such as `@AuthUser`, `@AuthTenantMember`, or the
37
- * gating performed by `PageController`) — those validators verify the signature
38
- * before this function runs, so the decoded `tenantId` is authentic.
39
- *
40
- * For routes that legitimately accept anonymous traffic (e.g. `@IfAuthUser`),
41
- * the fallback resolves to `DEFAULT_TENANT_ID` so public layouts can be served.
42
- *
43
- * Do NOT introduce new routes that rely on `tenantId` for authorization without
44
- * upstream auth verification — without signature checks, the claim is forgeable.
44
+ * {@link getRequestTenantId} for a provider that may run before the route's
45
+ * auth decorator (`TenantScopedModel`, a tenant-scoped `Model`): a bearer
46
+ * token not yet verified is verified here, and its tenant bound to the
47
+ * request. An invalid token reads as anonymous: `DEFAULT_TENANT_ID`.
45
48
  */
46
- function getRequestTenantId(ctx) {
47
- const authenticatedTenantId = (0, request_authenticators_1.getAuthenticatedRequestTenantId)(ctx);
48
- if (authenticatedTenantId)
49
- return authenticatedTenantId;
50
- const token = extractBearerToken(ctx.rawRequest?.headers?.authorization);
51
- if (token) {
52
- const tenantIdFromJwt = readTenantIdFromToken(token);
53
- if (tenantIdFromJwt)
54
- return tenantIdFromJwt;
49
+ async function resolveRequestTenantId(ctx) {
50
+ const bound = (0, request_authenticators_1.getAuthenticatedRequestTenantId)(ctx);
51
+ if (bound)
52
+ return bound;
53
+ const header = ctx.rawRequest?.headers?.authorization;
54
+ const token = typeof header === "string" ? header.match(BEARER_SCHEME)?.[1] : undefined;
55
+ if (!token)
56
+ return constants_1.DEFAULT_TENANT_ID;
57
+ const request = ctx.rawRequest;
58
+ const pending = pendingResolutions.get(request);
59
+ if (pending)
60
+ return pending;
61
+ const resolution = verifyRequestTenantId(ctx, token);
62
+ pendingResolutions.set(request, resolution);
63
+ try {
64
+ return await resolution;
65
+ }
66
+ finally {
67
+ pendingResolutions.delete(request);
55
68
  }
56
- return constants_1.DEFAULT_TENANT_ID;
57
69
  }
58
70
  //# sourceMappingURL=request-tenant.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"request-tenant.js","sourceRoot":"","sources":["../src/request-tenant.ts"],"names":[],"mappings":";;AAkDA,gDASC;AA1DD,+CAAsC;AACtC,mFAAyF;AACzF,2CAAgD;AAEhD,MAAM,aAAa,GAAG,SAAS,CAAC;AAMhC,SAAS,kBAAkB,CAAC,UAAmB;IAC7C,IAAI,OAAO,UAAU,KAAK,QAAQ;QAAE,OAAO,SAAS,CAAC;IACrD,IAAI,CAAC,UAAU,CAAC,UAAU,CAAC,aAAa,CAAC;QAAE,OAAO,SAAS,CAAC;IAC5D,OAAO,UAAU,CAAC,KAAK,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC;AAChD,CAAC;AAED,SAAS,qBAAqB,CAAC,KAAa;IAC1C,IAAI,CAAC;QACH,MAAM,OAAO,GAAG,IAAA,qBAAM,EAAC,KAAK,CAAgC,CAAC;QAC7D,IACE,OAAO;YACP,OAAO,OAAO,CAAC,QAAQ,KAAK,QAAQ;YACpC,OAAO,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,EAC3B,CAAC;YACD,OAAO,OAAO,CAAC,QAAQ,CAAC;QAC1B,CAAC;IACH,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,SAAgB,kBAAkB,CAAC,GAAmB;IACpD,MAAM,qBAAqB,GAAG,IAAA,wDAA+B,EAAC,GAAG,CAAC,CAAC;IACnE,IAAI,qBAAqB;QAAE,OAAO,qBAAqB,CAAC;IACxD,MAAM,KAAK,GAAG,kBAAkB,CAAC,GAAG,CAAC,UAAU,EAAE,OAAO,EAAE,aAAa,CAAC,CAAC;IACzE,IAAI,KAAK,EAAE,CAAC;QACV,MAAM,eAAe,GAAG,qBAAqB,CAAC,KAAK,CAAC,CAAC;QACrD,IAAI,eAAe;YAAE,OAAO,eAAe,CAAC;IAC9C,CAAC;IACD,OAAO,6BAAiB,CAAC;AAC3B,CAAC"}
1
+ {"version":3,"file":"request-tenant.js","sourceRoot":"","sources":["../src/request-tenant.ts"],"names":[],"mappings":";;AA2BA,gDAEC;AAyBD,wDAmBC;AAvED,iCAAkC;AAClC,mFAGgD;AAChD,2CAAgD;AAEhD,MAAM,aAAa,GAAG,mBAAmB,CAAC;AAE1C,MAAM,kBAAkB,GAAG,IAAI,OAAO,EAAoC,CAAC;AAE3E;;;;;;;;;;;;;GAaG;AACH,SAAgB,kBAAkB,CAAC,GAAmB;IACpD,OAAO,IAAA,wDAA+B,EAAC,GAAG,CAAC,IAAI,6BAAiB,CAAC;AACnE,CAAC;AAED,KAAK,UAAU,qBAAqB,CAClC,GAAmB,EACnB,KAAa;IAEb,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,eAAQ,CAAC,uBAAuB,CAAC,KAAK,CAAC,CAAC;QAC3D,IAAI,CAAC,IAAI;YAAE,OAAO,6BAAiB,CAAC;QACpC,MAAM,IAAI,GAAG,MAAM,eAAQ,CAAC,oBAAoB,CAAC,IAAI,CAAC,CAAC;QACvD,IAAI,CAAC,IAAI,IAAI,OAAO,IAAI,KAAK,SAAS;YAAE,OAAO,6BAAiB,CAAC;QACjE,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,IAAI,6BAAiB,CAAC;QACpD,IAAA,qDAA4B,EAAC,GAAG,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,GAAG,EAAE,QAAQ,EAAE,CAAC,CAAC;QAClE,OAAO,QAAQ,CAAC;IAClB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,6BAAiB,CAAC;IAC3B,CAAC;AACH,CAAC;AAED;;;;;GAKG;AACI,KAAK,UAAU,sBAAsB,CAC1C,GAAmB;IAEnB,MAAM,KAAK,GAAG,IAAA,wDAA+B,EAAC,GAAG,CAAC,CAAC;IACnD,IAAI,KAAK;QAAE,OAAO,KAAK,CAAC;IACxB,MAAM,MAAM,GAAG,GAAG,CAAC,UAAU,EAAE,OAAO,EAAE,aAAa,CAAC;IACtD,MAAM,KAAK,GACT,OAAO,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,aAAa,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;IAC5E,IAAI,CAAC,KAAK;QAAE,OAAO,6BAAiB,CAAC;IACrC,MAAM,OAAO,GAAG,GAAG,CAAC,UAAU,CAAC;IAC/B,MAAM,OAAO,GAAG,kBAAkB,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;IAChD,IAAI,OAAO;QAAE,OAAO,OAAO,CAAC;IAC5B,MAAM,UAAU,GAAG,qBAAqB,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IACrD,kBAAkB,CAAC,GAAG,CAAC,OAAO,EAAE,UAAU,CAAC,CAAC;IAC5C,IAAI,CAAC;QACH,OAAO,MAAM,UAAU,CAAC;IAC1B,CAAC;YAAS,CAAC;QACT,kBAAkB,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IACrC,CAAC;AACH,CAAC"}
@@ -0,0 +1,26 @@
1
+ import { type MemberChange } from "./hooks";
2
+ /** What one membership or invitation write changes, and the write itself. */
3
+ export interface TenantMembersWrite<T> {
4
+ /**
5
+ * Announced through `Hook.MEMBER_BEING_CHANGED`, in order, before `write`
6
+ * runs. Empty for a write that changes no membership, such as an invitation
7
+ * created, edited or retired.
8
+ */
9
+ changes: MemberChange[];
10
+ write: () => Promise<T>;
11
+ }
12
+ /**
13
+ * The one entry point for writing tenant members and invitations: every
14
+ * insert, update or delete of a `TenantMemberModel` or `UserInviteModel` row
15
+ * goes through it. It admits the write under the tenant's lifecycle (refused
16
+ * while a barrier is held or once the tenant is closed), calls `plan` to read
17
+ * the current state and describe the change, fires `Hook.MEMBER_BEING_CHANGED`
18
+ * for each change (a throwing handler refuses the write, replays included),
19
+ * then runs the write and releases the admission.
20
+ *
21
+ * `plan` runs inside the admission so the change it describes is read from
22
+ * state no paused initiator is validating. Never call this from inside
23
+ * another `changeTenantMembers` or `runTenantLifecycleOperation` callback:
24
+ * each call takes its own admission.
25
+ */
26
+ export declare function changeTenantMembers<T>(tenantId: string, plan: () => Promise<TenantMembersWrite<T>>): Promise<T>;
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.changeTenantMembers = changeTenantMembers;
4
+ const hooks_1 = require("./hooks");
5
+ const tenant_lifecycle_1 = require("./tenant-lifecycle");
6
+ /**
7
+ * The one entry point for writing tenant members and invitations: every
8
+ * insert, update or delete of a `TenantMemberModel` or `UserInviteModel` row
9
+ * goes through it. It admits the write under the tenant's lifecycle (refused
10
+ * while a barrier is held or once the tenant is closed), calls `plan` to read
11
+ * the current state and describe the change, fires `Hook.MEMBER_BEING_CHANGED`
12
+ * for each change (a throwing handler refuses the write, replays included),
13
+ * then runs the write and releases the admission.
14
+ *
15
+ * `plan` runs inside the admission so the change it describes is read from
16
+ * state no paused initiator is validating. Never call this from inside
17
+ * another `changeTenantMembers` or `runTenantLifecycleOperation` callback:
18
+ * each call takes its own admission.
19
+ */
20
+ async function changeTenantMembers(tenantId, plan) {
21
+ return (0, tenant_lifecycle_1.runTenantLifecycleOperation)(tenantId, async () => {
22
+ const { changes, write } = await plan();
23
+ for (const change of changes) {
24
+ await (0, hooks_1.ExecuteHooks)(hooks_1.Hook.MEMBER_BEING_CHANGED, { tenantId, ...change });
25
+ }
26
+ return write();
27
+ });
28
+ }
29
+ //# sourceMappingURL=tenant-members.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tenant-members.js","sourceRoot":"","sources":["../src/tenant-members.ts"],"names":[],"mappings":";;AA4BA,kDAWC;AAvCD,mCAAgE;AAChE,yDAAiE;AAajE;;;;;;;;;;;;;GAaG;AACI,KAAK,UAAU,mBAAmB,CACvC,QAAgB,EAChB,IAA0C;IAE1C,OAAO,IAAA,8CAA2B,EAAC,QAAQ,EAAE,KAAK,IAAI,EAAE;QACtD,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,GAAG,MAAM,IAAI,EAAE,CAAC;QACxC,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;YAC7B,MAAM,IAAA,oBAAY,EAAC,YAAI,CAAC,oBAAoB,EAAE,EAAE,QAAQ,EAAE,GAAG,MAAM,EAAE,CAAC,CAAC;QACzE,CAAC;QACD,OAAO,KAAK,EAAE,CAAC;IACjB,CAAC,CAAC,CAAC;AACL,CAAC"}
@@ -6,7 +6,10 @@ export interface TenantMembershipPatch {
6
6
  /** Durable invite delivery; grants membership without replacing later edits. */
7
7
  deliveryId?: string;
8
8
  }
9
- /** Admits membership writes before a tenant can begin destructive deletion. */
9
+ /**
10
+ * Adds the user to the tenant, or sets the roles and ownership of an existing
11
+ * member, through `changeTenantMembers`.
12
+ */
10
13
  export declare function applyTenantOwnership(userModel: UserModel, userId: string, tenantId: string, patch: TenantMembershipPatch): Promise<void>;
11
14
  export declare function syncPlatformOwnerOnTenantOwnerChange(userModel: UserModel, userId: string, tenantId: string, nextIsTenantOwner: boolean): Promise<void>;
12
15
  export declare function clearPlatformOwnerOnMemberRemoval(userModel: UserModel, userId: string, tenantId: string, removedMemberWasTenantOwner: boolean): Promise<void>;
@@ -5,25 +5,20 @@ exports.syncPlatformOwnerOnTenantOwnerChange = syncPlatformOwnerOnTenantOwnerCha
5
5
  exports.clearPlatformOwnerOnMemberRemoval = clearPlatformOwnerOnMemberRemoval;
6
6
  const interface_database_decorators_1 = require("@antelopejs/interface-database-decorators");
7
7
  const tenantMembers_model_1 = require("./db/models/tenantMembers.model");
8
- const hooks_1 = require("./hooks");
9
- const tenant_lifecycle_1 = require("./tenant-lifecycle");
8
+ const tenant_members_1 = require("./tenant-members");
9
+ const member_changes_1 = require("./internal/member-changes");
10
10
  const tenant_ownership_1 = require("./internal/tenant-ownership");
11
- /** Admits membership writes before a tenant can begin destructive deletion. */
11
+ /**
12
+ * Adds the user to the tenant, or sets the roles and ownership of an existing
13
+ * member, through `changeTenantMembers`.
14
+ */
12
15
  async function applyTenantOwnership(userModel, userId, tenantId, patch) {
13
- await (0, tenant_lifecycle_1.runTenantLifecycleOperation)(tenantId, async () => {
14
- // Public callers supplying a delivery identity still need the policy veto.
15
- // Internal resolution checks it before persisting its applying phase.
16
- if (patch.deliveryId &&
17
- !(await (0, interface_database_decorators_1.GetModel)(tenantMembers_model_1.TenantMemberModel, tenantId).getByUser(userId))) {
18
- await (0, hooks_1.ExecuteHooks)(hooks_1.Hook.MEMBER_BEING_CHANGED, {
19
- tenantId,
20
- userId,
21
- isTenantOwner: patch.isTenantOwner,
22
- previousIsTenantOwner: null,
23
- deliveryId: patch.deliveryId,
24
- });
25
- }
26
- await (0, tenant_ownership_1.applyAdmittedTenantOwnership)(userModel, userId, tenantId, patch);
16
+ await (0, tenant_members_1.changeTenantMembers)(tenantId, async () => {
17
+ const existing = await (0, interface_database_decorators_1.GetModel)(tenantMembers_model_1.TenantMemberModel, tenantId).getByUser(userId);
18
+ return {
19
+ changes: (0, member_changes_1.describeMembershipPatch)(existing, userId, patch),
20
+ write: () => (0, tenant_ownership_1.applyAdmittedTenantOwnership)(userModel, userId, tenantId, patch),
21
+ };
27
22
  });
28
23
  }
29
24
  async function syncPlatformOwnerOnTenantOwnerChange(userModel, userId, tenantId, nextIsTenantOwner) {
@@ -1 +1 @@
1
- {"version":3,"file":"tenant-ownership.js","sourceRoot":"","sources":["../src/tenant-ownership.ts"],"names":[],"mappings":";;AAmBA,oDAuBC;AAED,oFAOC;AAED,8EAQC;AA5DD,6FAAqE;AACrE,yEAAoE;AACpE,mCAA6C;AAC7C,yDAAiE;AACjE,kEAGqC;AAUrC,+EAA+E;AACxE,KAAK,UAAU,oBAAoB,CACxC,SAAoB,EACpB,MAAc,EACd,QAAgB,EAChB,KAA4B;IAE5B,MAAM,IAAA,8CAA2B,EAAC,QAAQ,EAAE,KAAK,IAAI,EAAE;QACrD,2EAA2E;QAC3E,sEAAsE;QACtE,IACE,KAAK,CAAC,UAAU;YAChB,CAAC,CAAC,MAAM,IAAA,wCAAQ,EAAC,uCAAiB,EAAE,QAAQ,CAAC,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,EAChE,CAAC;YACD,MAAM,IAAA,oBAAY,EAAC,YAAI,CAAC,oBAAoB,EAAE;gBAC5C,QAAQ;gBACR,MAAM;gBACN,aAAa,EAAE,KAAK,CAAC,aAAa;gBAClC,qBAAqB,EAAE,IAAI;gBAC3B,UAAU,EAAE,KAAK,CAAC,UAAU;aAC7B,CAAC,CAAC;QACL,CAAC;QACD,MAAM,IAAA,+CAA4B,EAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,CAAC,CAAC;IACzE,CAAC,CAAC,CAAC;AACL,CAAC;AAEM,KAAK,UAAU,oCAAoC,CACxD,SAAoB,EACpB,MAAc,EACd,QAAgB,EAChB,iBAA0B;IAE1B,MAAM,IAAA,sCAAmB,EAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,iBAAiB,CAAC,CAAC;AAC5E,CAAC;AAEM,KAAK,UAAU,iCAAiC,CACrD,SAAoB,EACpB,MAAc,EACd,QAAgB,EAChB,2BAAoC;IAEpC,IAAI,CAAC,2BAA2B;QAAE,OAAO;IACzC,MAAM,IAAA,sCAAmB,EAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,CAAC,CAAC;AAChE,CAAC"}
1
+ {"version":3,"file":"tenant-ownership.js","sourceRoot":"","sources":["../src/tenant-ownership.ts"],"names":[],"mappings":";;AAsBA,oDAgBC;AAED,oFAOC;AAED,8EAQC;AAxDD,6FAAqE;AACrE,yEAAoE;AACpE,qDAAuD;AACvD,8DAAoE;AACpE,kEAGqC;AAUrC;;;GAGG;AACI,KAAK,UAAU,oBAAoB,CACxC,SAAoB,EACpB,MAAc,EACd,QAAgB,EAChB,KAA4B;IAE5B,MAAM,IAAA,oCAAmB,EAAC,QAAQ,EAAE,KAAK,IAAI,EAAE;QAC7C,MAAM,QAAQ,GAAG,MAAM,IAAA,wCAAQ,EAAC,uCAAiB,EAAE,QAAQ,CAAC,CAAC,SAAS,CACpE,MAAM,CACP,CAAC;QACF,OAAO;YACL,OAAO,EAAE,IAAA,wCAAuB,EAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,CAAC;YACzD,KAAK,EAAE,GAAG,EAAE,CACV,IAAA,+CAA4B,EAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,CAAC;SACnE,CAAC;IACJ,CAAC,CAAC,CAAC;AACL,CAAC;AAEM,KAAK,UAAU,oCAAoC,CACxD,SAAoB,EACpB,MAAc,EACd,QAAgB,EAChB,iBAA0B;IAE1B,MAAM,IAAA,sCAAmB,EAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,iBAAiB,CAAC,CAAC;AAC5E,CAAC;AAEM,KAAK,UAAU,iCAAiC,CACrD,SAAoB,EACpB,MAAc,EACd,QAAgB,EAChB,2BAAoC;IAEpC,IAAI,CAAC,2BAA2B;QAAE,OAAO;IACzC,MAAM,IAAA,sCAAmB,EAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,CAAC,CAAC;AAChE,CAAC"}
@@ -2,7 +2,8 @@ import { type Class } from "@antelopejs/interface-core/decorators";
2
2
  import { type DataModel } from "@antelopejs/interface-database-decorators";
3
3
  /**
4
4
  * Parameter/property decorator that injects `GetModel(cl, currentTenantId)`,
5
- * where `currentTenantId` is resolved per-request via {@link getRequestTenantId}.
5
+ * where `currentTenantId` is the tenant of the request's verified credential
6
+ * ({@link resolveRequestTenantId}), `DEFAULT_TENANT_ID` without one.
6
7
  *
7
8
  * Use on a `Model`-typed parameter of a route handler or DataAPI field. Only
8
9
  * applicable when the target table is registered in the `dms-tenant` schema
@@ -7,15 +7,16 @@ const interface_database_decorators_1 = require("@antelopejs/interface-database-
7
7
  const request_tenant_1 = require("./request-tenant");
8
8
  /**
9
9
  * Parameter/property decorator that injects `GetModel(cl, currentTenantId)`,
10
- * where `currentTenantId` is resolved per-request via {@link getRequestTenantId}.
10
+ * where `currentTenantId` is the tenant of the request's verified credential
11
+ * ({@link resolveRequestTenantId}), `DEFAULT_TENANT_ID` without one.
11
12
  *
12
13
  * Use on a `Model`-typed parameter of a route handler or DataAPI field. Only
13
14
  * applicable when the target table is registered in the `dms-tenant` schema
14
15
  * (or any schema with one instance per tenant).
15
16
  */
16
17
  exports.TenantScopedModel = (0, decorators_1.MakeParameterAndPropertyDecorator)((target, key, index, cl) => {
17
- (0, interface_api_1.SetParameterProvider)(target, key, index, (ctx) => {
18
- return (0, interface_database_decorators_1.GetModel)(cl, (0, request_tenant_1.getRequestTenantId)(ctx));
18
+ (0, interface_api_1.SetParameterProvider)(target, key, index, async (ctx) => {
19
+ return (0, interface_database_decorators_1.GetModel)(cl, await (0, request_tenant_1.resolveRequestTenantId)(ctx));
19
20
  });
20
21
  });
21
22
  //# sourceMappingURL=tenant-scoped-model.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"tenant-scoped-model.js","sourceRoot":"","sources":["../src/tenant-scoped-model.ts"],"names":[],"mappings":";;;AAAA,6DAGmC;AACnC,sEAG+C;AAC/C,6FAGmD;AACnD,qDAAsD;AAEtD;;;;;;;GAOG;AACU,QAAA,iBAAiB,GAAG,IAAA,8CAAiC,EAChE,CAAC,MAAM,EAAE,GAAG,EAAE,KAAK,EAAE,EAA8C,EAAE,EAAE;IACrE,IAAA,oCAAoB,EAAC,MAAM,EAAE,GAAG,EAAE,KAAK,EAAE,CAAC,GAAmB,EAAE,EAAE;QAC/D,OAAO,IAAA,wCAAQ,EAAC,EAAE,EAAE,IAAA,mCAAkB,EAAC,GAAG,CAAC,CAAC,CAAC;IAC/C,CAAC,CAAC,CAAC;AACL,CAAC,CACF,CAAC"}
1
+ {"version":3,"file":"tenant-scoped-model.js","sourceRoot":"","sources":["../src/tenant-scoped-model.ts"],"names":[],"mappings":";;;AAAA,6DAGmC;AACnC,sEAG+C;AAC/C,6FAGmD;AACnD,qDAA0D;AAE1D;;;;;;;;GAQG;AACU,QAAA,iBAAiB,GAAG,IAAA,8CAAiC,EAChE,CAAC,MAAM,EAAE,GAAG,EAAE,KAAK,EAAE,EAA8C,EAAE,EAAE;IACrE,IAAA,oCAAoB,EAAC,MAAM,EAAE,GAAG,EAAE,KAAK,EAAE,KAAK,EAAE,GAAmB,EAAE,EAAE;QACrE,OAAO,IAAA,wCAAQ,EAAC,EAAE,EAAE,MAAM,IAAA,uCAAsB,EAAC,GAAG,CAAC,CAAC,CAAC;IACzD,CAAC,CAAC,CAAC;AACL,CAAC,CACF,CAAC"}
@@ -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` |
@@ -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