Prerequisites
- Bun 1.2 or newer (
packageManageris[email protected]). The repository’s scripts and test runner use Bun. - Node.js 20.9 or newer (
.nvmrcpins 24). Next.js runs on it. - A local PostgreSQL 16+ server and an empty database.
pg_dumpat least as new as your server, only if you regeneratedb/schema.sql.
Local setup
.env.local. For local development you need:
RESEND_API_KEY can stay empty: sign-up and invitation emails are written to
the terminal running the dev server. The other variables
are optional (configuration.md). Third-party services are
connected in Settings once the app runs, not here.
Then create the schema and start the app:
db:seed:demo creates [email protected] with password demo-password-123 in
an organization named “Northwind Demo”, filled with fictional data. Without it,
sign up at /sign-up yourself (the verification link appears in the dev server
log) and create an organization at /onboarding.
db:setup refuses a database that already has tables. To start over, drop and
recreate the database.
Scripts
All frompackage.json. bun run <name>.
Testing
- The suite runs under Bun. Roughly half the files import
bun:testand half importnode:test; Bun runs both.node --testdoes not work (it fails on the firstbun:testimport). --conditions=react-serveris required: several modules importserver-only, which throws outside that condition. The scripts inscripts/that call app code pass the same flag.- Tests that call library code which reads the organization scope run inside
runInOrganization("00000000-0000-0000-0000-00000000000a", ...). - No test needs a database or network.
Auth policies test
scripts/e2e/auth-policies.ts checks the sign-up modes (AUTH_SIGNUP),
organization creation and the platform operator through Better Auth against a
real database. It needs a freshly set-up empty database (bun run db:setup),
because it asserts on the “first account” and “first organization” rules, needs
no server, and refuses to run unless DATABASE_URL points at localhost. CI
runs it.
Tenancy isolation test
scripts/e2e/tenancy-isolation.ts checks, end to end, that a member of a
second organization cannot see, change or count anything of the first. It
drives a running server over HTTP:
bun run db:seed:demo works with [email protected] /
demo-password-123. It signs in as organization A’s owner, creates a second user and organization,
and fires reads and writes at A’s ids, scanning every response for A’s
identifiers. It refuses to run unless DATABASE_URL points at localhost
(exit code 2), because it creates users and sends mutations. Set
ISOLATION_SKIP_WRITES=1 to skip the write phase.
Typecheck and lint
Changing the database
The schema’s history is the set of migration scripts inscripts/; Drizzle
definitions in src/lib/**/schema.ts are kept in sync by hand;
db/schema.sql is generated. To change the schema:
- Write a migration as a new TypeScript file in
scripts/, run withbun run scripts/<name>.ts. It loads.env.local, connects withDATABASE_URL, guards DDL withIF NOT EXISTS/IF EXISTS, and is safe to re-run. Seed scripts upsert rather than blind-insert. - Update the matching Drizzle schema in
src/lib/<domain>/schema.ts. - Apply the migration to your local database.
- Regenerate the snapshot:
bun run db:schema:dump, and commitdb/schema.sqltogether with the migration and the Drizzle change. - If you added a table, classify it in
src/lib/tenancy/registry.ts(scoped, inherited, global, auth or legacy), and add a snake_case table toOWNED_TABLESindrizzle.config.ts(except the lead-database tables, see database.md). Give every scoped table anorganization_idwith an index. - Run
bun run db:check. It fails if a table is unclassified or a scoped table is missing its organization column.
db/seed.sql.
See database.md for the reasoning and caveats.
Code conventions
- TypeScript only. New code is
.ts/.tsx, including scripts. Do not add.jsor.mjsfiles. (Existing older scripts are.js.) - Tenancy. Every query of a scoped table filters with
inOrg(table); every insert setsorganizationId: currentOrganizationId(); an id from another organization answers 404, not 403. API routes open the scope withwithOrgContext; workers and webhooks resolve the organization from the data and callrunInOrganization. Full rules: multi-tenancy/conventions.md. - Integrations. Never read a third-party key from
process.env. UsegetPlatformCredentials/requirePlatformCredentialsfromsrc/lib/platform/credentials.ts; user-facing routes answer 409 when it is not connected and background jobs return early. - Drizzle is the only ORM. No Prisma.
.server.tssiblings.postgrescannot be bundled for the browser. When a module has pure helpers a client component imports and database calls, put the queries in a.server.tssibling (for examplesrc/lib/linkedin/inviteRetry.tsandinviteRetry.server.ts). Adding adbimport to a client-safe module breaks the build withCan't resolve 'fs'.import typefrom a query module is always safe.- Two
cn()helpers.@/utils/cnextends tailwind-merge with the AlignUI typography groups;@/lib/linkedin/utilsis plaintwMerge(clsx()). They are not interchangeable: keep LinkedIn components on the latter. - Connection pool. Do not create another
postgres()pool in app code; usedbfromsrc/lib/db.ts. - Commits. One logical change per commit, with a message in the form
Area: summary(for exampleOutreach: skip suppressed addresses).
Recorder extension
extensions/whatsapp-recorder/src/{background,wa-hook,wa-agent,bridge}.ts
into extensions/whatsapp-recorder/dist/ (gitignored) and copies
static/ (manifest and icons) next to it. Load dist/ as an unpacked
extension at chrome://extensions. It imports src/lib/calls/contract.ts; keep
that file free of runtime imports. The origins it accepts are the built-in
ones in src/origins.ts (in sync with static/manifest.json) plus any added
on its Options page, which needs no rebuild. It depends on WhatsApp Web’s English labels (“Voice call”, “End call”).
Documentation
docs/ is also a Mintlify site: docs/docs.json holds
its navigation, and docs/.mintignore keeps internal records (design history,
maintainer notes, migration runbooks) off it. Pages are Markdown (.md) or
MDX (.mdx) with title and description frontmatter. A page appears in the
sidebar only once it is listed in docs.json.
{ starts an expression: write merge fields as inline code
(`{{firstName}}`). Link between pages with relative .md paths in the
Markdown pages and root paths (/email/campaigns) in the MDX ones.