Multi-tenancy
One deployment holds many books. The invariant is that a caller never says which tenant it is.
Where the tenant comes from
tenantId is derived from the caller's membership rows, or from the service key's own row. There
is no argument a client can set to reach another book. The public API accepts tenantSlug, and
only as a disambiguator for a caller who belongs to several tenants; membership is still what decides.
Missing, not forbidden
A document id belonging to another tenant reads as not found. Answering "forbidden" would confirm the row exists, which is itself the cross-tenant read you were trying to prevent.
Indexes lead with the tenant
Every index leads with tenantId, so a query that forgets the scope cannot use an index at all
and fails loudly in review rather than quietly returning somebody else's rows.
One exception is deliberate and marked: the resolver cron sweeps every book's due forecasts through a
tenant-less index. It is an internalMutation for exactly that reason and is unreachable from
any client. A test asserts it scores each book against its own prices.
Service keys
Workers and the MCP server have no browser session, so they present a key. The operator generates it locally and registers only its hash, which means the deployment never returns a secret and a leaked database does not leak a credential.
KEY="$(openssl rand -hex 32)"
npx convex run tenants:issueServiceKey "{\"key\":\"$KEY\",\"label\":\"sync-worker\",\"role\":\"viewer\"}"
One key maps to exactly one tenant and carries its own role. Rotate by issuing a new label and revoking the old one.