Multi-tenant lead intelligence with restricted-role DB access
Built the internal sales workbench for a growing Nordic consultancy – deterministic ICP scoring, restricted-role Postgres access in a shared Supabase project, GDPR-defensible exports, and a mobile-first "Today" view I actually use standing up.
My contribution
Defined the sales workflow, shaped the workbench and scoring model, designed the tenant and data boundaries, and implemented the product. Tested authentication and isolation, delivered the application, and refined the daily workflow around sales use.
Open full-size image ↗
Situation
Stratas' sales pipeline was living across a Google Sheet, a Notion board, and the occasional LinkedIn tab. Three problems bit at once:
- Prospect scoring was intuition-per-account. Every review meeting reopened the "is this actually ICP?" argument because no two people had scored the same account the same way.
- Supabase infrastructure was already shared across products. A separate Supabase project per internal tool would have doubled operational overhead; using the shared project safely meant designing the multi-tenant story before writing the first migration.
- Contact enrichment via paid APIs (Hunter, Firecrawl, EXA, Claude) had no natural upper bound on spend. Without deterministic gates, an accidental full-table rescore could cost more than a month of the sales function's tooling budget.
Task
Decide how to build a multi-tenant lead-intelligence tool inside a shared Supabase project, with deterministic scoring, defensible exports under Norwegian marketing law, and paid-API cost gates – under the constraint that the shared Postgres project also hosts other products with different sensitivity boundaries.
Action
System workflow
Architecture
Dedicated Postgres schema plus restricted DB role. The Supabase project is shared. The prospector schema is not. Migration 002_prospector_app_role.sql creates a prospector_app role that is NOLOGIN, has usage grants only on the prospector schema, and no access to anything else in the project. The app signs NextAuth JWTs with role: prospector_app; PostgREST enforces the boundary. Anonymous and authenticated Supabase roles have zero grants – the "public API" of the shared project cannot see a Prospector row.
Deterministic ICP scoring, LLM-free. scoring.ts supports three criterion types: numeric_range (ideal min/max with linear falloff, hard bounds), enum_map (normalised string lookup), and manual (user-entered 0–1). Missing inputs keep a company unscored (never defaulted to 0), with a partial breakdown showing which criteria are missing. A SHA-256 hash of the active criteria plus scoring version invalidates stale scores automatically; a rescore touches score columns only in chunks of 250. No LLM ever influences the score. That decision closes off a whole class of "why is this account WARM this week and MONITOR next?" that no LLM path can honestly answer.
Tenant scoping in the app layer, verified by tests. A tenantScoped() helper wraps every read/write; a middleware test verifies every API route rejects unauthenticated traffic. The database prevents cross-schema leaks; the app prevents cross-tenant leaks within the schema. Belt-and-suspenders is deliberate here – one of the two layers alone would be brittle.
Enrichment eligibility gates. Companies must be scored to enter enrichment; unscored companies are held in flight (not force-purged) so a later discovery run can fill missing inputs. Rows scored unscored with no financials and unreachable sites move to disqualified after a judge pass – never deleted, so discovery's known-orgnr check never re-adds them.
Alternatives considered and rejected:
- Separate Supabase project per tool. Would have doubled ops for a single-user internal tool. The restricted-role approach carries its weight instead.
- LLM-assisted scoring for edge cases. Ships fast, feels smart, becomes an unauditable disagreement engine within a quarter. Not a fit for the "why is this WARM" conversation.
- App-layer auth as the sole boundary. One accidental service-role query and the boundary is gone. The restricted role forces PostgREST to enforce it structurally.
Implementation
- Excel export with a formula-injection sanitizer. ExcelJS workbook with two sheets (Prospects, Activity Log). Every string cell passes through
sanitizeCell(), which strips leading=,+,@, and–characters. Estimators paste rows into other workbooks; a mistyped material name should not exfiltrate one. - GDPR default: personal emails excluded. Norwegian
markedsføringsloven§15 requires opt-in for personal (non-role) email marketing. Personal addresses are flagged and excluded from export by default; a?includePersonalEmails=1query parameter overrides with an audit-trail row. - Session revocation without logout. Every user has a
session_versioncolumn, bumped when a password is reset. Live JWTs become invalid the next time PostgREST checks the row, without needing a logout endpoint or a session store. - SSRF guard on any user-supplied URL. Internal IP ranges blocked, redirect count capped at 3, probes issued as
GET Range: bytes=0-0expecting 200 or 206. Lookalike-URL detection runs before any external fetch. - Mobile-native "Today" view. Three surfaces stacked on
page.tsx: overdue plus due-today actions as row cards with owner and score chips, funnel strip by stage (linking to filtered rows), full sortable-filterable-exportable prospect table. Desktop uses the table; mobile uses stacked cards with a filter drawer – separate renderings, not responsive shrinking.
Verification
- 80 test files, ~11,000 lines of test code. Golden tests for scoring config-hash mismatch, formula-injection defence, SSRF range validation, middleware-wide 401-if-unauthenticated sweep, stage-transition rules, idempotent contact-merging on enrichment.
- Every paid API call tagged and logged to Langfuse. Hunter, Firecrawl, EXA, Claude – spend visible per tenant per day in the fleet dashboard.
- Migrations are idempotent. Sixteen migration files, safe to re-apply, small enough to review one-shot.
Result
Open full-size image ↗
- Live tool for the internal sales function, running against real accounts, replacing three prior fragments (Sheet, Notion, LinkedIn tab hoarding).
- Zero cross-schema access from Prospector's PostgREST calls. Cannot read Saga rows, cannot read SMI rows, cannot read the website's tables – enforced by the database.
- Deterministic score, explainable per criterion. Every review meeting cites the same breakdown; the argument moved from "is this ICP" to "should this criterion weight more".
- GDPR-defensible exports with personal-email exclusion by default and an audit trail on override.
- Sixteen migrations, ~11k lines of tests, one shared Supabase project safely partitioned.
Second-order effects: the restricted-role pattern is now the template for any new product sharing the Supabase project. The pattern is short enough to fit in one migration; the review checklist for adding a new product to the shared project has "restricted-role migration merged" as its first item.
What we would do differently: ship the outbox call-channel earlier. The outreach sequences and templates were ready weeks before the call channel joined email and LinkedIn as a first-class outreach type. Sales work happens on the phone as much as in the inbox; treating call as an afterthought in the data model made the retrofit larger than it needed to be.
Skills evidenced
- Postgres multi-tenancy without multi-project overhead – schema isolation, restricted role with
NOLOGINand scoped grants, JWT-signed role assignment, PostgREST as the structural boundary. - Security-first data plumbing – RLS by default, formula-injection sanitisation on export, SSRF guard on user-supplied URLs, session revocation via row-versioning.
- Deterministic scoring at production quality – hashed-config staleness detection, missing-input handling as first-class state (not zero-defaulted), pure functions with golden tests.
- Trigger.dev v4 orchestration – enrichment, demo-build, template generation, outreach drafts as chained tasks with cost tags per hop.
- Mobile-first productivity UX – separate mobile and desktop renderings for a workbench used standing up, not a responsive-shrink of a desktop table.
Portable lessons
- A restricted DB role is a one-migration pattern that closes a whole class of shared-project blast radius. Every new tool inside a shared Supabase project gets one; app-layer auth alone is not enough.
- Missing inputs must be first-class state. Defaulting to zero is a lie the model doesn't know it's telling;
unscoredwith a partial breakdown is the honest surface. - Sanitise export cells, always. Formula injection via CSV/XLSX is one of the oldest exfiltration paths and one of the easiest to close – never ship an export without it.
Want to discuss the decisions behind it?
Over fifteen years of consulting and growth, and hands-on AI delivery from discovery to daily operations. I can share my CV and talk through how I connect business outcomes with systems that run.
Available for a chat. In person in Stavanger, or remotely worldwide.