Invitation-Only Onboarding
By default the Memex portal is open — anyone who authenticates may self-provision an account
through /onboarding (see Memex Cloud Deployment → Onboarding).
Invitation-only mode narrows this: an admin invites an email address, the system emails that
person from the M365 mailbox the portal sends and receives as, and only an invited email may complete
onboarding. Every other email is refused at the gate — except on an instance whose own Entra directory
demonstrably gates sign-in, which is exempt (see
Exempt where the instance's own directory gates sign-in).
Turn it on with one flag (see Feature Flags):
Features__Onboarding__InvitationOnly=true
Acceptance model — verified-email allowlist. The address onboarding uses is always a VERIFIED one, and it is never typed: either the identity provider (Entra, Google, …) delivered it as an email claim, or the invitation email's signed link proved it (see The invitation link token). A Pending invitation matching that verified email unlocks onboarding, after which the invitation flips to Accepted. The first-user bootstrap exception still applies — a brand-new deployment with zero User nodes always lets the very first user in, so it can never lock itself out. The one other way in is the own-directory exemption: where Microsoft is the only sign-in route, the tenant names one directory and Microsoft Graph confirms the instance's Entra application requires user assignment, the directory's assignment list is the allowlist and no invitation is needed.
End-to-end flow
- An admin opens Admin app (
/Admin) → Invitations, enters an email, clicks Invite. InvitationService.CreateInvitationwrites aPendingInvitationnode. It does not send the email.InvitationEmailSender— a hosted service — watches Pending invitations whoseEmailSentAtis null, sends throughIEmailSender, and stampsEmailSentAtso it never re-sends. Decoupling it from the creation entry point means an invitation created from the settings tab, from MCP (create), or from a REST call is emailed exactly once.- The invitee follows the email's link (
/auth/invite?token=…), signs in via any configured IdP, and lands on the invited Space.OnboardingMiddlewarefinds no User node for the email and redirects to/onboarding, where the address is shown read-only — there is no field to type into. - The onboarding gate looks up a Pending invitation for the verified email. If found, the profile
form is shown; on submit the user is created and the invitation flips to Accepted. If not
found, the page shows "Invitation Required" and
CreateUseris refused — unless the own-directory exemption holds for this sign-in (below), in which case the form is shown and the user is created without an invitation.
One node per email, many invitations over its life
The node is keyed by the email (it is the onboarding allowlist), so every Space invite to a
not-yet-registered address — SpaceInviteService.GrantOrScheduleAccess, the Space invite and the
Access Control "grant by email" alike — overwrites it with a new Invitation: a fresh Id, its
own SpacePath and Note, EmailSentAt cleared. The deferred grants do not share it: each
(email, node) pair has its own EventSubscription, so every scheduled grant lands on sign-up.
InvitationEmailSender therefore treats the invitation's Id, not the node path, as the unit it
emails (MeshWeaver#7481). Two rules follow, and breaking either loses an email silently:
- The claim is a fold on the node's CURRENT content, through
GetMeshNodeStream(path).Update, and stampsEmailSentAtonly when the current invitation is the one it was offered. It used to write the whole node back from the query snapshot it was offered; the query lags the store, so a claim for the first of two back-to-back invites restored the first Space over the second, and the second was never announced. That is the CQRS rule — never decide or write content from a query. - The in-process de-dup is keyed per invitation (
{path}#{Id}), never per path. Keyed by path, the first email for an address suppressed every later invitation to it until the process restarted.
An offer the node has already moved past (a newer invite overwrote it before the claim ran) is still emailed, unstamped: it was a real invite and its Space is announced nowhere else. Back-to-back invites to two Spaces therefore produce two emails, one per Space.
The Space Invite form: what a click shows, and Cancel
The Space Invite form ({Space}/Invite, the node menu's Invite people,
SpaceInviteLayoutArea) ends with ONE button row, its last element: Cancel, then Invite.
Cancel only navigates back to the node the form was opened on ({node}/Overview, the platform's
form-cancel convention from CreateLayoutArea); it has no click action and sends nothing.
The Invite click is its own feedback (Buttons: Pending State). The invite is RETURNED to the click, so from the click until the invite answered the button is disabled with the status line "Sending the invitation to …" and the framework's Cancel; it then settles with the outcome as its summary line ("Invited … as Editor. They get access automatically when they sign up." / "… already has an account — granted Editor on this Space."). A fault settles it with the reason as its error line — an invalid address, a caller without the right, or "The invitation was not sent: …" — and so does a permission check or an invite that completes without an answer: no path completes the click silently. Nothing bounds the invite with a timeout: a hang stays visibly busy, and it is never turned into an outcome that did not happen.
Cancel during the invite releases the button, it does not stop the writes. The invite is several writes (the deferred grant, then the invitation; or the grant, then the pin), and disposing them could stop between two commits — a grant scheduled with no invitation email. So once the permission check granted, the writes run to their end on their own subscription (idempotent upserts; a late fault is logged), and Cancel only lets go of the click. Before the check has granted, Cancel stops everything.
This replaced an outcome dialog (maintainer, 2026-11-03: "no screen change happening after
invite"). The click used to fire the invite on its own subscription and answer at once, so its busy
state ended before any work started, and the only outcome surface was a closable $Dialog with no
close action. The store keeps a closed dialog, and that dialog's control is identical whatever its
text (the text lives in $Dialog/ContentArea), so after a first dialog had been dismissed every later
outcome on the page was no visible change; and while the invite had not answered, nothing showed at
all. SpaceInviteFormTest (MeshWeaver.Graph.Test) pins the settled click for every outcome, a
second invite on the same page, the Viewer refusal, the button row and Cancel.
Who may invite. The area is served on every node a viewer may read, and for an address with no
account the service writes the invitation and the deferred grant AS SYSTEM — no access check sees
the inviter on that half. So the click itself requires Permission.Update on the Space (the gate the
Invite people menu item shows itself on) for the signed-in caller, and refuses everyone else
before anything is written ("You need edit rights on this Space to invite people to it."). The
immediate grant to an existing account still runs as the caller and is refused by the mesh where
they hold no right to write it.
The host's personal message
The Space Invite form has an optional Personal message field. What the host types travels on
the invitation as Invitation.Message (SpaceInviteService.Invite(…, message), or
GrantOrScheduleAccess(…, note, message)), trimmed, and blank stores nothing. The invitation email
renders it as its own paragraph, HTML-encoded (MeshWeaver.Plugins#4551).
It is deliberately a separate field from Note. The platform fills Note with an admin-facing
description ("Invited to space …", "Invited to group … as …"), and that text must never reach the
invitee's inbox. An address that already has an account is granted at once and is sent no
invitation email, so the message only travels with an invitation.
The language of the invitation email
The invitee has no account yet, so there is no profile to read a language from. The inviting host
picks it, and it defaults to the host's own language (#7482). The Space Invite form has a
Language of the invitation email picker (endonyms: English, Deutsch) that starts at the host's
AccessContext.Locale; the pick travels on the invitation as Invitation.Locale
(SpaceInviteService.Invite(…, message, locale), or GrantOrScheduleAccess(…, note, message, locale)).
A null or blank pick is the host's own language, read on the host's turn before the System-run
lookup and writes; a group invitation (InviteToGroup) stamps the host's language the same way. The
value is always a supported tag (Locales.Resolve), and it is never read from the process culture.
InvitationEmail.Build(invitation, spaceName, baseUrl, inviteUrl) (MeshWeaver.Graph) renders the
subject and the HTML body in that language: every line comes from the catalog
(notification.email.invitation.*), the host's personal message is its own paragraph, and the
standing footer of EmailTemplate follows the same tag. inviteUrl is the signed link the sender
minted (next section); when given, the button points at it unchanged in both the Space and the
deployment-wide variant, and only without it does the button fall back to the Space or the portal. An invitation written before the field
existed carries no language and renders in English. The emailer in MeshWeaver.Plugins
(InvitationEmailSender.BuildInviteEmail) is the one that sends it, and it builds the email through
this method (MeshWeaver.Plugins#4706), passing its signed invitation link as inviteUrl. A
deployment whose Plugins build predates that change still sends its own English text, whatever the
invitation says.
The invitation link token
An invited person was once asked to TYPE an email whenever their sign-in provider returned no email
claim, and the typed, never-verified address became the key for the invitation admission and for
every pending Space grant that fires on User.email (MeshWeaver.Plugins#4515). The link in the
invitation email now carries a signed token, and the onboarding page has no editable email at all.
- Minted by the invitation emailer when it sends:
InvitationLinkToken.Issueprotects the invitation's path, itsId, the invited address and the Space with ASP.NET Core Data Protection — the key ring the portal's sign-in cookies already use, shared by every replica — through a time-limited protector. The crypto is the platform's; nothing here signs by hand. - Single-purpose: the protector purpose
Memex.Invitation.OnboardingLink.v1isolates it from every other protected payload, so no other token can be replayed as an invitation. - Time-bounded: valid for 14 days from the send; an expired token fails to unprotect exactly as an altered one does. A person who misses it is re-invited, which mints a new invitation and email.
- Bound to the invitation node: it binds only while
Admin/Invitation/{slug}still holds that exact invitation (Id), Pending, for that address. A re-invite, an acceptance or a revocation retires every token minted before it.
Following the link (InvitationLinkController, /auth/invite):
| Session | Result |
|---|---|
| Not signed in | Sign-in, then back to the link |
| Token altered, expired or foreign | Refused — /onboarding?inviteRefused=invalid |
| Verified email claim equal to the token's address | Navigates to the invited Space |
| Verified email claim for a DIFFERENT address | Refused (wrongAccount): a token for one address never onboards another |
| No verified address, invitation still current | The session cookie is re-issued carrying the token's address (as a provider with an email claim would have issued it); then the invited Space |
| No verified address, invitation no longer current | Refused (notPending) |
The onboarding page reads only the verified address. With none — a provider that returned no email claim and no invitation link followed — it explains how to get one and shows no form; a self-sign-up on an open portal therefore needs a provider that delivers an email claim.
Where invitations live
Invitations are MeshNodes of type Invitation stored in the always-present Admin partition
at Admin/Invitation/{slug} (the slug is the lowercased email with non-alphanumerics replaced by
_). The content record is Invitation:
Email, InvitedBy, InvitedAt, Status (Pending/Accepted/Revoked), AcceptedAt, Note.
The onboarding gate must find an invitation by email, globally, before the user has any identity.
🚨 Invitation queries MUST be path-scoped —
path:Admin/Invitation.InvitationNodeTyperegisters aQueryRoutingHints { Partition = "Admin" }rule for path-lessnodeType:Invitationqueries, but that rule is currently inert:PostgreSqlPartitionedMeshQueryroutes purely by the path's first segment and does not consumeQueryRoutingHintsyet. A path-lessnodeType:Invitationquery therefore goes through the cross-schema fan-out, which excludes theadminschema — so it silently returns zero rows and every invited user is refused. The rule is kept only so the hint is in place once the router honours it.
InvitationService,InvitationEmailSenderandInvitationsSettingsTaball path-scope for this reason. Do the same in any new caller:// ✅ path-scoped — resolves the admin schema via the first path segment "path:Admin/Invitation scope:children nodeType:Invitation" // ❌ path-less — inert routing hint, cross-schema fan-out skips `admin`, 0 rows "nodeType:Invitation"
Why not the auth-mirror trigger? The V27 auth-mirror trigger only mirrors
User/Group/Role/VUser/ApiTokeninto theauthschema; it would silently dropInvitationrows. Admin-partition storage + the routing rule avoids any schema/migration change. See Postgres Schema Architecture.
All invitation writes (create, accept, revoke) target the Admin partition where the caller has no
rights, so InvitationService
wraps them in accessService.ImpersonateAsSystem() — the same infrastructure-write pattern as
UserOnboardingService.CreateUser. See Access Context Propagation.
The onboarding gate
The security boundary is the CreateUser call in
Onboarding.razor, not the UI. The
gate adds an invitation synced query alongside the existing first-user / username / email checks and,
when InvitationOnly is on and this is not the first user, refuses unless a Pending invitation
matches the email or the own-directory exemption holds. On success it chains
InvitationService.MarkAccepted when an invitation was used. The page renders the form for admitted
visitors and an "Invitation Required" message for everyone else (messaging only — the real gate
is at CreateUser, which re-reads the same decision).
Exempt where the instance's own directory gates sign-in
Invitation-only is exempt on an instance whose own Entra directory decides who may use it. Three conditions, all required, all re-checked at every sign-in:
- Microsoft is the only sign-in route. Any second route (Google, LinkedIn, Apple, GitHub, the developer login) admits identities no directory issued, so it voids the exemption.
Authentication:Microsoft:TenantIdnames one directory — not unset,common,organizationsorconsumers. The handler then signs in with the authorization-code flow against that tenant's authority, so every identity reaching the portal was authenticated by that directory.- Microsoft Graph confirms the instance's Entra application requires user assignment
(
appRoleAssignmentRequired = trueon its service principal). A named tenant alone proves only which directory AUTHENTICATES a visitor; without assignment required, every member of the tenant can sign in, and the exemption would open an invitation-only portal to all of them.
How condition 3 is read. The portal asks Graph with the instance's OWN credential
(Authentication:Microsoft:ClientId / ClientSecret, an app-only token from the tenant's token
endpoint): GET /v1.0/servicePrincipals(appId='{clientId}')?$select=appRoleAssignmentRequired. The
call runs on the HTTP I/O pool. A successful reading is reused for at most two minutes, so turning
assignment off closes the exemption within that window; a failed reading is never cached.
It fails closed. No client secret, a refused token, a 403, a missing service principal or an
unparseable answer each mean not exempt: invitation-only applies, and the portal logs a Warning
starting [InvitationOnly] The own-directory exemption does NOT apply that names the cause.
🚨 The Graph read needs a permission the sign-in app does not have by default: the
Application.Read.All application permission on the instance's Entra app registration, with
admin consent granted in that tenant. Until the tenant's administrator grants it, Graph answers 403
and the exemption stays off — invitations remain the way in. Granting it is an act of the tenant's
owner, never of the platform.
Sessions are bound to the sign-in configuration. The auth cookie lives fourteen days and slides, so a session opened through a route that has since been turned off — or against a tenant that has since changed — would otherwise outlive the change. At sign-in the portal stamps a fingerprint of the sign-in configuration (which routes are on, their client ids, the Microsoft tenant; never a secret) into the cookie, and on every request it rejects and deletes a cookie whose fingerprint no longer matches, or that carries none. The visitor signs in again under the current routes. Rotating a client secret does not change the fingerprint. A cookie issued before the stamp existed carries no fingerprint and is rejected, so each user signs in once more after the change ships.
The exemption never opens a closed registration (InvitationOnly and AllowSelfOnboarding both
off) and never promotes anybody: the first-user rule is unchanged.
The decision lives in the portal's onboarding gate (MeshWeaver.Plugins: OnboardingGate.Decide, fed
by EntraAppAssignmentProbe; the cookie binding is SignInConfigurationStamp). The deployment-record
pre-flight checks the configuration shape (conditions 1 and 2); only the running portal can read
condition 3. Decided on Systemorph/Memex#633.
A request that died while its identity was being resolved
OnboardingMiddleware runs in front of every page and API route and decides — from the signed-in
user's User node and roles — whether to pass the request on, redirect it to /onboarding, or
answer 503 because identity could not be resolved. That wait is deliberately deaf to
HttpContext.RequestAborted: abandoning it half-way would leave the request with no outcome, and
the lookup carries its own bound. The consequence is that the request the middleware resumes may
already be dead.
It therefore checks RequestAborted after the decision arrives and, when the connection is gone,
hands nothing to the endpoint. Before that check (#4859) a burst of five requests on the public instance was
passed to a JSON endpoint after Kestrel's shutdown had aborted their connections and disposed its
transport; every response write rented from the dead pool and died on
ObjectDisposedException: 'MemoryPool' at Http1OutputProducer.GetFakeMemory, attributed to this
middleware because it was the frame that resumed them. (In Kestrel, GetFakeMemory is the path a
write takes once its connection has already ended, and the pool it rents from belongs to the
transport — so that stack is a write arriving after shutdown tore the HTTP transport down, whichever
way the connection itself ended.)
🚨 Not answering a dead request must not erase what the crash was evidence of. An abort while the
host is stopping is how Kestrel ends a shutdown drain that ran out of budget, so that case is logged at
Warning (the connection … was ABORTED while the host is stopping); a burst of those lines at one
instant says the host's shutdown budget ran out before HTTP drained, which is a question about the
teardown ahead of Kestrel, not about this middleware. A client that simply left a running portal is
logged at Debug. Pinned by OnboardingMiddlewareAbortedRequestTest.
The admin Invitations tab
InvitationsSettingsTab adds an Invitations tab under the Administration settings group,
registered through AddGlobalSettingsMenuItems (wired in MemexConfiguration). The gate is
AdminMenuGate.IsPlatformAdmin(host) — reactive, so the tab appears as soon as the platform-admin
grant surfaces.
🚨 "Platform admin" means Permission.All at scope Admin, not root-level. A root-level grant
is the data-superuser shape and is deliberately not how platform admins are provisioned — see
Access Control → The Admin partition. (The XML doc comment on
InvitationsSettingsTab still says "root-level Permission.All"; the code does not.)
The tab lets an admin enter an email + optional note and Invite (which creates the node — the
hosted InvitationEmailSender sends the mail), lists all invitations with their status, and
Revokes a Pending one.
Sending email (Microsoft Graph)
The portal had no email infrastructure; this feature adds a small sender that reuses your M365
tenant. It is configured by the Email section and disabled by default — when disabled, a
NoOpEmailSender logs the would-be
send and reports success, so local dev and tests never send mail.
| Key | Type | Default | Notes |
|---|---|---|---|
Email:Enabled |
bool | false |
When false, the NoOp sender is registered. |
Email:MailboxAddress |
string | "" |
The mailbox the portal sends and receives as — a real/shared mailbox (e.g. memex@yourtenant.com). |
Email:TenantId |
string | "" |
Entra tenant id (client-secret flow). |
Email:ClientId |
string | "" |
App-registration client id (client-secret flow). |
Email:ClientSecret |
string | "" |
App-registration client secret (keep in Key Vault). |
Email:UseManagedIdentity |
bool | false |
When true, authenticate via DefaultAzureCredential (managed identity) instead of a client secret. |
GraphEmailSender calls Graph /users/{mailbox}/sendMail and returns IObservable<bool>. 🚨 It
bridges the async Graph call through a bounded HTTP IIoPool (_http.Run(...)), not
Observable.FromAsync — a bare FromAsync runs the prologue on the subscribing thread and
deadlocks under a blocking subscriber. Observable.FromAsync is forbidden everywhere in src/
outside IoPool itself; there is no "not hub-reachable, so it's fine" exemption. See
Controlled I/O Pooling.
Azure setup (one-time)
/sendMail with client credentials uses the Mail.Send application permission, which is
distinct from the delegated sign-in app. Recommended:
- Register a dedicated Entra app (or reuse the managed identity in production).
- Add the
Mail.Sendapplication permission and grant tenant-admin consent — without it Graph returns 403. - Provision a real licensed or shared mailbox the portal sends and receives as, and that the app is allowed to act as (application access policies may scope this).
- Production: prefer
Email:UseManagedIdentity=trueand grant the managed identity theMail.Sendapp role; keep no secret in config. Self-host: supplyTenantId/ClientId/ClientSecret(the secret belongs in Key Vault — see Memex Cloud Deployment → Secrets).
{
"Email": {
"Enabled": true,
"MailboxAddress": "memex@yourtenant.com",
"TenantId": "<tenant-guid>",
"ClientId": "<app-client-id>",
"ClientSecret": "<from-key-vault>",
"UseManagedIdentity": false
}
}
Registration lives in MemexConfiguration.ConfigureMemexServices: IEmailSender resolves to
GraphEmailSender when Email:Enabled=true, else NoOpEmailSender.
Enabling the feature
# Require invitations…
Features__Onboarding__InvitationOnly=true
# …and turn on email so invitations actually go out.
Email__Enabled=true
Email__MailboxAddress=memex@yourtenant.com
Email__TenantId=<tenant-guid>
Email__ClientId=<app-client-id>
Email__ClientSecret=<from-key-vault>
With Email:Enabled=false you can still run invitation-only mode — invitations are created and
enforced, but no email goes out (the admin shares the portal link out-of-band).
Testing & verification
- Unit —
InvitationServiceTests(real mesh, no mocks):CreateInvitationwrites a queryable Admin-partition node;FindPendingInvitationreturns it (null when absent);Revoke/MarkAcceptedflip status; the NoOp sender returns success without sending. - Manual — as a platform admin open Admin app (
/Admin) → Invitations, invite an email, then sign in as a non-invited email (→ "Invitation Required") and as the invited email (→ profile form → completes → invitation shows Accepted).
Related
- Feature Flags — the
Featuressection reference (theInvitationOnlyflag and onboarding modes). - Postgres Schema Architecture — partitions, schemas, and the auth-mirror trigger.
- Access Context Propagation — why invitation writes impersonate as System.
- Synced Mesh-Node Queries —
workspace.GetQuery, used by the gate and the tab.