plan.md; this file is the reference.
The pieces
src/lib/auth/context.ts— who is asking:requireOrgContext(request),withOrgContext(request, fn),requirePageOrgContext(),can(),requirePermission(),authContextErrorResponse().src/lib/tenancy/scope.ts— the organization the current unit of work acts for:runInOrganization(orgId, fn),currentOrganizationId(),inOrg(table). Reading the organization outside a scope throws.src/lib/tenancy/registry.ts— which tables are scoped (ownorganizationId), inherited (scoped through a NOT NULL parent), global, auth or legacy; and which uniques are per organization.
1. Entry points open the scope — exactly once
API routesrequireCrmMutationContext(request) keep it (it
also checks same-origin) and wrap the rest:
const ctx = await requirePageOrgContext(); then load data inside
runInOrganization(ctx.organizationId, …).
Workers, cron routes, webhooks, public token links have no session.
They resolve the organization from the data (mailbox → org, Unipile
account → org, call session → org, grid job → table → org, crm job → org)
and run each item inside runInOrganization(item.organizationId, …). One
global loop is fine; each item carries its own organization. An
integration check such as isPlatformConnected is per organization, so it
moves inside the per-item / per-org scope.
2. Every read and write of a scoped table filters by organization
- Fetch by id: the org filter goes in the same
where, so another organization’s id is simply not found (respond 404, never 403 — do not confirm the id exists). - Inherited tables (e.g.
outreach_leads,crm_drafts,grid_rows) have no column of their own. Reach them through a scoped parent: join the parent and filter it withinOrg(parent), or filter byinArray(child.parentId, db.select({ id: parent.id }).from(parent).where(inOrg(parent))), or load the parent org-filtered first and then query children by that parent’s id. - Joins: filter the scoped table you start from; a join to another scoped table on a foreign key needs no second filter only if the foreign key makes cross-organization rows impossible — when in doubt, filter both.
- Raw SQL (
db.execute(sql\…`)): addorganization_id = $for every scoped table in the statement (”organizationId”` on the LinkedIn tables). - Counts, aggregates, analytics, badges: same rule — no number may include another organization’s rows.
3. Uniques and upserts
Business uniques are per organization (PER_ORG_UNIQUES in the registry):
people email / LinkedIn URL, company domain, suppression email, pipeline
name and default, grid_providers.key, targeted domain, legacy CRM lead
email, entity_columns (entity, key). Upserts on those tables target the
per-org unique:
4. Settings that used to be “the workspace”
Per organization: platform integrations, OpenRouter BYOK settings and AI connections, enrichment connections, the default CRM pipeline and itscrm_settings, sequences, knowledge, lead categories (subcategories),
custom lead fields (entity_columns + people.custom / companies.custom).
A new organization gets its defaults lazily (get-or-create on first use), so
nothing depends on a sign-up hook having run.
Global and shared: the domains datasets, the four crm_categories,
LinkedIn JobRun / JobLog.
5. Permissions
Members use the product. Owners and admins additionally manage integrations, AI settings, members and teams:requirePermission(ctx, { integrations: ["manage"] }) on the server, the
same role definitions (src/lib/auth/permissions.ts) in the UI.
6. Tests
Unit tests of lib functions that now read the scope run insiderunInOrganization("00000000-0000-0000-0000-00000000000a", …). The
isolation suite signs in as a second organization and asserts it can see,
change and count nothing of the first.