Sales Portal #
Classification: Restricted (describes source code, infrastructure, and access control).
The Sales Portal is an internal-only admin app for the Sales and Customer Success teams. It proxies Clerk so staff can manage customer organizations without direct Clerk-dashboard access, and it records an audit trail of every action to Datadog.
- Code:
apps/sales-portal/ - Stack: TanStack Start (React 19) + Vite + Nitro, shared
@bonsai/ui. - Hosts: dev
dev-sales-portal.internal.gotofu.com, prodsales-portal.internal.gotofu.com(internal ALB, VPN/office restricted). - Doppler: shares the
bonsaiproject (like webapp/tofu-mcp); synced to thebonsai-secretk8s secret at runtime.
What it does #
| Area | Capability |
|---|---|
| Organizations | List/search (by name, slug, or Clerk org id), create (auto-slug + invite owners inline), rename; whole row is clickable |
| Org deletion | Needs org:organization:delete — “Danger zone” on the org detail → General tab, behind a type-the-org-name confirmation. Deletes the Clerk org; bonsapi’s organization_deleted webhook cleans up the DB row, billing cache, and Intercom |
| Members | Two tabs — Members and Invited (invitee, status, date); invites are Owner-only |
| Users | Needs org:member:manage — flat, searchable Clerk user directory (/users); each row opens a detail page (/users/:userId) with read-only Profile and Organizations tabs, plus a “Danger zone” delete behind a type-the-email confirmation. Internal gotofu staff are hidden from the list and cannot be viewed or deleted from here |
| Feature flags | Toggle any flag, including canary (replaces hand-editing in the Clerk dashboard) |
| Trials | Constrained: extend expiry by 1 week, max entities fixed at 2, max extractions/month +10 (10s cooldown, cap 100) |
| Impersonation | Eye icon in the member list (needs sales_portal:impersonate): mints a Clerk actor token and opens a recorded webapp session |
| Recordings | Needs sales_portal:watch_recording — replay impersonation sessions (contain customer PII) |
| Billing plans | Needs sales_portal:plans — org detail → Billing Plans tab. Hide a publicly listed Stripe plan from one organization’s billing page. Only products marked public in Stripe are listed, so a sales-only plan cannot be granted from here |
| Audit trail | Everyone with access — global (/audit) and per-org (org detail → Audit tab), read back from Datadog |
Feature flags and trials are stored in Clerk organization metadata
(publicMetadata.features, publicMetadata.trial_*,
privateMetadata.data_retention_period_in_days). bonsapi’s organization.updated
webhook mirrors these into the database, so writes made here stay in sync.
Plan visibility lives alongside them in publicMetadata.billing_plans, keyed by
Stripe product id (not plan name, so a rename in Stripe cannot orphan a toggle).
It is a sparse map of overrides, not an allowlist: a product absent from it
falls back to its own Stripe visibility metadata, which is what keeps the first
toggle from implicitly hiding every other plan. It deliberately does not go
through the config/features.yaml codegen — it is per-org sales data keyed on
Stripe ids, not a product feature flag.
Access control (Clerk RBAC) #
“Feature or permission?” — both. A Clerk custom permission key is composed of
a Feature namespace plus an action: org:<feature>:<permission>. The portal
uses:
- Feature (namespace):
sales_portal - Permissions (the source of truth for gating):
org:sales_portal:access(see the portal at all),org:sales_portal:impersonate(the member-list eye icon),org:sales_portal:watch_recording(the Recordings screen),org:sales_portal:plans(the Billing Plans tab),org:organization:delete(the General tab’s “Danger zone”),org:member:manage(the Users page’s delete action). - Attach these permissions to whatever roles you like in the Clerk dashboard
(e.g. a
salesrole with just access, acsmrole with access + impersonate, an owner role with all three). The portal checks the permissions on the caller’s internal-org membership — not role names. The audit trail is visible to anyone withaccess. Users withoutaccessget a “No access” screen with a sign-out button.
⚠️
org:organization:deleteandorg:member:managedo not sit in thesales_portalnamespace.org:organization:deleteneeds its ownorganizationFeature in the Clerk dashboard;org:member:manageneeds its ownmemberFeature (matching bonsapi’s own permission convention inlibs/rust/bonsai-model/src/org_permission.rs, so the portal and bonsapi agree on what the permission means). Until a permission’s Feature exists and is attached to a role, the corresponding capability (canDeleteOrg/canManageUsers) is always false and its UI simply never renders. Create both before expecting org deletion or user deletion to work in a given Clerk instance.
Access is decided server-side by looking up the caller’s membership in the
internal gotofu Clerk org (SALES_PORTAL_INTERNAL_CLERK_ORG_ID) and reading that
membership’s permission list — not the JWT has() helper, because a staff user
usually has a customer org active and custom permissions only appear in the
token for the active org. See apps/sales-portal/src/shared/server/auth.ts.
Do not confuse the three “feature” concepts in this repo. BonsAI feature flags are
config/features.yaml→ orgpublicMetadata.features(what the Feature Flags tab toggles). Clerk RBAC Features are permission namespaces likesales_portal. Clerk Billing features are unused (Tofu bills via Stripe) — if Clerk Billing is ever enabled, permission checks only pass when the permission’s Feature is in the org’s active plan.
Granting a staff member access #
- In the Clerk dashboard, open the internal gotofu organization.
- Add the user as a member with role
org:salesororg:csm. - No code change or redeploy is needed — the portal reads the role live (60s cache).
Audit trail #
Every mutation writes a Datadog log tagged is_audit_log: true (matching the
backend audit_info! convention in libs/rust/bonsai-utils). Find them with:
service:sales-portal is_audit_log:true
Each event carries audit_type (OrgCreate, OrgNameUpdate, OrgDelete,
OrgInvitationCreate, FeatureFlagUpdate, TrialUpdate, ImpersonationStart,
UserDelete), the actor’s user id + email, the target org/user, and
before/after snapshots for flag/trial changes. Source:
apps/sales-portal/src/shared/server/audit.ts.
OrgDelete carries a full before snapshot (name, slug, member count,
features, trial) — the Clerk org is gone by the time the event is read, so this
log is the only remaining record of what was deleted. UserDelete carries the
same idea for a user (email, name) — the Clerk user is gone by the time the
event is read.
Impersonation & recording #
CSM users can impersonate a customer user from an org’s Impersonate tab. The
portal mints a short-lived Clerk actor token and opens
https://<WEBAPP_HOST>/sign-in?__clerk_ticket=<token> in a new tab; Clerk’s <SignIn>
redeems the ticket.
The impersonated webapp session is screen-recorded with
rrweb: the ImpersonationRecorder
(apps/webapp/src/shared/components/impersonation-recorder.tsx) dynamically loads
rrweb only for impersonated sessions and streams event chunks to
POST /api/impersonation-recording, which uploads them to a Google Drive shared
drive. CSMs replay sessions in the portal under Recordings.
Runbooks #
Doppler keys (bonsai project)
#
The portal shares the bonsai Doppler project. CLERK_SECRET_KEY,
DATADOG_API_KEY and WEBAPP_HOST already exist there; add the rest:
| Key | Status | Notes |
|---|---|---|
VITE_CLERK_PUBLISHABLE_KEY |
add | Clerk publishable key (client). Inlined at build. Same value as NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY. |
CLERK_SECRET_KEY |
exists | — |
SALES_PORTAL_INTERNAL_CLERK_ORG_ID |
add | Clerk org id (org_…) of the internal gotofu org. |
WEBAPP_HOST |
exists | Bare webapp host, e.g. app.gotofu.com; the portal prepends the scheme. |
DATADOG_API_KEY |
exists | — |
GOOGLE_SALES_PORTAL_SERVICE_ACCOUNT_JSON_B64 |
add | Base64 of the Drive service-account JSON (also used by the webapp recorder). |
GDRIVE_SALES_PORTAL_RECORDINGS_FOLDER_ID |
add | Root folder id in the recordings shared drive (also used by the webapp recorder). |
The existing bonsai-doppler-external-secret already syncs every bonsai key
into bonsai-secret (dataFrom regexp .*), so no new ExternalSecret is needed.
CI build uses the shared DOPPLER_TOKEN / DOPPLER_CONFIG secrets (same as webapp).
Clerk dashboard setup (dev AND prod instances) #
- Roles & Permissions → Custom permissions → create Feature
sales_portal, then permissionssales_portal:accessandsales_portal:impersonate. - Create roles
org:sales(access only) andorg:csm(access + impersonate). - Assign staff in the internal gotofu org; note that org’s id →
SALES_PORTAL_INTERNAL_CLERK_ORG_ID. - Confirm the Enhanced B2B Authentication add-on covers the extra custom roles, and that Clerk Billing is not enabled.
Google Drive service account (one-time) #
- In Google Cloud Console, create/reuse a project (e.g.
tofu-sales-portal). - Enable the Google Drive API.
- Create a service account
sales-portal-recorder; add a JSON key; base64-encode it intoGOOGLE_SALES_PORTAL_SERVICE_ACCOUNT_JSON_B64. - Create a Shared Drive “Impersonation Recordings”. It must be a Shared
Drive — service accounts have no personal Drive quota, so uploads to a normal
folder fail with
storageQuotaExceeded. - Add the service-account email as Content manager on the Shared Drive; keep human membership restricted to the sales/CSM group (recordings contain customer data).
- Put the target folder id in
GDRIVE_SALES_PORTAL_RECORDINGS_FOLDER_ID. - All Drive API calls pass
supportsAllDrives: true(+includeItemsFromAllDrives: trueon lists), or Shared Drive items are invisible.
Retention: recordings are not auto-deleted yet. Decide and implement a retention policy (manual cleanup or a scheduled job) before this sees heavy use.
Local development #
mise run sales-portal-dev # Doppler sales-portal/dev + vite dev on :3040