@antelopejs/interface-dms 0.7.1 → 0.7.2

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 (56) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +70 -68
  3. package/dist/auth/db/models/sessions.model.d.ts +1 -0
  4. package/dist/auth/db/models/userExternalIdentities.model.d.ts +1 -0
  5. package/dist/auth/db/models/users.model.d.ts +1 -0
  6. package/dist/base/block-types.d.ts +1 -0
  7. package/dist/base/block-types.js +1 -0
  8. package/dist/base/block-types.js.map +1 -1
  9. package/dist/base/live-toggle.d.ts +2 -2
  10. package/dist/base/period-selector.d.ts +2 -2
  11. package/dist/base/table-view/schema.d.ts +4 -4
  12. package/dist/db/internal/exportJobs.model.d.ts +1 -0
  13. package/dist/db/internal/inviteResolutions.model.d.ts +1 -0
  14. package/dist/db/internal/tenantLifecycle.model.d.ts +1 -0
  15. package/dist/db/models/roles.model.d.ts +1 -0
  16. package/dist/db/models/tenantMembers.model.d.ts +1 -0
  17. package/dist/db/models/tenants.model.d.ts +1 -0
  18. package/dist/db/models/user_invites.model.d.ts +1 -0
  19. package/docs/.navigation.yml +2 -0
  20. package/docs/1.core/.navigation.yml +2 -0
  21. package/docs/1.core/1.introduction.md +169 -0
  22. package/docs/1.core/10.layout-banners.md +133 -0
  23. package/docs/1.core/2.pages-and-modules.md +647 -0
  24. package/docs/1.core/3.components.md +287 -0
  25. package/docs/1.core/4.permissions.md +279 -0
  26. package/docs/1.core/5.tenancy.md +356 -0
  27. package/docs/1.core/6.invites.md +239 -0
  28. package/docs/1.core/7.hooks.md +354 -0
  29. package/docs/1.core/8.realtime.md +147 -0
  30. package/docs/1.core/9.quick-actions.md +115 -0
  31. package/docs/2.auth/.navigation.yml +2 -0
  32. package/docs/2.auth/1.introduction.md +75 -0
  33. package/docs/2.auth/2.authentication.md +222 -0
  34. package/docs/2.auth/3.tokens-and-sessions.md +352 -0
  35. package/docs/2.auth/4.users.md +256 -0
  36. package/docs/2.auth/5.external-identities.md +122 -0
  37. package/docs/3.base/.navigation.yml +2 -0
  38. package/docs/3.base/1.introduction.md +202 -0
  39. package/docs/3.base/10.display-blocks.md +136 -0
  40. package/docs/3.base/2.types.md +402 -0
  41. package/docs/3.base/3.forms.md +349 -0
  42. package/docs/3.base/4.tables.md +608 -0
  43. package/docs/3.base/5.charts-and-widgets.md +466 -0
  44. package/docs/3.base/6.layout.md +431 -0
  45. package/docs/3.base/8.data-types.md +361 -0
  46. package/docs/3.base/9.helpers.md +414 -0
  47. package/docs/4.notifications/.navigation.yml +2 -0
  48. package/docs/4.notifications/1.introduction.md +79 -0
  49. package/docs/4.notifications/2.categories-and-subjects.md +212 -0
  50. package/docs/4.notifications/3.sending.md +283 -0
  51. package/docs/5.html-render/.navigation.yml +2 -0
  52. package/docs/5.html-render/1.introduction.md +372 -0
  53. package/docs/5.html-render/2.built-in-emails.md +107 -0
  54. package/docs/index.md +100 -0
  55. package/docs/tsconfig.json +11 -0
  56. package/package.json +24 -18
