Skip to Content
TechnicalApplicationsTenant management console

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:

AudienceWhoSees
sustentusThe Sustentus teamEvery live tenant; can invite and revoke access
partnerA vendor partnerOnly 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.ts gates every route the app will ever have, not a chosen segment. A signed-in user with no marker reaches /no-access and 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. readViewer is wrapped in React cache, 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 own requireStaff(), 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

RouteAudienceWhat it does
/BothDashboard: tenant counts and recent activity
/activityBothThe activity trail, newest first, scoped to the viewer
/notificationsBothThe notification centre — the notifiable trail entries in scope, with read state
/notifications/deliverysustentusRecent email delivery failures, newest first
/tenantsBothSearchable, server-paginated tenant directory
/tenants/newBothCreate a tenant and invite its first administrator
/tenants/:idBothTenant detail, its users, its subscription, and — staff only — its partner and lifecycle controls
/tenants/deletedsustentusDeleted tenants, with the recovery window remaining on each
/tenants/deleted/:id/removesustentusThe typed-name confirmation for permanent removal
/accesssustentusInvite and revoke console access
/no-accessWhere 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 malformed

A 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/deleted list 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 functionbuildLifecycleUpdate 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_1 among them.
  • buildConsoleDeletedTenantFilter takes no ConsoleTenantScope argument 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 through buildConsoleTenantIdFilter({ 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 implementationreadConsoleRoster in apps/console/lib/console-roster.ts, shared with /access. Clerk’s user API cannot filter by publicMetadata, 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 fileRecorded as
tenants/new/actions.tstenant.created
tenants/[tenantId]/actions.tstenant.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.tstenant.recovered, tenant.removed
access/actions.tsconsole.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:

IndexServes
createdAtthe staff feed — every entry, newest first, across every tenant
tenantId_createdAta 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:

PredicateWhy
action in the notifiable setseven of the trail’s fifteen actions
the trail’s own audience scopeidentical to what /activity shows
actorClerkUserId not the viewernobody 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.removed entry 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:

CollectionShapeWritten by
console-notification-cursorone per person: clerkUserId, allReadBeforeMark all read
console-notification-readone per person per entry: clerkUserId, activityIdMark 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):

ActionDestination
tenant.removednone — 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.tsx is 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.

ActionTenant adminsPartnerStaff
tenant.created tenant.suspended tenant.reactivated tenant.deleted tenant.recoveredyesyesyes
tenant.removedyesyes
tenant.user_role_changedyesyes
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:

AudienceResolved from
Tenant adminsorg:admin memberships and pending org:admin invitations on the organisation
Partnerthe tenant’s assigned partnerId, or nobody when it has none
Staffthe 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.

ReasonMeans
not-configuredRESEND_API_KEY is unset in this environment
transport-errorthe provider rejected or threw
recipient-missinga resolved recipient carried no address
recipients-unresolvablethe 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 active lifecycle 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 /activity uses, 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/delivery lists 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
Last updated on