Every Partners Portal user resolves to exactly one of seven account types. The type is the single axis that decides what a user can see and do — from "locked out" to "every inch of the platform." This page documents the model end to end: the types and the domain rule, the capability matrix, where the type is stored (and how it self-heals), the one writer and one resolver that own it, how the gates derive from it, the Super-Admin assignment UI, and the lockout-safety guarantees.
The pure model lives in apps/partners/lib/auth/account-type.ts; storage + sync in apps/partners/lib/auth/account-type-store.ts; the gates in apps/partners/lib/auth/{account-gate,admin-gate,super-admin-gate}.ts.
export const ACCOUNT_TYPES = [
"pending",
"rejected",
"prospect",
"partner",
"internal_team_member",
"admin",
"super_admin",
] as const;
export type AccountType = (typeof ACCOUNT_TYPES)[number];
| Type | Meaning |
|---|---|
pending | Default for everyone unclassified. No access — routed to an interstitial. The transient state before becoming prospect/partner or being promoted. |
rejected | Hard-denied. Kept on a separate list; a Super Admin can move them back to pending to reconsider. |
prospect | External, being nurtured. Read-only marketing/content; cannot register deals or request MDF. |
partner | External, active partner. Full partner portal. |
internal_team_member | @matters.ai staff. Read-only admin — can view every /admin/* surface but not mutate. |
admin | @matters.ai. Most of the platform with write access — everything except the Super-Admin-only surfaces. |
super_admin | @matters.ai. Literally everything, including account-type assignment and access-request review. |
The user's email domain constrains which types they may ever hold. This is enforced in the pure layer and re-checked in the writer:
const EXTERNAL_TYPES = ["pending", "rejected", "prospect", "partner"]; // non-@matters.ai
export function isMattersEmail(email: string): boolean { /* lastIndexOf('@'), === "matters.ai" */ }
export function allowedTypesForEmail(email: string): AccountType[] {
return isMattersEmail(email) ? [...ACCOUNT_TYPES] : [...EXTERNAL_TYPES];
}
export function canAssignType(email: string, type: AccountType): boolean {
return allowedTypesForEmail(email).includes(type);
}
A non-@matters.ai email can never be internal_team_member, admin, or super_admin — the assignment dropdown hides them and setAccountType throws if asked. isMattersEmail matches the domain exactly (case-insensitive), so look-alikes like matters.ai.evil.com or notmatters.ai are rejected.
Everyone starts pending. The only automatic grant is for an external user whose email domain matches a known active partner account (the seeded partner_accounts / Salesforce data — see Salesforce & CSV Mode):
export function deriveDefaultType(email: string, domainIsKnownActivePartner: boolean): AccountType {
if (!isMattersEmail(email) && domainIsKnownActivePartner) return "partner";
return "pending";
}
@matters.ai users are never auto-granted internal access — they land pending and wait for a Super Admin to promote them (the approval defaults them to internal_team_member, which the Super Admin can then raise). This is intentional: internal privilege is always an explicit human decision.
Authorization is expressed as capabilities, not raw type checks. can(type, capability) is the one predicate everything funnels through:
export type Capability =
| "portal.write" | "portal.read" | "content.read"
| "admin.read" | "admin.write" | "audit.read" | "superadmin";
export function can(type: AccountType, cap: Capability): boolean { /* matrix lookup */ }
| Capability → | portal.read | portal.write | content.read | admin.read | admin.write | audit.read | superadmin |
|---|---|---|---|---|---|---|---|
super_admin | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
admin | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | — |
internal_team_member | ✓ | — | ✓ | ✓ | — | ✓ | — |
partner | ✓ | ✓ | ✓ | — | — | — | — |
prospect | ✓ | — | ✓ | — | — | — | — |
pending | — | — | — | — | — | — | — |
rejected | — | — | — | — | — | — | — |
The split that matters most: admin.read vs admin.write. internal_team_member has read but not write — they can open every admin page but every mutating control is hidden in the UI and rejected server-side. admin adds write. Only super_admin has superadmin (account assignment + access requests).
partner_sessions.account_type ← SOURCE OF TRUTH (durable, queryable, what Super Admins write)
Clerk privateMetadata.accountType ← synced read-mirror (free on the hot path; self-heals)
Four columns on partner_sessions (Data Model): account_type (default pending), account_type_source (auto | assigned — assigned is sticky so a re-login auto-derive never clobbers a manual decision), account_type_updated_by (the Super Admin's email), account_type_synced_at (mirror freshness; null = dirty).
Why privateMetadata, not publicMetadata: it is server-only — never exposed to the browser or the session JWT — which is correct for an authorization field. The gates run in server components that already call currentUser(), which returns privateMetadata for free, so reading it on the hot path costs nothing. (Neither bucket is client-writable; the difference is purely read exposure. publicMetadata is the sanctioned fallback only if edge/middleware role checks are ever needed — a localized 2-line change, since the mirror is touched in exactly two places.)
setAccountType()#The single mutation path. Every assignment — Super-Admin UI, access-request approval, first-login auto-derive — goes through it. The authoritative transition is one atomic Postgres transaction; the Clerk mirror is synced after commit and is never authoritative:
setAccountType({ clerkUserId?, email, type, source, actorEmail, … }):
1. validate the domain rule (throw before any write)
2. db().transaction(tx => {
upsert partner_sessions row (by clerkUserId, or by email for pre-login)
→ account_type, account_type_source, account_type_updated_by,
account_type_synced_at = null // mark mirror dirty
recordActivity(tx, …) // the audit row commits in the SAME tx
})
3. after commit (best-effort): push privateMetadata.accountType to Clerk;
on success stamp synced_at = now(); on failure leave it null for self-heal
The DB write and the audit row commit together or not at all (ACID — see Audit Log). A Clerk sync failure can never leave a user in a wrong authorization state, because gates read DB truth. Pre-login assignment (assigning a type to an email that has no Clerk user yet) writes a row keyed by a pending:<email> placeholder clerk_user_id; the resolver adopts it on first login.
resolveAccountType()#The single read path, used by every gate:
resolveAccountType({ clerkUserId, clerkOrgId, email, privateMetaType, isAllowlistedSuperAdmin }):
1. BOOTSTRAP FLOOR: isAllowlistedSuperAdmin → return "super_admin" // can't be locked out
2. no partner_sessions row yet → first login:
2a. adopt a pre-login "pending:<email>" row if present (claim it, sync mirror)
2b. else auto-derive (deriveDefaultType), persist via setAccountType(source:"auto"), sync
3. existing row → DB is truth; reconcile the mirror:
read account_type (DB) + the privateMetadata value (free)
if mirror !== db OR synced_at stale (>15 min) → re-push DB→Clerk (self-heal), stamp synced_at
return the DB value
The bootstrap floor is what makes the system un-lockout-able: SUPER_ADMIN_EMAILS (default eshank@matters.ai) or Matters.AI-org admin always resolves to super_admin regardless of DB/Clerk state, so the portal can never lose its ability to assign roles. The TTL reconcile (15 min, mirroring the partner-status cache) keeps the Clerk mirror eventually-consistent and DB-wins on any drift.
Gates live in lib/auth/. They wrap resolveAccountType() (via getAccountSession() in account-gate.ts) and assert a capability:
| Gate | Requires | Use for |
|---|---|---|
requireSuperAdminSession() | account_type === super_admin | /admin/accounts, /admin/access-requests, role assignment |
requireAdminWriteSession() | can(type, "admin.write") (admin/super_admin) | all mutating admin actions — MDF approve/reject/claim, directory toggle, content/agreement/tier save+delete, content upload, PII export |
requireAdminSession() / requireInternalReadSession() | can(type, "admin.read") (internal_team_member+) | read-only admin pages + the (admin) layout + the audit-log viewer |
requirePartnerSession() | partnerStatus === active (partner portal) | /dashboard, deal reg, MDF, team — see Auth & Gates |
The read/write split is load-bearing. Admin view pages use the read gate so
internal_team_membercan look; the mutating server actions and routes userequireAdminWriteSessionso they cannot act. Wiring a write action to the read gate would be a privilege escalation — that exact bug existed mid-build and was caught and closed.
The (admin) layout passes the real isSuperAdmin (from the resolved type) into the sidebar, and the nav config gates links with internalOnly (read-only admin links, any internal tier) vs superAdminOnly (Accounts + Access Requests). So an internal_team_member sees the read-only admin links but not the Super-Admin-only ones — the nav matches the page gates.
admin-accounts#/admin/accounts#A Super-Admin-only surface (requireSuperAdminSession). It lists every partner_sessions row grouped by organisation (org name resolved from partner_profiles → partner_accounts → raw org id), each with a domain-filtered dropdown to set the type, plus a separate Rejected section with a "Move to Pending" action. Mutations go through the single assignAccountType server action (lib/actions/account-types.ts) — the Rejected section's Move to Pending control reuses the same action with type = pending, which:
canAssignType),super_admin (the env allowlist remains the ultimate floor, but the UI blocks the foot-gun),setAccountType() writer (so every change is transactional + audited + mirrored).partner on first login.pending; they submit an access request; a Super Admin approves (→ partner, provisions Clerk org/invite/email) or rejects (→ rejected). See Access Requests.@matters.ai teammate → pending on first login; a Super Admin promotes them at /admin/accounts to internal_team_member / admin / super_admin.resolveAccountType step 1 resolves super_admin from SUPER_ADMIN_EMAILS / Matters-org admin regardless of DB/Clerk state. The portal can never lock itself out of role administration.super_admin.The partner_sessions composite key (clerk_user_id, clerk_org_id) allows one Clerk user to hold multiple rows (one per org) once Multi-Account Login is enabled. The resolver currently looks a user up by clerk_user_id alone, so for a multi-org user it would read an arbitrary org's row. This is harmless today (the MULTI_ACCOUNT_ENABLED flag is OFF → one row per user) but must be reconciled — make the resolver/writer org-scoped — before enabling multi-account. The semantic decision (account type per-org vs global) is tracked in the repo PENDING_TASKS.md §5.
Beyond "can you enter the portal," individual features/pages can be limited to a multi-select audience of account types — a minimal, code-only flag system in lib/feature-access.ts.
export const FEATURES = {
contentLibrary: { label: "Content Library", path: "/content", audience: ["super_admin"] },
agreements: { label: "Agreements", path: "/agreements", audience: ["super_admin"] },
tierBenefits: { label: "Tier Benefits", path: "/tier-benefits", audience: ["super_admin"] },
publicDirectory: { label: "Public Directory", path: "/directory", audience: ["super_admin"] },
};
// audience = "all" | AccountType[] — canAccessFeature(key, accountType)
requireFeature(key) (lib/auth/feature-gate.ts) server-gates the page (unauth → sign-in, wrong audience → /dashboard); the sidebar independently hides links whose feature audience excludes the viewer. The gate is authoritative; nav filtering is cosmetic./admin/feature-access (features × the 7 account types).super_admin-only (internal testing before public). Opening one up is a one-line audience edit — gate, nav, and admin matrix all follow. The Public Directory gate is config-first, so setting its audience back to "all" restores public ISR with no dynamic auth.| Concern | File |
|---|---|
| Types, domain rule, capability matrix (pure) | lib/auth/account-type.ts |
| Writer + resolver + mirror sync | lib/auth/account-type-store.ts |
| Session resolution + read gate | lib/auth/account-gate.ts |
| Admin read/write gates | lib/auth/admin-gate.ts |
| Super-Admin gate + clearance | lib/auth/super-admin-gate.ts |
| Assignment actions | lib/actions/account-types.ts |
| Assignment UI | app/(admin)/admin/accounts/page.tsx + AccountTypeSelect.tsx |
| Columns | lib/db/schema.ts → partner_sessions |