@@ -0,0 +1,356 @@
1
+ ---
2
+ title: "Tenancy"
3
+ description: "The DMS serves several tenants from one backend."
4
+ ---
5
+
6
+ # Tenancy
7
+
8
+ ## Overview
9
+
10
+ The DMS serves several tenants from one backend. Tenant-owned tables live in a per-tenant schema, and a model handle is bound to one tenant when you obtain it: `GetModel(TenantMemberModel, tenantId)` reads and writes that tenant's rows only. No query filters rows by tenant, and no table carries a `tenantId` column — the isolation is in the handle.
11
+
12
+ The tenancy surface spans several subpaths:
13
+
14
+ | Subpath | Contains |
15
+ | ------- | -------- |
16
+ | `@antelopejs/interface-dms/constants` | Schema names and the default tenant |
17
+ | `@antelopejs/interface-dms/db` | The `Tenant`, `TenantMember` and `Role` tables and their models |
18
+ | `@antelopejs/interface-dms/request-tenant` | Resolving the request's tenant |
19
+ | `@antelopejs/interface-dms/tenant-scoped-model` | Injecting a tenant-bound model |
20
+ | `@antelopejs/interface-dms/tenant-access` | Tenant access gates |
21
+ | `@antelopejs/interface-dms/tenant-ownership` | Membership and ownership writes |
22
+ | `@antelopejs/interface-dms/tenant-lifecycle` | Producer admission and permanent closure before deletion |
23
+ | `@antelopejs/interface-dms/utils/saas-mode` | Declaring SaaS mode |
24
+ | `@antelopejs/interface-dms/data-controllers` | The built-in members and roles data controllers |
25
+
26
+ ## Constants
27
+
28
+ ```typescript
29
+ import {
30
+ CORE_SCHEMA_NAME,
31
+ DEFAULT_TENANT_ID,
32
+ DEFAULT_TENANT_NAME,
33
+ TENANT_SCHEMA_NAME,
34
+ } from "@antelopejs/interface-dms/constants";
35
+ ```
36
+
37
+ | Constant | Value | Meaning |
38
+ | -------- | ----- | ------- |
39
+ | `CORE_SCHEMA_NAME` | `"dms-core"` | Schema for tables with a single instance across the whole backend. |
40
+ | `TENANT_SCHEMA_NAME` | `"dms-tenant"` | Schema instantiated once per tenant. |
41
+ | `DEFAULT_TENANT_ID` | `"default"` | The tenant an unauthenticated request resolves to, and the only tenant a single-tenant project uses. |
42
+ | `DEFAULT_TENANT_NAME` | `"Default"` | Display name of that tenant. |
43
+
44
+ Register your own table in `TENANT_SCHEMA_NAME` to make it per-tenant, or in `CORE_SCHEMA_NAME` to keep one instance of it:
45
+
46
+ ```typescript
47
+ import {
48
+ Field,
49
+ Index,
50
+ RegisterTable,
51
+ Table,
52
+ } from "@antelopejs/interface-database-decorators";
53
+ import { TENANT_SCHEMA_NAME } from "@antelopejs/interface-dms/constants";
54
+
55
+ @RegisterTable("invoices", TENANT_SCHEMA_NAME)
56
+ export class Invoice extends Table {
57
+ @Field("string")
58
+ declare _id: string;
59
+
60
+ @Index()
61
+ @Field("string")
62
+ declare reference: string;
63
+ }
64
+ ```
65
+
66
+ ## The Tenancy Tables
67
+
68
+ All three tables and their models are exported from `@antelopejs/interface-dms/db`, along with their table-name constants `tenantsTableName`, `tenantMembersTableName` and `ROLES_TABLE_NAME`.
69
+
70
+ ### `Tenant`
71
+
72
+ Table `tenants` in `dms-core` — one row per tenant, shared across the backend.
73
+
74
+ | Field | Type | Notes |
75
+ | ----- | ---- | ----- |
76
+ | `_id` | `string` | Tenant id. This is the instance id every per-tenant model handle takes. |
77
+ | `name` | `string` | Display name. |
78
+ | `createdAt` | `Date` | Creation time, indexed. |
79
+ | `updatedAt` | `Date` | Update time, indexed. |
80
+
81
+ `TenantModel` extends `BasicDataModel(Tenant, tenantsTableName)` and adds `getMany(ids: string[]): Promise<Tenant[]>`, which returns `[]` for an empty id list.
82
+
83
+ Being a core-schema table, `TenantModel` is obtained without an instance id: `GetModel(TenantModel)`.
84
+
85
+ ### `TenantMember`
86
+
87
+ Table `tenant_members` in `dms-tenant` — one row per member of the tenant the handle is bound to.
88
+
89
+ | Field | Type | Notes |
90
+ | ----- | ---- | ----- |
91
+ | `_id` | `string` | Membership id. |
92
+ | `userId` | `string` | Indexed, related to the `User` table of the [Auth](/docs/interfaces/auth/introduction) domain. |
93
+ | `roleIds` | `string[]` | Related to `Role`, many. The roles whose grants make up the member's base permission set. |
94
+ | `isTenantOwner` | `boolean` | Whether the member owns the tenant. |
95
+ | `joinedAt` | `Date` | Creation time, indexed. |
96
+ | `invitedBy` | `string \| null` | Indexed, related to `User`. |
97
+
98
+ `TenantMemberModel` extends `BasicDataModel(TenantMember, tenantMembersTableName)`:
99
+
100
+ | Method | Returns |
101
+ | ------ | ------- |
102
+ | `getByUser(userId)` | `Promise<TenantMember \| undefined>` |
103
+ | `existsByUser(userId)` | `Promise<boolean>` |
104
+ | `listAll()` | `Promise<TenantMember[]>` |
105
+ | `listOwners()` | `Promise<TenantMember[]>` — members with `isTenantOwner` set |
106
+ | `listByUserWithTenantIds(userId)` | `Promise<Array<{ tenantId: string; member: TenantMember }>>` |
107
+ | `getOldestByUser(userId)` | `Promise<{ tenantId: string; member: TenantMember } \| undefined>` — earliest `joinedAt` |
108
+ | `countOwnersExcluding(excludedUserIds)` | `Promise<number>` |
109
+
110
+ The two methods that return a `tenantId` alongside the row report which tenant each row came from, which is what a lookup that spans instances needs.
111
+
112
+ ### `Role`
113
+
114
+ Table `roles` in `dms-tenant` — the tenant's roles.
115
+
116
+ | Field | Type | Notes |
117
+ | ----- | ---- | ----- |
118
+ | `_id` | `string` | Role id, as referenced by `TenantMember.roleIds`. |
119
+ | `createdAt` | `Date` | Creation time, indexed. |
120
+ | `updatedAt` | `Date` | Update time, indexed. |
121
+ | `name` | `string` | Indexed. |
122
+ | `permissions` | `string[]` | Granted permission ids ([Permissions](/docs/interfaces/core/permissions)). |
123
+
124
+ `RoleModel` extends `BasicDataModel(Role, ROLES_TABLE_NAME)` and adds `getByName(name): Promise<Role | undefined>`.
125
+
126
+ ## Resolve the Request's Tenant
127
+
128
+ `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.
129
+
130
+ ```typescript
131
+ import {
132
+ Context,
133
+ Controller,
134
+ Get,
135
+ type RequestContext,
136
+ } from "@antelopejs/interface-api";
137
+ import { AuthUser } from "@antelopejs/interface-dms/auth";
138
+ import { getRequestTenantId } from "@antelopejs/interface-dms/request-tenant";
139
+ import type { User } from "@antelopejs/interface-dms/auth/db";
140
+
141
+ export class WorkspaceController extends Controller("/api/workspace") {
142
+ @Get("/current")
143
+ async current(@AuthUser() _user: User, @Context() ctx: RequestContext) {
144
+ return { tenantId: getRequestTenantId(ctx) };
145
+ }
146
+ }
147
+ ```
148
+
149
+ 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.
150
+
151
+ ## Scope a Model to the Request's Tenant
152
+
153
+ `TenantScopedModel(ModelClass)` is a parameter and property decorator that injects `GetModel(ModelClass, getRequestTenantId(ctx))`.
154
+
155
+ ```typescript
156
+ import { Controller, Get } from "@antelopejs/interface-api";
157
+ import { AuthTenantMember } from "@antelopejs/interface-dms/guards";
158
+ import { RoleModel } from "@antelopejs/interface-dms/db";
159
+ import { TenantScopedModel } from "@antelopejs/interface-dms/tenant-scoped-model";
160
+ import type { User } from "@antelopejs/interface-dms/auth/db";
161
+
162
+ export class RoleController extends Controller("/api/roles") {
163
+ @Get("/list")
164
+ async list(
165
+ @AuthTenantMember() _user: User,
166
+ @TenantScopedModel(RoleModel) roles: RoleModel,
167
+ ) {
168
+ return roles.getAll();
169
+ }
170
+ }
171
+ ```
172
+
173
+ As a property decorator it fills a data controller's `@ModelReference()` field:
174
+
175
+ ```typescript
176
+ import { ModelReference } from "@antelopejs/interface-data-api/metadata";
177
+ import { RoleModel } from "@antelopejs/interface-dms/db";
178
+ import { TenantScopedModel } from "@antelopejs/interface-dms/tenant-scoped-model";
179
+
180
+ // inside a DataController subclass
181
+ @ModelReference()
182
+ @TenantScopedModel(RoleModel)
183
+ declare model: RoleModel;
184
+ ```
185
+
186
+ Only use it for a model whose table lives in `TENANT_SCHEMA_NAME` or another schema with one instance per tenant. For a core-schema table, take the model without an instance id.
187
+
188
+ ## Tenant Access Gates
189
+
190
+ A gate is a binary, tenant-level check, evaluated at every tenant authorization surface. It answers whether the tenant may use the product at all, independently of any permission set.
191
+
192
+ ```typescript
193
+ import { RegisterTenantAccessGate } from "@antelopejs/interface-dms/tenant-access";
194
+
195
+ export async function construct(): Promise<void> {
196
+ RegisterTenantAccessGate({
197
+ id: "my_app.subscription-status",
198
+ order: 100,
199
+ gate: async (userId, tenantId) => {
200
+ if (await tenantIsSuspended(tenantId)) {
201
+ return { allowed: false, code: "my_app.errors.tenant_suspended" };
202
+ }
203
+ return { allowed: true };
204
+ },
205
+ });
206
+ }
207
+ ```
208
+
209
+ ```typescript
210
+ type TenantAccessResult = { allowed: true } | { allowed: false; code: string };
211
+
212
+ type TenantAccessGateFn = (
213
+ userId: string | undefined,
214
+ tenantId: string,
215
+ ) => Promise<TenantAccessResult> | TenantAccessResult;
216
+ ```
217
+
218
+ `TenantAccessGateInfo` carries `id`, `gate` and `order`.
219
+
220
+ Semantics the interface guarantees:
221
+
222
+ - Gates run in ascending `order`, ties in registration order; the first denial wins and its `code` is what reaches the client.
223
+ - Registering an id twice replaces the earlier gate rather than adding a second one.
224
+ - A gate that throws fails the request.
225
+ - The registration is removed when the registering module unloads.
226
+ - Register in `construct()` so the gate is active before the API serves requests.
227
+ - `userId` is `undefined` for an unauthenticated request.
228
+
229
+ Two functions evaluate the gates:
230
+
231
+ | Function | Behavior |
232
+ | -------- | -------- |
233
+ | `CheckTenantAccess(userId, tenantId)` | Resolves to the first denial, or `{ allowed: true }`. |
234
+ | `AssertTenantAccess(userId, tenantId)` | Calls `CheckTenantAccess` and throws a `403` whose body is the denying gate's `code`. |
235
+
236
+ ```typescript
237
+ import {
238
+ AssertTenantAccess,
239
+ CheckTenantAccess,
240
+ } from "@antelopejs/interface-dms/tenant-access";
241
+
242
+ const access = await CheckTenantAccess(user._id, tenantId);
243
+ if (!access.allowed) {
244
+ // access.code is a machine-readable key
245
+ }
246
+
247
+ await AssertTenantAccess(user._id, tenantId); // or let it throw
248
+ ```
249
+
250
+ `AuthTenantOwner` and `AuthTenantMember` call `AssertTenantAccess` unless the route opts out with `bypassTenantAccessGate` ([Permissions](/docs/interfaces/core/permissions)).
251
+
252
+ Use a gate to deny a tenant outright; use a permissions resolver to shape what its users may do.
253
+
254
+ ## Memberships and Ownership
255
+
256
+ `applyTenantOwnership(userModel, userId, tenantId, patch)` upserts a membership row for the user in the tenant.
257
+
258
+ ```typescript
259
+ import { GetModel } from "@antelopejs/interface-database-decorators";
260
+ import { applyTenantOwnership } from "@antelopejs/interface-dms/tenant-ownership";
261
+ import { UserModel } from "@antelopejs/interface-dms/auth/db";
262
+
263
+ await applyTenantOwnership(GetModel(UserModel), userId, tenantId, {
264
+ roleIds: ["role-editor"],
265
+ isTenantOwner: false,
266
+ });
267
+ ```
268
+
269
+ `TenantMembershipPatch` is the fourth argument:
270
+
271
+ | Field | Type | Description |
272
+ | ----- | ---- | ----------- |
273
+ | `roleIds` | `string[]` | Roles the member holds after the write. Replaces the previous list. |
274
+ | `isTenantOwner` | `boolean` | Ownership flag after the write. |
275
+ | `invitedBy` | `string \| null` | Recorded on insert. Ignored when the membership row already exists. |
276
+
277
+ When the user is not yet a member, `applyTenantOwnership` 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 ([Hooks](/docs/interfaces/core/hooks)).
278
+
279
+ Two helpers keep the legacy platform-owner flag on the user row aligned with tenant ownership:
280
+
281
+ | Function | Signature |
282
+ | -------- | --------- |
283
+ | `syncPlatformOwnerOnTenantOwnerChange` | `(userModel, userId, tenantId, nextIsTenantOwner: boolean) => Promise<void>` |
284
+ | `clearPlatformOwnerOnMemberRemoval` | `(userModel, userId, tenantId, removedMemberWasTenantOwner: boolean) => Promise<void>` |
285
+
286
+ Both mirror the value onto `User.owner`, and both are no-ops outside the default tenant and when the backend runs in [SaaS mode](#saas-mode) — where `User.owner` means platform ownership rather than tenant ownership. `clearPlatformOwnerOnMemberRemoval` additionally does nothing when the removed member was not a tenant owner. Call them after a write that changes ownership by a path other than `applyTenantOwnership`, which already mirrors on its own.
287
+
288
+ ### SaaS Mode
289
+
290
+ By default, the owners of the default tenant are the platform owners. In SaaS mode the two are independent: tenant owners are customers, and platform ownership is managed on its own. The SaaS module declares that mode itself with `RegisterSaasMode`; the DMS never infers it from the modules it finds loaded.
291
+
292
+ ```typescript
293
+ import { RegisterSaasMode } from "@antelopejs/interface-dms/utils/saas-mode";
294
+
295
+ export async function construct(): Promise<void> {
296
+ RegisterSaasMode();
297
+ }
298
+ ```
299
+
300
+ While SaaS mode is on:
301
+
302
+ - the default tenant's owner flag is no longer mirrored onto `User.owner`, by `applyTenantOwnership` or by the two helpers above;
303
+ - removing members from the default tenant no longer refuses to remove the last platform owner.
304
+
305
+ The registration is removed when the registering module unloads, and the mode stays on while at least one registration is active. `isSaasMode()` resolves to the current state, `false` until a module registers. Both behaviours read it on every request, so it does not matter whether the SaaS module registers before or after the DMS starts.
306
+
307
+ ## Tenant Lifecycle Admission
308
+
309
+ 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.
310
+
311
+ ### `runTenantLifecycleOperation`
312
+
313
+ `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.
314
+
315
+ 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.
316
+
317
+ ### `closeTenantLifecycleAdmission`
318
+
319
+ `closeTenantLifecycleAdmission(tenantId: string): Promise<void>` permanently closes admission, then checks that no admitted operation remains. It rejects if work is active or indeterminate, or if the database cannot confirm the transition. Rejection can leave admission closed: it never authorizes deletion or reopening.
320
+
321
+ Call it before any destructive tenant phase, including deletion hooks. Do not delete business data when closure rejects. Preserve the `tenant_lifecycle` and `invite_resolutions` safety rows after deletion; dropping the tenant schema or recreating those rows can reopen admission. Mixed-version writers and raw model writes bypassing admission prevent a safe rollout. These table names describe a preservation obligation, not supported raw-model APIs.
322
+
323
+ The [backend-services guide](/docs/building/backend-services#tenant-deletion-closes-producer-admission-first) explains where this fits in a module's deletion flow.
324
+
325
+ ## Built-in Data Controllers
326
+
327
+ The interface ships the two data controllers the built-in settings screens are built on. The DMS module registers both itself, so their routes exist as soon as it is loaded: reference them, do not register them again.
328
+
329
+ | Export | Import from | Table | Base path |
330
+ | ------ | ----------- | ----- | --------- |
331
+ | `memberSettingDataAPI` | `@antelopejs/interface-dms/data-controllers` | `TenantMember` | `/api/tables/members` |
332
+ | `roleSettingDataAPI` | `@antelopejs/interface-dms/data-controllers/roles` | `Role` | `/api/tables/roles` |
333
+
334
+ The `/data-controllers` barrel re-exports the members controller only; the roles controller is imported from its own path.
335
+
336
+ ```typescript
337
+ import { roleSettingDataAPI } from "@antelopejs/interface-dms/data-controllers/roles";
338
+ import { DefaultDataTypes } from "@antelopejs/interface-dms/base/data-types/default-types";
339
+
340
+ // a role picker on one of your own form fields
341
+ const reviewerRolesField = {
342
+ id: "reviewerRoleIds",
343
+ label: "Reviewer roles",
344
+ type: new DefaultDataTypes.RelationType({
345
+ multiple: true,
346
+ dataApiController: roleSettingDataAPI,
347
+ keyMapping: { label: "name", value: "_id" },
348
+ }),
349
+ };
350
+ ```
351
+
352
+ Both bind their model with `TenantScopedModel`, so they read and write the request's tenant. `memberSettingDataAPI` exposes the member's `name` and `email` joined from the `User` table, the member's roles as a plain relation onto `roleSettingDataAPI` (the DMS members page draws it with its own role picker, through the edit form's component slot), `isTenantOwner`, `joinedAt`, `isValidated` and a computed `connected` field; `roleSettingDataAPI` exposes `name`, `permissions`, `createdAt` and `updatedAt`. Reference either one as the relation target of your own columns when you need a member or role picker, or feed one to a `TableView` to render it.
353
+
354
+ ## Next Steps
355
+
356
+ [Invites](/docs/interfaces/core/invites) covers bringing a user into a tenant that has no membership row for them yet.
@@ -0,0 +1,239 @@
1
+ ---
2
+ title: "Invites"
3
+ description: "An invite brings a person into a tenant by email."
4
+ ---
5
+
6
+ # Invites
7
+
8
+ ## Overview
9
+
10
+ An invite brings a person into a tenant by email. `inviteUserToTenant` is the entry point: when the email already belongs to a user it grants them membership directly, and otherwise it creates a token-bearing invite row the person redeems later. The two outcomes are distinguished by the `kind` field of the result.
11
+
12
+ The functions live in `@antelopejs/interface-dms/invites`; the `UserInvite` table and its model live in `@antelopejs/interface-dms/db`.
13
+
14
+ ## Invite a User
15
+
16
+ ```typescript
17
+ import { inviteUserToTenant } from "@antelopejs/interface-dms/invites";
18
+
19
+ const result = await inviteUserToTenant({
20
+ tenantId,
21
+ email: "alice@example.com",
22
+ roleIds: ["role-editor"],
23
+ asTenantOwner: false,
24
+ sendEmail: true,
25
+ });
26
+
27
+ if (result.kind === "added") {
28
+ // an account with that email existed: membership granted, no token
29
+ return { userId: result.userId };
30
+ }
31
+
32
+ // no account yet: an invite row was created
33
+ return { inviteId: result.inviteId, token: result.token };
34
+ ```
35
+
36
+ ### `InviteUserToTenantOptions`
37
+
38
+ | Option | Type | Description |
39
+ | ------ | ---- | ----------- |
40
+ | `tenantId` | `string` | Tenant the person is invited into. Required. |
41
+ | `email` | `string` | Email to look up and, failing that, to invite. Required. |
42
+ | `firstname` | `string \| null` | Invitee's first name, stored on the invite row and used to address the email. Defaults to `null`. |
43
+ | `lastname` | `string \| null` | Invitee's last name. Defaults to `null`. |
44
+ | `language` | `string` | Language stored on the invite row. Defaults to `"en"`. |
45
+ | `roleIds` | `string[]` | Roles granted on join. Defaults to `[]`. |
46
+ | `asTenantOwner` | `boolean` | Grant tenant ownership on join. Defaults to `false`. |
47
+ | `sendEmail` | `boolean` | Dispatch the admin invite email after the row is created. Defaults to `false`. |
48
+ | `awaitEmailDelivery` | `boolean` | With `sendEmail`, wait for the email and report its outcome as `emailDelivery` instead of sending it in the background. Defaults to `false`. |
49
+ | `inviterName` | `string` | Name of the sender shown in the invitation email. |
50
+ | `invitedBy` | `string \| null` | Id of the inviting user, stored on the invitation. |
51
+ | `extensions` | `InviteExtensionPayloads` | Payloads keyed by registered invite extension key. |
52
+
53
+ ### `InviteUserToTenantResult`
54
+
55
+ ```typescript
56
+ type InviteEmailDelivery = "sent" | "failed";
57
+
58
+ type InviteUserToTenantResult =
59
+ | { kind: "added"; userId: string }
60
+ | InvitedUserResult;
61
+
62
+ interface InvitedUserResult {
63
+ kind: "invited";
64
+ inviteId: string;
65
+ token: string;
66
+ emailDelivery?: InviteEmailDelivery; // only with sendEmail + awaitEmailDelivery
67
+ }
68
+ ```
69
+
70
+ The `"added"` branch runs `applyTenantOwnership` with `roleIds` and `asTenantOwner` ([Tenancy](/docs/interfaces/core/tenancy)), so the member hooks fire and no token is generated. The `"invited"` branch creates the invite row through `createUserInviteToken`, then dispatches the email when `sendEmail` is set. By default that is fire-and-forget: the call does not wait on delivery and does not fail when delivery does.
71
+
72
+ Pass `awaitEmailDelivery: true` when someone is waiting on the answer and has to learn that the email did not leave. The call then waits for the email and sets `emailDelivery` to `"sent"` or `"failed"`. It still does not throw: the invitation is kept either way, so the caller can offer to resend it. The failure's details (the provider's error) go to the server log only; tell the user something generic. The DMS members **Invite** form and the pending invitations' **Resend** action both work this way.
73
+
74
+ ```typescript
75
+ const result = await inviteUserToTenant({
76
+ tenantId,
77
+ email: "alice@example.com",
78
+ sendEmail: true,
79
+ awaitEmailDelivery: true,
80
+ });
81
+
82
+ if (result.kind === "invited" && result.emailDelivery === "failed") {
83
+ // the invite exists, but its email did not leave: say so, offer a resend
84
+ }
85
+ ```
86
+
87
+ The function is deliberately side-effect-light: it does not throw on duplicates and performs no context-specific pre-checks. Decide in the caller what "already a member" should answer.
88
+
89
+ ## Create an Invite Token Directly
90
+
91
+ `createUserInviteToken` is the lower-level half — it always creates a row and never touches memberships or email.
92
+
93
+ ```typescript
94
+ import { createUserInviteToken } from "@antelopejs/interface-dms/invites";
95
+
96
+ const { inviteId, token } = await createUserInviteToken({
97
+ tenantId,
98
+ email: "alice@example.com",
99
+ language: "en",
100
+ roleIds: [],
101
+ asTenantOwner: false,
102
+ });
103
+ ```
104
+
105
+ `CreateUserInviteTokenOptions` requires `tenantId`, `email`, `language`, `roleIds` and `asTenantOwner`; `firstname` and `lastname` are optional and default to `null`. Optional `invitedBy` records the sender and `extensions` carries `InviteExtensionPayloads`. `replacementReason` selects `"replaced"` (default) or `"resent"` when retiring an existing invitation. `CreateUserInviteTokenResult` is `{ inviteId: string; token: string }`.
106
+
107
+ Optional `replacesInvite: UserInvite` identifies the exact incarnation being replaced, including a retained snapshot during a retry.
108
+
109
+ `inviteeDisplayName(firstname?, lastname?)` joins the two into the name the invite email addresses the person by, and returns `undefined` when the invite carries neither. `inviteUserToTenant` calls it for you; call it yourself when you render your own invite surface from a stored row, so the fallback for a nameless invite matches what the email does.
110
+
111
+ An existing invite for the same email in the same tenant is retired through a durable decision. Completion invokes `Hook.INVITE_DELETED` for the old invitation, then creates the replacement through `Hook.INVITE_BEING_CREATED` and `Hook.INVITE_CREATED`, and removes the old row. Failed completion can replay hooks with the same delivery identity; see [durable invite resolution](#durable-invite-resolution) and [Hooks](/docs/interfaces/core/hooks).
112
+
113
+ ## Token Constants
114
+
115
+ | Constant | Value | Meaning |
116
+ | -------- | ----- | ------- |
117
+ | `INVITE_TOKEN_LENGTH` | `64` | Length of the generated token. |
118
+ | `INVITE_EXPIRY_DAYS` | `7` | Days from creation to `expiresAt`. |
119
+
120
+ ```typescript
121
+ import {
122
+ INVITE_EXPIRY_DAYS,
123
+ INVITE_TOKEN_LENGTH,
124
+ } from "@antelopejs/interface-dms/invites";
125
+ ```
126
+
127
+ ## The `UserInvite` Table
128
+
129
+ Table `user_invites` in the `dms-tenant` schema, so invites are per-tenant like memberships. The table-name constant `USER_INVITES_TABLE_NAME` is exported next to it.
130
+
131
+ | Field | Type | Notes |
132
+ | ----- | ---- | ----- |
133
+ | `_id` | `string` | Invite id — the `inviteId` the create functions return. |
134
+ | `createdAt` | `Date` | Creation time, indexed. |
135
+ | `email` | `string` | Indexed. |
136
+ | `firstname` | `string \| null` | Invitee's first name, when the invite carried one. |
137
+ | `lastname` | `string \| null` | Invitee's last name, when the invite carried one. |
138
+ | `roles_ids` | `string[]` | Related to `Role`, many. Roles to grant on redemption. |
139
+ | `language` | `string` | Language the invite was issued in. |
140
+ | `token` | `string` | Indexed. The secret handed to the invitee. |
141
+ | `asTenantOwner` | `boolean` | Grant tenant ownership on redemption. |
142
+ | `expiresAt` | `Date` | Indexed. `INVITE_EXPIRY_DAYS` after creation. |
143
+
144
+ Note the field is `roles_ids`, not `roleIds` — the membership field of that name lives on `TenantMember` ([Tenancy](/docs/interfaces/core/tenancy)).
145
+
146
+ ## `UserInviteModel`
147
+
148
+ `UserInviteModel` extends `BasicDataModel(UserInvite, USER_INVITES_TABLE_NAME)`:
149
+
150
+ | Method | Returns |
151
+ | ------ | ------- |
152
+ | `getByEmail(email)` | `Promise<UserInvite \| undefined>` |
153
+ | `getByToken(token)` | `Promise<UserInvite \| undefined>` |
154
+ | `getByTokenWithTenant(token)` | `Promise<{ invite: UserInvite; tenantId: string } \| undefined>` |
155
+ | `deleteByToken(token)` | Deletes every row carrying the token |
156
+
157
+ Invitations are stored with their e-mail trimmed and lowercased, and `getByEmail` normalizes its argument the same way, so the casing a caller passes does not matter.
158
+
159
+ ```ts docs-check
160
+ import { GetModel } from "@antelopejs/interface-database-decorators";
161
+ import { UserInviteModel } from "@antelopejs/interface-dms/db";
162
+
163
+ export async function hasExpiredInvite(tenantId: string, email: string): Promise<boolean> {
164
+ const invite = await GetModel(UserInviteModel, tenantId).getByEmail(email);
165
+ return invite !== undefined && invite.expiresAt.getTime() < Date.now();
166
+ }
167
+ ```
168
+
169
+ `getByTokenWithTenant` reports the tenant the row came from, which is what a redemption flow needs: the person following an invite link is not authenticated yet, so there is no tenant claim to bind the handle to.
170
+
171
+ Raw deletion, including `deleteByToken`, does not coordinate durable decisions or extension cleanup. Use the invite-resolution contract below for terminal actions, not direct model writes.
172
+
173
+ ## `RegisterInviteExtension`
174
+
175
+ `RegisterInviteExtension<T>(options: InviteExtensionOptions<T>): () => void`, from `@antelopejs/interface-dms/invite-extensions`, contributes fields to the members invite form. Registration belongs to the module and is removed when it stops; the returned function removes it earlier.
176
+
177
+ | Option | Type | Contract |
178
+ | --- | --- | --- |
179
+ | `key` | `string` | Required non-empty namespace. Must not contain `__`, the field-id separator. |
180
+ | `component` | `FormBuilder` | Required form whose fields join the invite modal. |
181
+ | `schema` | `ZodType<T, ZodTypeDef, unknown>` | Required validator at submission and delivery. Uses unprefixed field ids; callbacks receive parsed output `T`. |
182
+ | `onAccept` | `InviteAcceptHandler<T>` | Required callback after membership exists. |
183
+ | `onCleanup` | `InviteCleanupHandler<T>` | Optional cleanup on cancellation, replacement, expiry or member removal, never acceptance. |
184
+ | `editable` | `boolean` | Defaults to `true`. `false` makes fields read-only on the pending-invitation edit form. |
185
+ | `onUpdate` | `InviteUpdateHandler<T>` | Called after a changed pending payload is stored. Failure is logged, not raised to the caller. |
186
+ | `label` | `string` | Heading, defaulting to the form's title. |
187
+ | `description` | `string` | Description of the contribution. |
188
+ | `placement` | `InviteExtensionPlacement` | Optional `side` (`"before"`, `"after"`, `"end"`), `anchorField`, and numeric `order`. Defaults to end, order 0. |
189
+
190
+ `anchorField` names a top-level field or group of the invite form. Without an anchor, `before` places the contribution first; `after` and `end` append it. Lower orders come first, with extension keys breaking ties. Form field ids are namespaced by `key`; stored `InviteExtensionPayloads` is `Record<string, unknown>`, keyed by extension.
191
+
192
+ ### Callback contracts
193
+
194
+ | Callback | Arguments | Return |
195
+ | --- | --- | --- |
196
+ | `InviteAcceptHandler<T>` | `(payload: T, member: TenantMember, context: InviteExtensionContext)` | `MaybePromise<void>` |
197
+ | `InviteUpdateHandler<T>` | `(payload: T, previous: T \| undefined, context: InviteExtensionContext)` | `MaybePromise<void>` |
198
+ | `InviteCleanupHandler<T>` | `(payload: T \| undefined, context: InviteCleanupContext)` | `MaybePromise<void>` |
199
+
200
+ `InviteExtensionContext` has `tenantId`, `email`, and optional `deliveryId` and `inviteId`. Direct addition of an existing account calls `onAccept` without an `inviteId`. `previous` is undefined when no old payload exists or it no longer validates.
201
+
202
+ `InviteCleanupContext` has `tenantId`, `reason: "invite-deleted" | "member-removed"`, and optional `deliveryId`, `inviteId`, `email`, and `userId`. Invite cleanup identifies the invitation; member cleanup identifies the user. Durable delivery may replay concurrently with the same `deliveryId`: deduplicate external effects and scope invite resource cleanup to `inviteId`. See [durable delivery guidance](/docs/building/backend-services#extension-and-hook-delivery) for operational obligations.
203
+
204
+ ## Durable Invite Resolution
205
+
206
+ The functions in `@antelopejs/interface-dms/invite-resolution` coordinate competing terminal actions. They do not authorize the caller. Prefer the higher-level invitation entry points unless implementing a terminal action yourself.
207
+
208
+ | Function | Signature and result |
209
+ | --- | --- |
210
+ | `loadInviteForAction` | `(tenantId: string, inviteId: string) => Promise<UserInvite \| undefined>` — loads the exact invitation or its durable snapshot after the live row is gone. |
211
+ | `assertInviteReady` | `(tenantId: string, invite: UserInvite) => Promise<void>` — rejects with HTTP 409 while a replacement's creation is incomplete. |
212
+ | `decideInvite` | `(input: ResolveInviteInput) => Promise<InviteResolution>` — records one permanent terminal outcome before effects. Conflicting reason, user or replacement content rejects with HTTP 409. A new acceptance of an expired invitation also rejects. |
213
+ | `completeInviteResolution` | `(resolution: InviteResolution) => Promise<void>` — enters tenant lifecycle admission and replays effects; callback failures retain the snapshot for retry. |
214
+
215
+ `ResolveInviteInput` contains `tenantId`, `invite: UserInvite`, `reason: InviteResolutionReason`, and optional `userId` and `replacement: UserInvite`. `InviteResolutionReason` is `"accepted" | "cancelled" | "replaced" | "resent" | "expired"`. The API captures extension keys itself; callers do not supply them.
216
+
217
+ `InviteResolution` (from `/db/tables/inviteResolutions.table`) is the stored decision and retry snapshot: `_id`, `tenantId`, `reason`, nullable `userId`, `invite`, optional `inviteToken`, nullable `replacement`, `extensionKeys`, `revision`, `membershipPhase`, `replacementPhase`, `completed`, and `decidedAt`. Both phase fields use `InviteMembershipPhase` (`"pending" | "applying" | "applied"`). Treat these as read-only results and pass the decision back to completion; do not mutate the model to advance phases.
218
+
219
+ Custom membership/invite producers must follow [tenant lifecycle admission](/docs/interfaces/core/tenancy#tenant-lifecycle-admission), and all competing terminal paths must decide before producing effects. A durable decision does not make external effects exactly-once; callback implementations still own idempotency.
220
+
221
+ ## Mark the Invite Action Unavailable
222
+
223
+ `RegisterInviteAvailability`, from `@antelopejs/interface-dms/invite-extensions`, lets a module disable the members page's **Invite** button while one of its conditions holds — a plan whose seats are all taken, say. The resolver runs per request while the page layout is built, with `{ tenantId, user }`; returning `{ reason }` disables the button and shows the reason next to it, `undefined` leaves it enabled. The first resolver to refuse wins, and one that throws is logged and ignored.
224
+
225
+ ```typescript
226
+ import { RegisterInviteAvailability } from "@antelopejs/interface-dms/invite-extensions";
227
+
228
+ RegisterInviteAvailability(async ({ tenantId }) =>
229
+ (await hasFreeSeat(tenantId))
230
+ ? undefined
231
+ : { reason: "$acme.seats.invite_unavailable" },
232
+ );
233
+ ```
234
+
235
+ It only shapes the UI. A module that must refuse the invitation itself does so from `Hook.INVITE_BEING_CREATED` and `Hook.MEMBER_BEING_ADDED`. The registration is lifted when the registering module stops; the returned function removes it earlier.
236
+
237
+ ## Next Steps
238
+
239
+ [Hooks](/docs/interfaces/core/hooks) covers the invite and membership events these functions fire, and how to react to them.