This page documents how the Partners Portal (apps/partners, served at partners.matters.ai) decides who gets in, what they get to see, and how every protected surface is guarded.
It covers the Clerk integration, the middleware chain, the sign-in / onboarding flow, the organization model, and the mechanics of the server-side gate functions. It does not re-derive the account-type capability matrix — that lives on a separate page:
The short version: Clerk owns identity (who you are). partner_sessions (a Neon/Postgres table, hydrated from Salesforce) owns partner standing (are you an active partner). The account-type model owns internal privilege (are you internal / admin / super-admin). Three different gate tiers read these three facts.
account_type is the one authorization axis#Everything authorizes off account_type (partner_sessions.accountType, DB + Clerk mirror). The 7-type ladder — pending · rejected · prospect · partner · internal_team_member · admin · super_admin — drives both the partner-facing app (via portalAdmission) and the internal admin zone (via the tier gates).
| Surface | Gate | Admits |
|---|---|---|
Partner app (/dashboard, opportunities, MDF, enablement) | requirePartnerSession() → portalAdmission(account_type) | partner / prospect / internal tiers |
Internal admin zone (/admin/*) | requireInternalReadSession / requireAdminSession / requireAdminWriteSession | internal tiers (read vs write) |
| Super-Admin surfaces (approvals, account-type assignment, feature-access) | requireSuperAdminSession | super_admin only |
Historical note: admission used to key off a second axis — the SF-derived
partner_sessions.partnerStatus. That was disjoint fromaccount_type, so admin grants never reached the gate.partnerStatusis now display metadata only (tier / partner type / SF ids); it is never an authorization input.
Identity is fully delegated to Clerk. The portal uses @clerk/nextjs (App Router) with the standard <ClerkProvider> at the root and Clerk's hosted-component <SignIn> widget.
| Variable | Purpose |
|---|---|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY | Client-side Clerk SDK key (browser). |
CLERK_SECRET_KEY | Server-side key. Used by @clerk/nextjs/server and by the raw Backend REST helper in lib/clerk-admin.ts (Bearer token). Never logged or thrown in an error message. |
CLERK_WEBHOOK_SECRET | Svix signing secret for the /api/webhooks/clerk endpoint. |
MATTERS_CLERK_ORG_ID | The Clerk org id of the internal Matters.AI team. Drives the internal bypass + the super-admin bootstrap floor. |
SUPER_ADMIN_EMAILS | Comma-separated allowlist of super-admin emails. Defaults to eshank@matters.ai if unset. |
NEXT_PUBLIC_APP_URL | Base URL for invitation redirect_url (defaults to https://partners.matters.ai). |
NEXT_PUBLIC_MULTI_ACCOUNT_ENABLED | Master flag for multi-session account switching. Default off (single-account behaviour). |
components/auth/PartnerSignIn.tsx renders Clerk's <SignIn> themed via useClerkAppearance() from lib/theme/clerkAppearance.ts (wired through the portal's own pm-* theme rather than left raw). The completed-sign-in redirect target depends on the multi-account flag:
<SignIn
forceRedirectUrl={MULTI_ACCOUNT_ENABLED ? "/post-auth" : "/dashboard"}
appearance={appearance}
/>
/dashboard, where requirePartnerSession() runs./post-auth router, which reads the now-active account's verified email and routes internal @matters.ai users to /admin and everyone else to /dashboard (landingFor() in lib/auth/multi-account.ts). This prevents an added internal account from transiting the partner gate./api/webhooks/clerk)#A Svix-signed webhook keeps Neon in step with Clerk lifecycle events. The signature is verified with CLERK_WEBHOOK_SECRET; an invalid signature returns 400.
| Clerk event | Action |
|---|---|
organizationMembership.created | Fire-and-forget getOrRefreshPartnerStatus(userId, orgId, email) — warms the partner_sessions cache so the partner's first dashboard load is already resolved. Does not block the webhook ack on Salesforce latency. |
user.deleted | Hard-delete the user's partner_sessions row and their opportunity_notes (GDPR-style cleanup). |
lib/clerk-admin.ts)#Admin-driven org/membership provisioning uses the documented Clerk Backend REST surface at https://api.clerk.com/v1 with a Bearer CLERK_SECRET_KEY — intentionally not @clerk/backend. It is server-only (only imported from "use server" actions) and exposes:
createOrgWithAdminInvite({ orgName, inviterEmail, inviteeEmail }) — find-or-create a Clerk org, then either add an existing user as org:admin or send an org invitation whose redirect_url is ${NEXT_PUBLIC_APP_URL}/accept-invite. Idempotent and safe to retry.revokeOrgInvite(orgId, invitationId, requestingUserId) — best-effort saga compensation; never throws (DB is the source of truth).setUserPrivateMetadata(clerkUserId, patch) — merges into Clerk privateMetadata (never the session JWT). Used to mirror the authoritative accountType.This helper is the back end of the access-request approval flow — see /partners/access-requests.
Partner companies are modeled as Clerk organizations. A partner user belongs to exactly one (or, under multi-account, may switch between) Clerk org; their orgRole (org:admin vs org:member) is read from the Clerk session. isOrgAdmin(session) in lib/auth/gate.ts is simply session.orgRole === "org:admin".
One Clerk org is special: the internal Matters.AI team org, whose id is MATTERS_CLERK_ORG_ID. Membership in that org (as org:admin) is one of the two bootstrap paths to super-admin (the other being the SUPER_ADMIN_EMAILS allowlist).
Partner users must be in an org. The middleware enforces this — but a no-org user is routed to /select-organization, the domain-based workspace chooser, not bounced to sign-in.
/select-organization)#Orgs are no longer self-service (that caused every signup to spawn its own Clerk org). Instead, a signed-in user without an active org lands on /select-organization, driven by lib/actions/org-onboarding.ts:
joinDomainOrg(orgId) only permits an org tied to the caller's domain — the security boundary that stops joining arbitrary orgs.createDomainOrg(name), capped at MAX_ORGS_PER_DOMAIN (5) per email domain.@matters.ai users are auto-added to MATTERS_CLERK_ORG_ID (autoJoinInternalOrg).Ops dependency: disable member org-creation in the Clerk Dashboard (so the capped server action is the only create path) and enable multi-session. Tracked on WEB-48.
apps/partners/middleware.ts runs Clerk's clerkMiddleware() on every request except static assets (matcher excludes _next/static, _next/image, favicon.ico). Each request is stamped with a fresh X-Request-ID. The chain, in order:
request
│
├─ 1. Host allowlist check ─────────► not allowed → 404 (noindex,nofollow)
│
├─ 2. Public route? ───────────────► yes → NextResponse.next() (no auth)
│
├─ 3. auth() → userId? ─────────────► no → 307 /sign-in?redirect_url=<safe path>
│
├─ 4. orgId? (skip for /api + /admin) ─► no → 307 /sign-in?reason=no_org
│
└─ 5. NextResponse.next() (pass to the page; gates run server-side)
isAllowedHost() only admits partners.matters.ai plus localhost / 127.0.0.1 / ::1 (dev). Anything else gets a 404 with X-Robots-Tag: noindex,nofollow — preview/vanity hosts never expose the portal or get indexed.
isPublicRoute (via createRouteMatcher) bypasses auth for the interstitial and machine endpoints:
/sign-in(.*) /post-auth /pending
/access-denied /request-access(.*) /accept-invite(.*)
/directory /api/sf/callout /api/webhooks/clerk
/api/health /api/csp-report
These have to be public so an un-provisioned or pending user can still reach the pages that explain why they can't get in.
auth() yields { userId, orgId }. No userId → redirect to /sign-in with a redirect_url set to the originally requested path (validated to start with a single /, falling back to /dashboard to block open-redirects). Under multi-account, the redirect also gets no-store / Vary: Cookie cache headers so Back/Forward never restores a prior account's page.
If authenticated but no orgId, redirect to /sign-in?reason=no_org — unless the path is under /api or matches isAdminRoute (/admin, /admin/(.*)). Admin routes deliberately do not require org membership, because internal Matters.AI users have no partner org; the @matters.ai gate for /admin is enforced server-side by requireAdminSession(), not by the middleware.
The middleware is a coarse gate (authenticated + has-an-org). The real authorization — partner status, account type, privilege tier — happens in the server-side gate functions the pages call. The middleware never reads partner_sessions or Salesforce.
requirePartnerSession() — the partner gate#lib/auth/gate.ts exports the gate every partner-facing server component / action calls. It returns a typed PartnerSession or redirect()s. Admission is decided by account_type — the same field the admin panel and domain auto-derivation control — so an admin grant or an approval actually lets the partner in. partnerStatus (SF/CSV) is only display metadata on the returned session.
const account = await getAccountSession();
if (!account) redirect("/sign-in?reason=unauthenticated");
const { clerkUserId, email, accountType } = account;
const { orgId, orgRole } = await auth();
getAccountSession() resolves (and on first login auto-derives) the caller's accountType — see Account-type resolution.
If isInternalTier(accountType) (internal_team_member / admin / super_admin), the function returns a synthetic session (partnerType: "internal", null SF ids) — keyed off the account type, not the org id, so internal staff are admitted regardless of which org is active. It never touches partner_sessions or Salesforce.
portalAdmission)#The pure helper portalAdmission(accountType) in lib/auth/account-type.ts decides the destination — it maps directly to the capability matrix (admit iff portal.read):
account_type | portalAdmission | Outcome |
|---|---|---|
partner, prospect, internal tiers | admit | continue (metadata step below) |
pending | pending | redirect("/pending") |
rejected (and anything without portal.read) | deny | redirect("/access-denied") |
An admitted external partner still needs an active org to scope their data. If orgId is absent, redirect("/select-organization") (the organization chooser). Otherwise the gate best-effort-refreshes SF metadata (getOrRefreshPartnerStatus — non-gating, so an SF outage never locks out an admitted partner) and returns the PartnerSession: clerkUserId, clerkOrgId, email, partnerRole, partnerType, partnerTier, sfAccountId, sfContactId, orgRole. isOrgAdmin(session) gates org-admin-only affordances.
Feature-access gate. On top of admission, individual pages can require a feature audience via
requireFeature(key)(lib/auth/feature-gate.ts) — a code-only config (lib/feature-access.ts) mapping each feature to"all"or a set of account types, surfaced read-only at/admin/feature-access. Currently Content Library, Agreements, Tier Benefits, and Public Directory aresuper_admin-only for internal testing.
The internal/admin gates read a different fact: the user's account type. lib/auth/account-gate.ts#getAccountSession() is the non-redirecting resolver they all build on.
A privilege decision is only ever made from a Clerk verified primary email:
const verifiedEmail =
primaryEmail?.verification?.status === "verified"
? (primaryEmail.emailAddress ?? "").toLowerCase()
: "";
An unverified address can populate the session's display email, but it can never satisfy the @matters.ai domain check or the SUPER_ADMIN_EMAILS allowlist. This blocks "set my email to admin@matters.ai" privilege-escalation attempts — an attacker would have to actually control the mailbox to verify it.
getAccountSession() computes whether the caller is an allowlisted super-admin before resolving:
const isAllowlistedSuperAdmin =
(!!mattersOrgId && orgId === mattersOrgId && orgRole === "org:admin") ||
(verifiedEmail.length > 0 && superAdminEmails().includes(verifiedEmail));
It then calls resolveAccountType() (in lib/auth/account-type-store.ts). The resolver always floors an allowlisted user to super_admin, regardless of any DB or Clerk state — so the portal can never lock itself out of approvals. (Type resolution, the Clerk-privateMetadata mirror, self-healing, and first-login auto-derivation are detailed on /partners/rbac.)
getSuperAdminClearance() is the non-redirecting form (returns { ok, userId, orgId, email }) used by shared chrome to decide whether to render admin navigation without bouncing the page.
There is a small, explicit ladder of gate functions. Each is a thin policy on top of the resolvers above; each redirect()s on failure so callers can treat the return value as a proof of access.
| Gate function | Tier / capability | Resolves on | On failure | Guards |
|---|---|---|---|---|
requirePartnerSession() | Portal admission | portalAdmission(account_type) = admit (partner/prospect/internal) | /pending, /access-denied, /select-organization, /sign-in?reason=… | All partner-facing surfaces: /dashboard, opportunity registration, MDF, enablement content. |
requireInternalReadSession() | admin.read capability | account type ∈ super_admin | /dashboard (no internal access) → or /sign-in if unauthenticated | Internal read-only surfaces (the AccountSession-typed callers). |
requireAdminSession() | Admin read tier | wraps requireInternalReadSession(); returns { userId, email } | /dashboard / /sign-in | Read-only /admin/* pages. Signature preserved so existing call sites compile unchanged. |
requireAdminWriteSession() | Admin write tier | admin.write capability (admin or super_admin; internal_team_member is read-only) | /admin?reason=read_only / /sign-in | Mutating admin actions — uploads, edits, deletes. |
requireSuperAdminSession() | super_admin only | account type === "super_admin" (incl. bootstrap floor) | /sign-in?reason=unauthorized (logs a warn) / /sign-in?reason=unauthenticated | Super-Admin-only surfaces: access-request approval, account-type assignment. |
Notes:
account_type. requirePartnerSession() admits via portalAdmission(account_type); the account-type gates check specific tiers/capabilities. The legacy partnerStatus is metadata only, never a gate. The middleware's /admin org-skip exists so the account-type gates can run for org-less internal users.requireProspectOrAbove does not exist as a standalone gate in the codebase — prospect/partner-level differentiation is expressed through the capabilities (portal.read, portal.write, content.read) on the account-type matrix and checked with can(type, capability), not via a dedicated gate function. See the matrix on /partners/rbac.requireAdminSession() returning { userId, email } (not the full AccountSession) is intentional API stability for the pre-existing /admin/* call sites.These live under the (auth) route group with a dedicated full-bleed layout (app/(auth)/layout.tsx) and are all on the public-route allowlist so un-provisioned users can reach them.
/sign-in#Hosts the Clerk <SignIn> widget. It reads a reason query param and renders a contextual message above the widget. The reason → copy map:
reason | Message |
|---|---|
unauthenticated | "Please sign in to access the partner portal." |
no_org | "Your account is not linked to a partner organisation. Contact your partner admin." |
no_email | "Your Clerk account has no verified email address. Please add one and try again." |
sf_error | "We could not verify your partner status with Salesforce. Please try again or contact support or wait for account approval." |
inactive | "Your account is not currently active in our partner program. Please contact your Matters.AI partner manager." |
pending | "Your partner account is pending activation. You will receive an email once approved." |
not_provisioned | "Your account could not be provisioned. Please contact your Matters.AI partner manager." |
unauthorized | (Super-Admin denial) — falls through to the generic "Access Restricted" card with a Sign Out / Switch Account button. |
If the user is already signed in and arrives with a non-unauthenticated reason, the page renders an "Access Restricted" card with a Sign Out / Switch Account button instead of the sign-in widget. In single-account mode, a signed-in internal @matters.ai user is redirected straight to /admin (rescuing them from the org-less bounce); multi-account mode skips this so the account chooser still works and /post-auth does the internal routing.
/pending#Shown when partnerStatus === "pending". Copy:
Partnership Pending — Your partner account is under review. You'll receive an email once it's activated. If you believe this is an error, contact your Matters.AI partner manager.
A single "← Back to sign in" link. No way forward — the user waits for approval, which sets their account_type to partner (via /admin/access-requests or /admin/accounts), picked up on the next resolve.
/access-denied#Shown when portalAdmission(account_type) is deny (rejected, or any type without portal.read). Copy:
No Partner Account Found — Your email address isn't linked to an active Matters.AI partner account. If you're a new partner, request access below.
Two actions: a Request Access button → /request-access, and a Sign Out button. This is the funnel into the access-request flow.
/request-access and /accept-invite#/request-access — a public application form (1–2 business-day review SLA in the copy). Submissions feed the access-request queue that a Super-Admin approves. Full flow on /partners/access-requests./accept-invite — the landing page for the Clerk org invitation redirect_url sent by createOrgWithAdminInvite(). It renders PartnerOrganizationList so the invitee can accept membership and enter the portal.1. User hits partners.matters.ai/dashboard
2. middleware: host OK → not public → no userId → /sign-in?redirect_url=/dashboard
3. User signs in with Clerk → forceRedirectUrl=/dashboard
4. middleware: userId ✓, orgId ✓ → next()
5. /dashboard server component calls requirePartnerSession()
├─ getAccountSession() → resolve/auto-derive account_type
│ (partner-domain email → auto "partner"; unknown domain → "pending")
├─ isInternalTier? → synthetic internal session (no SF)
└─ portalAdmission(account_type):
admit → has org? yes → render dashboard
│ no → /select-organization (join company org / create, cap 5)
pending → /pending
deny → /access-denied → "Request Access" → /request-access
An internal user hits the internal-tier bypass at step 5 (or is routed to /admin from /sign-in / /post-auth) and never touches Salesforce; /select-organization auto-joins them to the Matters org.
| Property | How it's enforced |
|---|---|
| Privilege decisions trust only real identity | Verified-email-only check in getAccountSession(); bootstrap floor requires either verified allowlist email or verified @matters.ai org-admin membership. |
| No lockout of approvals | resolveAccountType() always floors allowlisted users to super_admin, independent of DB/Clerk state. |
| Salesforce outage doesn't break active partners | First-visit SF failure with an existing cached row is allowed through; CSV mode runs entirely without SF. |
| No open redirects | redirect_url is validated to start with a single /, else falls back to /dashboard. |
| No host spoofing / preview-host exposure | Strict host allowlist → 404 + noindex for anything but partners.matters.ai (and localhost). |
| Webhook integrity | Svix signature verified against CLERK_WEBHOOK_SECRET; bad signature → 400. |
| Secrets never leak | lib/clerk-admin.ts never logs or throws CLERK_SECRET_KEY; account-type mirror lives in privateMetadata, never the session JWT. |
| Multi-account isolation | No-store + Vary: Cookie on authenticated responses; every sign-in transits the neutral /post-auth router so no account crosses another zone's gate. |
See /partners/rbac for the account-type capability matrix, /partners/access-requests for the approval saga, and /partners/salesforce for status resolution.