Tenant management console
The console is the Sustentus-side operational surface: the app the Sustentus team and its partners
use to administer tenants, separate from the tenant-facing platform in apps/web.
Overview
Package name: @sustentus/console
Framework: Next.js 16 (App Router, Turbopack)
Port: 3006
Path: apps/console/
Vercel project: tenant-management
Who it is for
The console has two audiences, and they are not the platform’s six roles. A platform role (vendor, customer, expert, CSM, SDM, admin) is scoped to a tenant and governed by the permissions registry. A console audience is scoped to Sustentus:
| Audience | Who | Sees |
|---|---|---|
sustentus | The Sustentus team | Every live tenant; can invite and revoke access |
partner | A vendor partner | Only the tenants assigned to them |
Access model
Console users are org-less Clerk users carrying a publicMetadata marker:
{ "consoleAccess": { "audience": "sustentus" } }parseConsoleAccess (in @sustentus/services/shared) is the one reader of that shape. Three
properties follow from it:
- Deny by default.
proxy.tsgates every route the app will ever have, not a chosen segment. A signed-in user with no marker reaches/no-accessand nothing else. - The marker is read live from Clerk on every request, not from a session claim — so clearing
it denies the very next request rather than waiting for a session to expire.
readVieweris wrapped in Reactcache, so it is one call per request however many components ask. - The proxy gates console access, not every staff-only capability. Its staff-matched routes
are
/access(.*)and/tenants/deleted(.*); the rest are open to both audiences, because partners legitimately read the tenants assigned to them. A staff-only action on such a route carries its ownrequireStaff(), and that call is the whole boundary — server actions are dispatched by action id and can be posted from any path, so route matching never constrains them.
The only routes reachable without a marker are /sign-in and /accept. /accept redeems a Clerk
invitation ticket and is the app’s sole sign-up surface; it refuses anyone without a ticket.
Routes
| Route | Audience | What it does |
|---|---|---|
/ | Both | Dashboard: tenant counts and recent activity |
/activity | Both | The activity trail, newest first, scoped to the viewer |
/notifications | Both | The notification centre — the notifiable trail entries in scope, with read state |
/notifications/delivery | sustentus | Recent email delivery failures, newest first |
/tenants | Both | Searchable, server-paginated tenant directory |
/tenants/new | Both | Create a tenant and invite its first administrator |
/tenants/:id | Both | Tenant detail, its users, its subscription, and — staff only — its partner and lifecycle controls |
/tenants/deleted | sustentus | Deleted tenants, with the recovery window remaining on each |
/tenants/deleted/:id/remove | sustentus | The typed-name confirmation for permanent removal |
/access | sustentus | Invite and revoke console access |
/no-access | — | Where a signed-in user with no marker lands |
Tenant directory
/tenants lists live tenants by name, 25 to a page, with a case-insensitive substring search.
/tenants/:id shows the tenant’s identity fields, derived status and setup state, and its user
list read from Clerk organization memberships (capped at 100 — the page says so when the cap is
hit, rather than reading as complete). That list is operable: see
Tenant user management below.
Scoping is applied inside the query, never after the read. The rules are pure functions in
packages/services/src/db/services/tenant/console-scope.ts:
buildConsoleTenantListFilter(scope, search?) // → the list filter for this audience
buildConsoleTenantIdFilter(scope, tenantId) // → the detail filter, or null if malformedA partner’s filter carries their partnerId; a staff filter carries no partner clause; both
exclude soft-deleted tenants. Because the bound is in the filter, a tenant that is not assigned to
the requesting partner, a tenant id that does not exist, and a malformed id are indistinguishable
to the caller — all three are the same not-found. Nothing on a partner’s directory reveals that
other tenants exist: no global count, no pagination past their own set.
partnerId is declared on the Tenant schema and indexed by migration, and written from the tenant
detail view — see Partner assignment below.
Tenant lifecycle
A tenant is created, suspended, ended and — eventually — erased from this app. The states live in
an optional lifecycle subdocument on the Tenant model:
{ status: "active" | "suspended" | "deleted", changedAt, changedBy, deletedAt? }Absence reads as active. Every tenant that predates the feature has no lifecycle field and is
active by that fact, so the migration backfills a stamp for the record rather than to make the
reads work.
Who holds what
Creating is the one lifecycle action both audiences hold. /tenants/new takes a name and a
first-administrator email, creates the Clerk organisation, then the Tenant document, then sends the
invitation — Clerk first, so a failure part-way leaves no Tenant document whose organisation does
not exist. A partner’s tenant is assigned to them in the same flow, through the same
tenantService.setPartner staff assignment uses; without that it would be a tenant they cannot see.
Suspend, delete, recover and permanent removal are requireStaff(), without exception — a
partner administers their clients, they do not decide whether a client still has a workspace. The
route gate above covers /tenants/deleted(.*), but /tenants/:id is deliberately open to both
audiences, so the suspend and delete actions carry their own check and that check is the boundary.
What each state means
- Suspended — the tenant stays in the directory for both audiences, badged. Its people cannot
reach the platform (see Enforcement in
apps/web). Reactivating is one click and restores nothing, because nothing was removed. - Deleted — soft. The tenant leaves both directories, its people are turned away, and it appears
on the staff-only
/tenants/deletedlist with the days left in a 30-day recovery window. Nothing has been destroyed: recovery returns it to active with its organisation, members and data intact. Past 30 days the page offers no Recover control, but the tenant is still there — nothing removes a tenant unattended. - Permanently removed — irreversible. The Clerk organisation is deleted, every tenant-scoped collection is emptied of that tenant’s rows — the activity trail among them — and the Tenant document is dropped. One trail entry survives, tenant-less: see Immutability below.
lifecycle.status: "deleted" and the schema’s isDeleted flag are written and cleared together,
by one function — buildLifecycleUpdate in
packages/services/src/db/services/tenant/lifecycle.ts. They cannot disagree because nothing else
writes either of them, and a unit test pins that.
Reading a deleted tenant
softDeletePlugin hooks pre(/^find/) and ANDs a live-only clause onto every model query, so a
deleted tenant is invisible to Tenant.find — which is the plugin working, not a bug to route
around. The deleted list, the recovery read and the removal therefore go through the raw driver,
under the plugin. Two consequences worth knowing:
- Those reads pass the schema’s
{ locale: "en", strength: 2 }collation explicitly. The driver handle knows nothing of the schema, and a string comparison issued under the collection’s default collation cannot use a collated index —clerkOrgId_1among them. buildConsoleDeletedTenantFiltertakes noConsoleTenantScopeargument at all. There is no partner shape to construct, so a partner-visible deleted read is not something a call site can build wrong.
Permanent removal
The confirmation is the tenant’s name, typed exactly, and it is checked server-side against the record — not against a hidden field, which would let anyone posting the action supply both halves of their own check.
The order is the Clerk organisation first, then the data and the Tenant document together. Identity
leads because it is the half that can fight back: while the organisation lives, a member signing in
still carries its orgId and would have upsertFromClerk write a fresh Tenant document over the
purge in progress. The cost is that an interruption leaves a Tenant document with no organisation —
which recovery detects and refuses, rather than handing back a workspace nobody can sign in to.
Erasure is policy-driven, not a hand-kept list. tenant-purge was extracted from the demo-reset
service so that both empty every tenant-scoped collection from the same lists the existing drift
test already guards. A collection added to the platform is covered by both, or by neither.
upsertFromClerk refuses a deleted tenant rather than resurrecting one. The plugin hides the
row from that method’s own findOneAndUpdate, so without the guard the first sign-in by one of its
users would silently insert a second Tenant document for the same clerkOrgId.
Enforcement in apps/web
The platform’s proxy redirects any authenticated request whose tenant is suspended or deleted to a
public /workspace-unavailable page. It runs before the route policy and while an admin is
emulating, because the question is whether the workspace may be used at all — not which of its pages
this role may see. The page names no cause: a suspension is a commercial or administrative matter
between Sustentus and the account, and a customer-facing page is not where that gets explained.
The gate fails open on a read error, logged. It is the one read here that cannot be narrowed to a role or a path, so letting it throw would turn a database blip into an error on every page of the platform at once — to defend a state that is administrative rather than a security boundary, and with the database down there is nothing behind the gate to reach anyway.
Partner assignment
/tenants/:id carries an Assigned partner panel, rendered and operable for sustentus only.
Staff set, change, or clear the tenant’s one partner from it — the write that makes a partner’s
scoped directory non-empty.
- The assignment is a field, not a membership: the optional
partnerId, holding the assigned partner’s Clerk user id. One partner per tenant is the field’s cardinality, not a rule that has to be policed. Clearing$unsets it, so an unassigned tenant reads the same whether it was never assigned or was cleared. - One shared write.
tenantService.setPartner(tenantId, partnerId | null)resolves the tenant throughbuildConsoleTenantIdFilter({ audience: "sustentus" }, tenantId), inheriting the live-only guard and the malformed-id answer. It performs no authorization of its own — deliberately, so that a partner-created tenant can later be auto-assigned to its creator through the same function. Authorization belongs to the caller. - The partner is chosen two ways: a select of known partners, and an assign-by-email fallback
for someone granted access directly onto an existing account, who leaves no invitation behind
and so never appears in the roster. Both resolve to a Clerk user whose marker is re-read live
and must still say
audience: "partner"before anything is written. - The roster has one implementation —
readConsoleRosterinapps/console/lib/console-roster.ts, shared with/access. Clerk’s user API cannot filter bypublicMetadata, so there is no “list every partner” query; both surfaces derive the same answer from the console’s invitation list.
Tenant user management
From /tenants/:id, both audiences can invite a user, revoke a pending invitation, remove a member,
and change a member’s role — Sustentus staff on any tenant, a partner on the tenants assigned to
them. The server actions live in apps/console/app/(console)/tenants/[tenantId]/actions.ts.
Every operation writes to the tenant’s Clerk organisation, which is authoritative for who
belongs and in which role — the same record the roster reads. The platform’s existing
organizationMembership.created|updated|deleted webhook mirrors each change onto the Mongo user
collection; the console adds no sync path and writes no user document itself. A change made here
is what the tenant’s own platform sees on its next request.
The assignable roles are the platform’s. CONSOLE_ASSIGNABLE_ROLES in
apps/console/lib/console-roles.ts derives from USER_ROLES in @sustentus/services/shared minus
sales, which is a Mongo-only system role and is never offered here. Roles are written in Clerk’s
org:<role> form. Granting or revoking tenant administration is assigning or unassigning admin;
there is no separate flag or claim.
Pending invitations are listed separately — email address, role and invited date, each revocable — because a Clerk organisation invite is pending until accepted and would otherwise be fire-and-forget.
The last-admin invariant. No action of this app leaves a tenant with no admin-role member:
the last admin can be neither removed nor moved off admin. The count comes from a role-filtered
Clerk membership query rather than the 100-row roster page, and the guard runs before any write, so
a refused action leaves Clerk and the Mongo mirror both intact. It bounds this app’s actions only —
the platform’s own in-tenant user management is the second door and carries its own guard. The
check is not transactional: two operators removing two different admins from a two-admin tenant at
the same moment can both pass it, and Clerk offers no lock to close that window.
Scoping and authorisation are unchanged from the directory. Every action calls requireViewer()
and then re-resolves the tenant through tenantService.findForConsole(scopeOf(viewer), tenantId),
so a partner acting on a tenant that is not theirs gets the same not-found as for one that does not
exist. Outcomes travel back as fixed codes in the query string, never as echoed input. Removal
removes the organisation membership only — the person keeps their Clerk account and every other
tenant they belong to.
Reverting the code does not revert the writes. There is no migration, so the change is clean to roll back, but invitations already sent stay live, removed memberships stay removed and changed roles stay changed. Undoing those means the Clerk dashboard.
Plan and subscription
/tenants/:id carries a subscription card for both audiences: the tenant’s plan name, the seats it
is using against the seats it is allowed, and its subscription status. It is read-only — there is
no control to move a tenant between plans (see the limitation at the end of this section).
Clerk Billing is the system of record, read live on every request through
clerkClient().billing.getOrganizationBillingSubscription() in
apps/console/lib/console-billing.ts. Nothing about a subscription is mirrored into Mongo, and
nothing is cached: a commercial state is exactly the sort of fact that is wrong the moment it is
stored.
Seats come from the organisation, not the subscription. Clerk Billing carries no seat quantity,
so “used” is the totalCount of a one-row membership read and “allowed” is the organisation’s
maxAllowedMemberships. Counting the member page the view renders would cap every large tenant at
that page size and show it comfortably inside an allowance it had blown through. Clerk writes “no
cap” as 0, which the card states in words rather than printing a zero that means the opposite.
No money reaches the card, for either audience. TenantSubscription carries a plan name and a
status and nothing else; fee, annualFee, amount, nextPayment and lifetimePaid are in
scope only inside console-billing.ts and are never mapped out of it. The partner money boundary
therefore holds by construction rather than by a per-audience branch in the view — there is no
amount in the shape to leak.
Three answers, not two. A read returns subscribed, none (Clerk’s 404 — this tenant has no
subscription) or unavailable (Clerk’s 403 — this instance may not read Billing). Collapsing the
last two would report a paying tenant as unsubscribed the day a billing permission is revoked, so
isClerkForbidden in apps/console/lib/clerk-errors.ts is deliberately kept apart from
isClerkNotFound. Any other Clerk failure is a genuine fault and throws.
Billing is not switched on for this Clerk instance. It is a public beta, and until it is
enabled every tenant reads Unavailable for plan and status. Seats are live and correct regardless,
because they come from the organisation rather than from Billing.
Moving a tenant between plans is not possible here, and not merely unbuilt. Clerk’s backend
Billing API exposes no plan-change write — its only writes are cancelSubscriptionItem and
extendSubscriptionItemFreeTrial. A plan switch happens in the payer’s own checkout, which console
users cannot reach: they are org-less by design and never enter a tenant organisation. Offering the
move needs a writable subscription store, which is its own piece of work.
Activity trail
Every administrative action this app performs is recorded, once, in an append-only trail:
ConsoleActivity in packages/services/src/db/models/console-activity.ts, written and read through
consoleActivityService from @sustentus/services/server.
An entry carries the actor’s Clerk user id, their name and email, the audience they acted as, the
action, the subject tenant and its name, a one-line detail, and the time. Names are snapshotted at
write time, not joined. Console people are org-less Clerk users with no row in the platform user
collection, so there is nothing to populate from, and resolving each actor against Clerk would be one
API call per rendered row. Snapshotting also keeps an entry readable after the person’s access has
been revoked — which is exactly when someone comes looking at it.
What is recorded
The fifteen server actions this app exports, and nothing else:
| Action file | Recorded as |
|---|---|
tenants/new/actions.ts | tenant.created |
tenants/[tenantId]/actions.ts | tenant.partner_assigned, tenant.partner_cleared, tenant.user_invited, tenant.invitation_revoked, tenant.user_removed, tenant.user_role_changed, tenant.suspended, tenant.reactivated, tenant.deleted |
tenants/deleted/actions.ts | tenant.recovered, tenant.removed |
access/actions.ts | console.access_granted, console.invitation_revoked, console.access_revoked |
Recording happens after the action’s own write succeeds, so a refused or invalid action leaves no trace and a completed one leaves exactly one entry. The write is awaited and its failure surfaces: the primary write has already committed by then, so swallowing the error would mean “done and not recorded”, with nobody told.
The three access/actions.ts entries carry no subject tenant — granting someone console access
is an act against the console, not against a tenant. That makes them staff-only by construction: a
partner’s read is bounded by their assigned tenant ids, and no tenant matches none.
Who sees what
/activity lists entries newest first on the directory’s own URL contract (page, 1-based; junk
reads as page 1). Staff see every entry, the tenant-less ones included. A partner sees only entries
whose tenant is currently assigned to them, resolved per request from the same assignment the
directory reads — so a re-assigned tenant takes its history out of the old partner’s view and into
the new partner’s. Denormalising partnerId onto the entry would freeze it at write time and
disagree with the directory the moment an assignment moved.
The bound is applied inside consoleActivityService.listForConsole, not by the shell’s nav, so a
partner requesting /activity directly gets the same scoped answer as one who followed the link.
Staff reads declare crossTenant: true — the tenant plugin’s documented escape hatch is how a
deliberate cross-tenant read announces itself. A partner’s { tenantId: { $in: [...] } } filter
needs no hatch, because the plugin can see it.
That scoping is not restated anywhere. consoleActivityScopeFilter and consoleActivityReadModel
are exported from the trail’s service and reused by the
Notification centre, which reads these same rows through these same bounds.
Immutability
Nothing edits or deletes an entry. The service exports a writer and two readers and no mutator, no
route or action offers one, and the model carries no softDeletePlugin — an audit is never edited
or removed (the view-as-audit precedent). A unit test asserts the exported surface holds no update
or delete method.
The one thing that removes entries is permanent removal, which empties every tenant-scoped
collection and the trail is one of them: afterwards nothing tenant-scoped about that tenant remains —
its creation, its suspensions, its user changes, all gone with it. One entry survives. The
tenant.removed entry is written tenant-less, snapshotting the removed tenant’s name, so the
console still records that Sustentus removed a workspace and who did it; being tenant-less it is
staff-only, which is the right audience for a tenant that no longer exists.
Indexes
autoIndex is off on this schema, so both indexes come from migrations rather than from model
registration:
| Index | Serves |
|---|---|
createdAt | the staff feed — every entry, newest first, across every tenant |
tenantId_createdAt | a partner’s $in feed, and any future per-tenant read |
createdAt deliberately does not lead with tenantId, unlike the rest of this database: the staff
read of this collection has no tenant clause to lead with, and a single-field createdAt is not a
prefix of the compound one. Both are created with the schema’s { locale: "en", strength: 2 }
collation, named explicitly — an index created without a collation inherits the collection’s
default, and autoCreate (a separate switch, still on) stamps the schema’s collation onto the
collection, so leaving it unnamed would make the result depend on whether the migration or the app’s
first model compile got there first.
Notification centre
The trail records; the centre tells. /activity and the dashboard feed are both places somebody
has to go — a partner learns a tenant of theirs was suspended by opening the console and looking.
The notification centre is the same information arriving instead: a bell in the shell header
carrying an unread count, a popover previewing the newest unread, and /notifications as the full
list.
A projection of the trail, not a second record
There is no notification collection and no fan-out write. A notification is a
console-activity entry, seen by somebody who is in scope for it. The centre —
consoleNotificationService in
packages/services/src/db/services/console-notification/ — reads that one collection with three
extra predicates over the trail’s own scoped filter, and lays per-person read state on top:
| Predicate | Why |
|---|---|
action in the notifiable set | seven of the trail’s fifteen actions |
| the trail’s own audience scope | identical to what /activity shows |
actorClerkUserId not the viewer | nobody is told of their own action |
Three properties follow from that and are not maintained by anything:
- The two views cannot disagree. Nothing writes a notification, so nothing can fail to write one, and no reconciliation job is needed to keep a second collection in step with the first.
- Removal is inherited. Permanently removing a tenant empties its trail entries, so its
notifications go with them. The one tenant-less
tenant.removedentry the trail keeps back survives as a staff notification naming the removed workspace — same row, same rule. - Marking read touches the trail not at all. Read state is a separate per-person collection, so the audit stays append-only and the same entry can be read by one person and unread by another.
Which entries are notifiable
Seven of the fifteen actions — the ones about a workspace existing, its lifecycle moving, or somebody’s power over it changing:
tenant.created · tenant.suspended · tenant.reactivated · tenant.deleted ·
tenant.recovered · tenant.removed · tenant.user_role_changed
The set is CONSOLE_NOTIFIABLE_ACTIONS, declared as a total Record over
ConsoleActivityAction in the shape CONSOLE_ACTIVITY_LABELS already uses. A sixteenth trail
action therefore stops the file compiling until somebody decides whether it is notifiable — the
eight that are not (partner assignment and clearing, the three user-management actions, and the
three console.* access actions) are false there rather than absent.
There is no plan-change notification because there is no plan-change action to project — see
Plan and subscription above. When plan movement becomes possible it is
one more trail action and one more true in that map.
Who is told
Scope is the trail’s scope, resolved live per request, and it is the trail’s own code rather
than a copy of it: consoleActivityScopeFilter and consoleActivityReadModel are exported from
db/services/console-activity/ and used by the centre. A partner’s
{ tenantId: { $in: [...] } } bound is the whole of partner authorization on this collection, so
a second copy would be a second place for it to widen.
Both of those functions match scope.audience === "sustentus" positively rather than testing for
!== "partner". The union has two members, so the forms are equivalent today — but only the staff
audience can then reach the unbounded filter and the crossTenant escape hatch, and a third
audience added later is bounded by default instead of inheriting staff’s view.
actorClerkUserId: { $ne: <viewer> } is applied in the query, not as a filter over the
results, so the list, the unread count and the page count all agree about which rows exist.
Read state
Two collections in packages/services/src/db/models/console-notification.ts, neither carrying a
tenantId — so neither takes tenantPlugin, neither needs a demo-reset policy classification
(policy.test.ts classifies schemas that carry tenantId), and neither is reached by
tenant-purge:
| Collection | Shape | Written by |
|---|---|---|
console-notification-cursor | one per person: clerkUserId, allReadBefore | Mark all read |
console-notification-read | one per person per entry: clerkUserId, activityId | Mark read |
A notification is read when its entry predates that person’s cursor or its id has a read row.
Mark all read is O(1) — stamp the cursor, then delete that person’s read rows, all of which name
entries the new watermark now subsumes. The read rows are bounded because markRead performs the
same compaction the moment nothing is left unread: without that, somebody who only ever marked rows
individually would never reach the operation that prunes them.
Both writes are upserts against a unique index, which is not atomic — two submissions of the
same mark race, and the loser’s insert raises a duplicate key. Both catch it through the house
isDuplicateKeyError, because the operation had in fact succeeded.
markRead re-resolves the entry through the same scoped, notifiable, not-your-own read before
writing anything, so a read row can only ever exist for a notification that was genuinely the
caller’s. Server actions are dispatched by action id and can be posted from any path; that
re-resolution is the boundary, not the page that renders the button.
Indexes come from a migration with autoIndex off, following the console-activity precedent, and
name the schema’s { locale: "en", strength: 2 } collation explicitly for the same reason the trail’s
do. No index was added to console-activity: staff sort on createdAt and a partner matches
tenantId then sorts, which the trail’s two existing indexes already serve; the notifiable action,
the actor exclusion and the read-row exclusion are residual filters over a set those have already
bounded, at a volume of a handful of administrative actions a day.
Where a notification goes
Derived from the action alone, with no extra lookup per row (notificationHref in
apps/console/lib/console-notification-links.ts):
| Action | Destination |
|---|---|
tenant.removed | none — the entry carries no tenant and the tenant is gone |
tenant.deleted | /tenants/deleted for staff; unlinked for a partner, who has no deleted-tenant surface |
| everything else | /tenants/:id, itself scoped, so a link a reader should not be able to follow behaves exactly as a typed URL does |
That file imports only a type, so it holds no server dependency and is unit-tested directly.
What the centre does not do
- It publishes nothing realtime. No Ably message —
packages/services/src/notifications/is the tenant platform’s per-resource notify path and is not involved here. The centre itself is an in-app projection for people signed into the console. The same events are now delivered by email, by a separate path that reads this same set — see Email notification below. - Marking read never removes a notification. The row stays, shown as read, because the trail entry behind it is the audit.
- Opening one does not mark it. Reading is an explicit act.
- Nothing is realtime. The count and the preview are read in the shell’s server component when
the page renders. Both mark actions
revalidatePath("/", "layout"), so the count is right after either — but App Router does not re-render a shared layout on client-side navigation between the routes under it, so a count is otherwise as of the last full load. Moving the header into a(console)/template.tsxis what would change that. - The mark-all-read watermark is global, while scope is resolved live. A tenant assigned to a partner after they last marked all read arrives with its history already behind their cursor, so those entries are in their centre — the list is correct — but never raise the badge. Fixing that means a per-scope watermark.
Email notification
The centre tells whoever signs in. Email tells the people who will not — a tenant’s own administrators, who have no console account at all, and an operator who is not looking today.
The event set is the centre’s, not a second list. CONSOLE_EMAIL_MATRIX
(packages/services/src/notifications/console/audience.ts) is a total Record over
ConsoleActivityAction saying which of three audiences each action reaches, and a unit test
asserts both directions against CONSOLE_NOTIFIABLE_ACTIONS: every notifiable action emails
somebody, and nothing the centre stays silent about sends mail. The two channels cannot drift.
| Action | Tenant admins | Partner | Staff |
|---|---|---|---|
tenant.created tenant.suspended tenant.reactivated tenant.deleted tenant.recovered | yes | yes | yes |
tenant.removed | yes | — | yes |
tenant.user_role_changed | — | yes | yes |
| the eight non-notifiable actions | — | — | — |
tenant.removed reaches no partner because the entry it projects carries no tenant — the same
reason it is absent from a partner’s centre. Its tenant-admin recipients are read before the
Clerk organisation is deleted and handed to the send, because afterwards there is no membership
left to read.
Two vocabularies
A tenant administrator is not a console user, and their mail says so: no console link, no console
words, no actor name — they are told what happened to their workspace, with
support@sustentus.com as the contact and the Reply-To. Partner and staff mail is operator-voice
and links back to the tenant at the same destination notificationHref gives the centre’s row, so
an email and a notification about one event land on the same page. With CONSOLE_APP_URL unset the
operator mail sends linkless rather than carrying a link to nowhere.
Nobody is told about their own action — matched on the actor’s Clerk id or their address,
because a recipient can reach the fan-out without an id (SYSTEM_ADMIN_EMAIL is a bare address, and
so is a pending invitation).
Recipients
Resolved in apps/console/lib/console-event-email.ts, because every one of them is a Clerk read —
which is this app’s dependency, not the services package’s:
| Audience | Resolved from |
|---|---|
| Tenant admins | org:admin memberships and pending org:admin invitations on the organisation |
| Partner | the tenant’s assigned partnerId, or nobody when it has none |
| Staff | the console invitation roster, plus SYSTEM_ADMIN_EMAIL when it is set |
The invitations half is not a nicety: a new tenant’s organisation is created empty — console people
are org-less, so there is no createdBy to make a first member — and its first administrator exists
only as an invitation. Reading memberships alone would find nobody at precisely the moment the
“your workspace is ready” mail is meant to send.
The roster half is likewise: it is derived from invitations this console created, so it cannot see somebody granted access directly on an existing account — the bootstrapped admin especially. The env address closes that without a second mechanism, and the fan-out deduplicates by address, so naming somebody who is also on the roster costs nothing.
Failure is recorded, never raised
The send runs in after(), after both the administrative write and the trail entry have committed,
and nothing in it can affect the action that caused it: every path resolves, the after() call
is itself wrapped, and a total failure is a set of rows rather than an error an operator sees. A
suspension that succeeded stays succeeded, and the fact that nobody was told is written down instead
of lost.
console-email-delivery-log is that record — append-only, failures only, bounded by a 30-day
TTL index created by migration with autoIndex off. It is its own collection rather than a row in
the platform’s notification-delivery-log, which requires a tenantId the tenant-less
tenant.removed cannot supply and whose recipients are platform user rows console people do not
have.
| Reason | Means |
|---|---|
not-configured | RESEND_API_KEY is unset in this environment |
transport-error | the provider rejected or threw |
recipient-missing | a resolved recipient carried no address |
recipients-unresolvable | the Clerk read for that audience failed |
Deliberate non-delivery is never a failure. A tenant with no partner, or an audience the actor was filtered out of, produces no row — an empty audience is a fact, an unreadable one is a fault, and only the second is logged.
Staff read the failures at /notifications/delivery — gated twice, by the proxy’s staff matcher
and by requireStaff() on the page — reachable from a staff-only nav entry so it can be opened to
confirm health, not only to diagnose breakage. The dashboard reports the trailing 24 hours in both
states — an alert when sends have failed, distinguishing a missing provider key from per-send
faults, and an all-clear when none have. Both reads declare
crossTenant: true: the question is whether console mail is getting out at all, which is about the
estate, and the tenant-less removal rows no tenant filter could reach.
Home dashboard
/ is the console’s landing surface, scoped to the viewer’s audience:
- Total tenants — every live tenant in scope: active + suspended, exactly what the directory lists.
- Active tenants — those in the
activelifecycle state, so the gap between the two numbers is the suspended ones. Deleted tenants are in neither count, and recovering one returns it to both. - Recent activity — the newest entries from the same scoped read
/activityuses, rendered by the same component, so the two surfaces cannot describe an entry differently. - Email delivery — staff only, in both states: the alert when the trailing 24 hours contain a
failed send, and an all-clear when they do not. The all-clear reports the absence of failures
and deliberately does not claim mail is getting through: successful sends are never logged, and
both counts in
failureSummary()come from failure rows, so an environment with no provider key and no administrative actions in the window produces the same zero as a healthy one. It names its own 24-hour window because/notifications/deliverylists 30 days. The summary read is skipped entirely for a partner rather than merely hidden, so a partner never causes an estate-wide count.
The counts are countDocuments over the console-scope filter builders, extended with a lifecycle
selector. Those filters carry the soft-delete clause explicitly: softDeletePlugin hooks
pre(/^find/), which countDocuments does not match, so a count leaning on the plugin would count
deleted tenants.
There is no platform-health or uptime figure — that needs a real definition before it is worth printing one.
Data
The console reads MongoDB through @sustentus/services/server, so it needs MONGODB_URI and
MONGODB_DATABASE_NAME in every Vercel scope it is deployed to. Outbound email needs
RESEND_API_KEY and RESEND_EMAIL_FROM (a verified sender), CONSOLE_APP_URL for the operator
mail’s link, and optionally SYSTEM_ADMIN_EMAIL. These belong to the tenant-management Vercel
project specifically — being set on web does nothing for the console. With the key unset the
console still works: every intended recipient is recorded not-configured and the dashboard says
so. MONGODB_URI is required and
throws when absent; MONGODB_DATABASE_NAME is optional to the driver and silently falls back to
the database named in the URI path, so it should be set explicitly rather than inferred.
Running it
pnpm --filter @sustentus/console dev # port 3006