This is the operator's guide for the Matters.AI Partners Portal (apps/partners) — every
environment variable it reads, the npm scripts that run it, how the Neon schema is managed, how the
seed and bootstrap scripts work, and the end-to-end production deployment to partners.matters.ai.
It mirrors the operational runbook in the repo PENDING_TASKS.md (both the Production
Deployment §§A–H block and the Multi-Account Login block) and the per-service reference in
apps/partners/PENDING_TASKS.md.
The portal is a standalone Next.js app on port 4005 with its own Clerk app, its own air-gapped Neon Postgres project, and its own R2 bucket — fully separate from workspace/memo. Nothing here is shared with the other apps.
Related pages: Data model · Auth · RBAC · Salesforce · Runbook
The canonical template is apps/partners/.env.example. Copy it to
.env.local and fill in real values — never commit secrets. For local prod-like runs the repo
also reads, in order, ~/.envs/.matters-ai/.env.partners.prod, then .env.local, then .env, then
.env.prod (this load order is defined in drizzle.config.ts and each script — the first file to
set a key wins).
Variables prefixed NEXT_PUBLIC_ are bundled into the browser; everything else is server-only and
must never be referenced from a Client Component.
The Partners portal gets its own Clerk application, separate from workspace/memo.
| Variable | Required | Notes |
|---|---|---|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY | yes | Public Clerk key (pk_live_… in prod, pk_test_… for the dev instance used by localhost + e2e). |
CLERK_SECRET_KEY | yes | Server-side Clerk secret (sk_live_… / sk_test_…). Also consumed by bootstrap:super-admin to call the Clerk Backend API. |
CLERK_WEBHOOK_SECRET | yes | Svix signing secret for the /api/webhooks/clerk endpoint (events organizationMembership.created + user.deleted). Dashboard → Webhooks → Signing secret. |
A separate Neon project, air-gapped from workspace/memo.
| Variable | Required | Notes |
|---|---|---|
PARTNERS_NEON_DATABASE_URL | yes | Full Neon connection string with ?sslmode=require. Read by lib/db/client.ts, drizzle.config.ts, and every seed script. Without it the DB client throws PARTNERS_NEON_DATABASE_URL is not set and the seeds exit 1. |
Stores partner logos, MDF documents, and signed agreements. Browser uploads use presigned URLs.
| Variable | Required | Notes |
|---|---|---|
R2_ACCOUNT_ID | yes | Cloudflare account ID. |
R2_ACCESS_KEY_ID | yes | R2 API token access key (Object Read & Write, scoped to the bucket). |
R2_SECRET_ACCESS_KEY | yes | R2 API token secret. |
R2_BUCKET_PARTNERS | yes | Bucket name — matters-partners. |
R2_PUBLIC_URL | yes | Public custom-domain base for serving assets — https://partners-assets.matters.ai. |
NEXT_PUBLIC_R2_PUBLIC_URL | yes | Same value, exposed to the browser so the client-side logo preview can build URLs. |
Backs rate limiting on the SF callout and auth routes.
| Variable | Required | Notes |
|---|---|---|
UPSTASH_REDIS_REST_URL | yes | REST URL of a Regional Upstash database. |
UPSTASH_REDIS_REST_TOKEN | yes | REST token. |
Sends invitations, deal notifications, and access-request approval/rejection emails.
| Variable | Required | Notes |
|---|---|---|
RESEND_API_KEY | yes | Resend API key (re_…). Required for the approval/rejection emails to actually send. |
RESEND_FROM_EMAIL | yes | Verified sender — partners@matters.ai. Needs the sending domain verified (SPF/DKIM/DMARC). |
Error tracking is wired in instrumentation.ts (Node + edge runtimes) plus
sentry.server.config.ts / sentry.edge.config.ts; it activates only when a DSN is present.
| Variable | Required | Notes |
|---|---|---|
SENTRY_DSN_PARTNERS | no | DSN read by instrumentation.ts; when unset, enabled: !!process.env.SENTRY_DSN_PARTNERS keeps Sentry off (safe local default). tracesSampleRate is 0.1 in production, 1.0 otherwise. |
SENTRY_AUTH_TOKEN | no | Only for source-map upload during build. |
v1 runs in CSV mode: when SF_CLIENT_ID is unset, partner lookup reads the seeded
partner_accounts table instead of live SOQL. Provide all of these only when switching to live SF —
the data layer flips over with zero code changes. See Salesforce.
| Variable | Required | Notes |
|---|---|---|
SF_CLIENT_ID | no (enables live SF when set) | Connected App Consumer Key. Leave blank to stay in CSV mode. |
SF_PRIVATE_KEY | conditional | Base64-encoded PKCS8 RSA private key for the JWT Bearer flow. |
SF_USERNAME | conditional | Integration user — e.g. partners-integration@matters.ai.sandbox. |
SF_LOGIN_URL | conditional | https://test.salesforce.com (sandbox) or https://login.salesforce.com (prod). |
SF_CALLOUT_HMAC_SECRET | conditional | HMAC secret for inbound SF push-callouts; set identically in the SF Named Credential header. Generate with openssl rand -hex 32. |
| Variable | Required | Notes |
|---|---|---|
NEXT_PUBLIC_APP_URL | yes | https://partners.matters.ai. Used to build invite redirect + email links (lib/email.ts, lib/clerk-admin.ts). |
| host allowlist | n/a | Not env-configurable. The accepted hosts are hardcoded in middleware.ts — partners.matters.ai plus loopback (localhost / 127.0.0.1 / ::1). |
CRON_SECRET | yes (prod) | Bearer token guarding /api/cron/* (revalidate-partner-status, lead-lifecycle-sweep). Generate with openssl rand -hex 32. Set in the Vercel dashboard. |
| Variable | Required | Notes |
|---|---|---|
MATTERS_CLERK_ORG_ID | no | The internal Matters.AI Clerk org ID. Admins of this org get Super Admin. Printed by bootstrap:super-admin. |
SUPER_ADMIN_EMAILS | no | Comma-separated fail-safe allowlist. A signed-in user with a verified primary email here reaches the Access Requests panel even before the org is wired. Defaults to eshank@matters.ai if unset — so you can never be locked out. |
NEXT_PUBLIC_MULTI_ACCOUNT_ENABLED | no (default false) | Master kill-switch for multi-account login (lib/auth/multi-account.ts, middleware.ts). When unset the portal behaves exactly as single-account. Also flips Sentry sendDefaultPii off in both Sentry configs when true. |
Either path grants Super Admin — Matters.AI Clerk org membership (
MATTERS_CLERK_ORG_ID) or a verified email inSUPER_ADMIN_EMAILS. See Auth and RBAC.
All scripts run from apps/partners with env loaded from .env.local (or use the pnpm --filter @matters/partners <script> form from the repo root). Defined in apps/partners/package.json.
| Script | Command | Purpose |
|---|---|---|
dev | next dev --port 4005 --webpack | Local dev server on port 4005. |
app | exports .env.prod then next dev --port 4005 --webpack | Dev server with prod env loaded from .env.prod. |
build | next build | Production build (uploads source maps to Sentry when SENTRY_AUTH_TOKEN is set). |
start | next start --port 4005 | Serve the production build locally. |
type:check | tsc --noEmit | Type-check the whole app — must be clean before deploy. |
lint | next lint | ESLint. |
db:generate | drizzle-kit generate | Emit a SQL migration file from lib/db/schema.ts diffs into drizzle/. |
db:migrate | drizzle-kit migrate | Apply pending migration files. Note: drizzle/ is not a complete from-scratch history here — db:push is the canonical path. |
db:push | drizzle-kit push | Canonical schema sync: diff lib/db/schema.ts → Neon and apply directly. Run after any schema change. |
db:studio | drizzle-kit studio | Browser-based DB inspector (verify indexes, rows). |
seed:csv | tsx scripts/seed-from-csv.ts | Load partner accounts from .sf_partners_data.csv → partner_accounts. |
seed:tiers | tsx scripts/seed-tiers.ts | Seed the 3 PRD tiers (Registered / Silver / Gold) → partner_tiers. |
bootstrap:super-admin | tsx scripts/bootstrap-super-admin.ts | Create the Matters.AI Clerk org + add an internal Super Admin. |
test | vitest run | Unit tests. |
test:watch | vitest | Unit tests, watch mode. |
test:e2e | playwright test | Playwright e2e suite (auth-dependent multi-account tests skip cleanly if test creds are absent). |
test:e2e:ui | playwright test --ui | Playwright in UI mode. |
The schema lives in lib/db/schema.ts. The runtime client is lib/db/client.ts — a lazily-cached
Neon serverless Pool driven by drizzle-orm/neon-serverless with casing: "snake_case". The Neon
DB is manually managed: any un-applied schema change makes the dependent pages 500 behind the
generic error boundary, so the schema must be pushed before the matching code goes live.
db:push#cd apps/partners
pnpm db:push
This diffs lib/db/schema.ts against the live Neon database and applies the changes directly. It is
the path used in this repo (the drizzle/ migration files are not a complete from-scratch
history — prefer db:push).
History — was broken on Postgres 18, now FIXED. On Neon Postgres 18 with the old drizzle-kit 0.28.1,
db:pushemitted spuriousDROP CONSTRAINT … not_nullstatements and failed with Postgres error 42P16 (applying nothing). It is now fixed and verified by upgrading todrizzle-orm@0.45.2+drizzle-kit@0.31.10(the versions pinned inpackage.json); the diff against PG18 is now clean.
Two harmless quirks remain once it works:
drizzle.config.ts sets strict: true, so db:push shows a confirm
prompt before mutating. Run it in an interactive terminal, not a piped or CI shell — a
non-interactive run can't answer the prompt.ALTER COLUMN … SET DEFAULT '{}' re-assertions for array columns. These are idempotent no-ops —
just confirm them.When you can't get a TTY (CI, a piped shell), apply idempotent additive DDL directly against the
Neon SQL console instead of db:push. For example, the multi-account composite-unique change on
partner_sessions:
-- drop the old single-column unique (substitute the real constraint name from \d partner_sessions)
ALTER TABLE partner_sessions
DROP CONSTRAINT IF EXISTS partner_sessions_clerk_user_id_unique;
-- add the composite unique index
CREATE UNIQUE INDEX IF NOT EXISTS partner_sessions_user_org_unique
ON partner_sessions (clerk_user_id, clerk_org_id);
Verify with pnpm db:studio — confirm the new index exists and the old one is gone. See
Data model for the full table list.
Both seed scripts are idempotent upserts — re-running them updates rows in place, never
duplicates. Each loads env from the same four-file order as drizzle.config.ts and exits 1 if
PARTNERS_NEON_DATABASE_URL is missing. They use the one-shot drizzle-orm/neon-http driver
(not the websocket Pool).
seed:csv — partner accounts (CSV mode)#scripts/seed-from-csv.ts reads .sf_partners_data.csv from the current working directory (a
Salesforce export), parses it with a small quote-aware CSV parser, derives a normalised
websiteDomain from each Website (strips www., lowercases), and upserts into
partner_accounts keyed on sfAccountId (onConflictDoUpdate). New rows are created with
portalStatus: "active". This table is what CSV mode authorizes partners against when SF_CLIENT_ID
is unset.
cd apps/partners
pnpm seed:csv # reads ./.sf_partners_data.csv
Expected columns include Account Name, Account ID, Website, Partner Account Name,
Account Type / Partner Account: Type, Partner Account: Industry,
Partner Account: Billing Country / City, and Partner Account Owner. Rows without an account
name are skipped.
seed:tiers — the three PRD tiers#scripts/seed-tiers.ts upserts exactly three tiers — registered, silver, gold — into
partner_tiers, keyed on the unique slug. Per the Partner Portal PRD v1.1 there is no Platinum
tier, and margin/MDF are configured per-deal, not per-tier — so arrRange, mdfQuarterlyLimitUsd,
and dealProtection are intentionally left null. Each tier carries its displayName, description,
sortOrder, and benefits / requirements arrays.
cd apps/partners
pnpm seed:tiers
scripts/bootstrap-super-admin.ts (run via pnpm bootstrap:super-admin) provisions the internal
Matters.AI Super Admin in the Partners Clerk app. It calls the Clerk Backend API directly with
CLERK_SECRET_KEY and is fully idempotent.
pnpm --filter @matters/partners bootstrap:super-admin
# or target a specific person / org:
pnpm --filter @matters/partners bootstrap:super-admin -- --email eshank@matters.ai --org "Matters.AI"
What it does:
Matters.AI (default; override with --org).SUPER_ADMIN_EMAILS, else
eshank@matters.ai; override with --email). If the user exists it adds them — or promotes
them — to org:admin (inspecting existing membership first so re-runs never error). If the user
doesn't exist yet it sends an org:admin organization invitation; they accept via inbox, then
sign in at /accept-invite.MATTERS_CLERK_ORG_ID (Vercel + .env.local) and keep
SUPER_ADMIN_EMAILS=eshank@matters.ai as the fail-safe.No secrets are printed — only the resulting org ID and a status summary. Requirements: a valid
CLERK_SECRET_KEY for the Partners app and Organizations enabled in that Clerk app. See
Auth and RBAC for how the org/allowlist resolves into the
7-type account model.
Target: Vercel, production domain partners.matters.ai. The recommended go-live order is a
CSV-mode launch (skip Salesforce): A Neon → B Clerk → C R2 → D Upstash → E Resend → F Sentry →
G Vercel. The division of labour: a human creates each third-party account / secret / DNS record;
the env wiring, schema push, seeds, and bootstrap are then mechanical.
console.neon.tech → New Project → copy the connection string →
set PARTNERS_NEON_DATABASE_URL. Apply the schema with pnpm db:push (see
Schema management), then run pnpm seed:tiers + pnpm seed:csv. Verify all
tables exist in the Neon console.
dashboard.clerk.com → Create application (Email sign-in only).
Copy NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY (pk_live_…) + CLERK_SECRET_KEY (sk_live_…). Add a
Webhook → https://partners.matters.ai/api/webhooks/clerk, events
organizationMembership.created + user.deleted → CLERK_WEBHOOK_SECRET. Set the Invitations
redirect → https://partners.matters.ai/accept-invite. Then run pnpm bootstrap:super-admin and
paste the printed MATTERS_CLERK_ORG_ID. (If running multi-account, also enable Multi-session
handling under Configure → Sessions, on each instance.)
Uploads/downloads fail without this. dash.cloudflare.com → R2 →
bucket matters-partners. Add the CORS rule (browser uploads break without it):
[
{
"AllowedOrigins": [
"https://partners-portal.matters.ai",
"https://partners.matters.ai",
"http://localhost:4005"
],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["Content-Type", "Content-Length"],
"MaxAgeSeconds": 3600
}
]
The production origin is
partners-portal.matters.ai. If it is missing fromAllowedOrigins, the presign route returns 200 but the browser's directPUTto R2 is blocked by CORS, so every upload (MDF attachments, logos, content, agreements) fails. Keep both hostnames listed.
Create an R2 API token (Object Read & Write) → R2_ACCESS_KEY_ID + R2_SECRET_ACCESS_KEY;
account ID → R2_ACCOUNT_ID; set a custom domain partners-assets.matters.ai → R2_PUBLIC_URL
NEXT_PUBLIC_R2_PUBLIC_URL; R2_BUCKET_PARTNERS=matters-partners.console.upstash.com → Create Database (Regional, lower
latency for rate limiting) → copy REST URL + token → UPSTASH_REDIS_REST_URL +
UPSTASH_REDIS_REST_TOKEN.
resend.com → Domains → Add Domain (matters.ai / mail.matters.ai) → add
the SPF / DKIM / DMARC DNS records Resend provides. Create a sending-scoped API key →
RESEND_API_KEY; set RESEND_FROM_EMAIL=partners@matters.ai. Required for approval/rejection emails
to send.
sentry.io → Create Project (Next.js) → DSN → SENTRY_DSN_PARTNERS
(optional source-map upload during build: SENTRY_AUTH_TOKEN). Code is already instrumented via
instrumentation.ts.
cd apps/partners && vercel link (needs your Vercel login). Root Directory: apps/partners;
framework auto-detects as Next.js. Push every env var (vercel env add …). Set Production Domain
partners.matters.ai (+ DNS), NEXT_PUBLIC_APP_URL=https://partners.matters.ai,
and CRON_SECRET (openssl rand -hex 32, guards /api/cron/*). The host allowlist is hardcoded in
middleware.ts (partners.matters.ai + loopback) — there is no ALLOWED_HOSTS env var to set.
After the first deploy, repoint the Clerk webhook + invitation redirect at the live domain.
Not needed for launch. While SF_CLIENT_ID is unset, the portal authorizes partners via the seeded
partner_accounts. For live SF later — RSA keypair + Connected App + integration user + custom
fields — see Salesforce and apps/partners/PENDING_TASKS.md §3. The data
layer switches over with zero code changes.
Multi-account login (sign into up to 3 accounts in one browser) is built but gated behind
NEXT_PUBLIC_MULTI_ACCOUNT_ENABLED, which defaults off. To enable it: (1) set
NEXT_PUBLIC_MULTI_ACCOUNT_ENABLED=true on each environment, and (2) toggle Multi-session
handling ON in the Clerk Dashboard (Configure → Sessions) — per instance (dev for local/e2e,
prod for the live site). With the flag off, exactly one partner_sessions row exists per user and
the account-type RBAC behaves as single-account. Before flipping it on in prod, apply the composite
(clerk_user_id, clerk_org_id) unique on partner_sessions (see the
direct-DDL fallback). See
RBAC for the account-type org-scoping model.
Once live, the 7-type account-type RBAC (pending · rejected · prospect · partner · internal_team_member · admin · super_admin) is run entirely from the UI — no scripts:
/admin/accounts — assign account types (Super Admin only). External emails can never be set
to an internal type./admin/access-requests — approve (→ partner, provisions the Clerk org + invite + approval
email) or reject (→ rejected, sends the rejection email) pending external requests. Domains that
match a seeded partner_accounts row auto-approve to partner on first login./admin/activity — the append-only audit log (filter by org / actor / action / date), visible
to all internal tiers.Guard rails are automatic: the last super_admin can't be demoted, every approval/rejection is an
all-or-nothing transaction, and account type mirrors to Clerk and self-heals on drift. Full detail
in RBAC and the operational Runbook.