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.matters.ai", "http://localhost:4005"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["Content-Type", "Content-Length"],
"MaxAgeSeconds": 3600
}
]
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.