Branches and Scoping
Learn how branch isolation keeps stock and sales apart, and where admins cross the line.
The branch as your boundary
Every stock row carries a branch_id. Batches, sales, transfers, and stock movements all point back to branches(id) in packages/database/schema.sql. Your JWT carries your home branch in its branch_id claim (apps/server/src/core/auth/token.ts), and handlers default to it unless the route explicitly allows an override. If you forget scoping, you leak another shop’s data, so always filter by branch.
Where the user row wins
authMiddleware in apps/server/src/core/auth/middleware.ts re reads users on every request and overwrites the token role and branch with the fresh row. That means a pharmacist moved to branch 2 loses branch 1 access on the next call, and a deactivated account gets a 401 immediately. Trust the requireUser(context) result from apps/server/src/core/common/http.ts, never the raw token payload, when you need the current branch. The same middleware also pins the tenant search_path through apps/server/src/core/database/tenancy.ts, so a request can never read another organization’s tables.
What pharmacists can touch
Pharmacists and cashiers operate inside their own branch. Sale creation takes branchId from the caller: POST /sales parses the body with currentUser.branch_id as the fallback in apps/server/src/features/sales/route.ts, and createSale in apps/server/src/features/sales/creation/service.ts only writes what that branch holds. The sync push path in apps/server/src/features/sync/service.ts uses the device branch from requireDeviceBranchAccess in apps/server/src/features/sync/protocol/branch-scope.ts, and re sales with a matching client_mutation_id return the original invoice instead of double charging.
Transfers are the exception: they name both from_branch_id and to_branch_id in packages/database/schema.sql, with a unique key on (from_branch_id, client_mutation_id) so the sending branch owns the dedupe.
Where admins cross branches
Only admin bypasses requireRoles(...) checks in apps/server/src/core/auth/middleware.ts. Device registration is the clearest example: in apps/server/src/features/sync/service.ts, registerDevice accepts the branch_id from the body only when currentUser.role === "admin", while everyone else is pinned to their own. The pull filter then serves changes whose origin_branch_id or to_branch_id matches the device branch, so an admin device still only sees what its registered branch may see.
How to scope a new query
Add WHERE branch_id = ? with the current user branch as the parameter. For catalog reads, GET /api/v1/sync/snapshot is the model: getSnapshot in apps/server/src/features/sync/service.ts hands listSnapshotBranches a flag from spansAllBranches in apps/server/src/features/sync/protocol/branch-scope.ts, so an admin gets every branch and user while a branch caller only gets its own. For writes, pass the branch explicitly into createSale or emitChange from apps/server/src/features/sync/protocol/emitter.ts so the sync layer records the right origin_branch_id.
What the pull filter guarantees
The GET /api/v1/sync/pull query is the enforcement point. listVisibleChanges in apps/server/src/features/sync/queries.ts compares the device branch against each change visibility, classified by classifyVisibility in apps/server/src/core/common/visibility.ts: global passes for all, branch only when origin_branch_id matches, directed when either side matches. A pharmacist on branch 1 therefore never receives branch 2 sales rows, even though both live in the same sync_changes table.
Common scoping mistakes
New code usually breaks scoping in the same few ways. Watch for these when you review a pull request.
- Missing
branch_idfilter on a newSELECT, leaking rows across shops - Trusting the token claim instead of the fresh
requireUser(context)result - Letting non admins pass an explicit
branch_idin a write body - Emitting a sync change with the wrong
origin_branch_idfor the writer
Try it with curl
Log in as each demo user and compare what branch you land in.
curl -s -X POST http://localhost:8000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"admin@demo.pharmaflow","password":"Demo@1234"}'
TOKEN="<paste access_token here>"
curl -s http://localhost:8000/api/v1/branches \
-H "Authorization: Bearer $TOKEN"
Next steps
- Read /architecture/request-lifecycle for how the branch claim reaches your handler
- Read /architecture/stock-and-fefo for branch-scoped batch deduction
- Read /sales/selling-at-the-counter for the register flow built on this scoping