Platform
Auth, roles & permissions
Totlob has no passwords. Everyone signs in with an email one-time code. Once in, an owner can do everything, while a staff member's access is the union of their role assignments, each scoped to branches and catalogue categories.
Sign-in
| Step | Endpoint (merchant / pos / storefront) | Rules |
|---|---|---|
| Request code | …/send-otp | 6-digit code, 15-minute TTL. Always returns success, so it can't be used to discover accounts. |
| Verify | …/login (authenticate in the dashboard) | Max 5 attempts per code. Returns an access token and a refresh token. |
| Refresh | …/refresh-token | Rotates the refresh token. Presenting a used one revokes all of the user's sessions. |
| Sign out | …/logout | Revokes the refresh token. The POS never calls it; it only clears local state. |
Tokens (api-v2/src/shared/auth/jwt.ts):
- Access token: HS256 JWT via
jose, with claimssub(userId) anduserTypeId. Default TTL is 15 minutes. - Refresh token: opaque 256-bit value, stored hashed. Default lifetime is 30 days.
User types: Customer = 1, Merchant = 2, Admin = 3. Identity is the pair (email, user_type_id). Staff are not a separate type. They're Merchant users linked through shop_staff.user_id.
Client behaviour
- Dashboard (
src/auth/AuthProvider.tsx,src/api/client.ts):- Tokens live in localStorage under
totlob.tokens.v1. - The client refreshes proactively, 60 seconds before expiry.
- Only one refresh runs at a time.
- A 401 refreshes once and retries; if the refresh fails, the user is signed out and the cache cleared.
- Tokens live in localStorage under
- POS (
src/api/client.ts,src/state/auth.ts):- The session (tokens, email, chosen till) is persisted under
totlob-pos-session. - On a 401, all concurrent failures share one refresh promise, then each retries once.
- A 5xx response is never retried.
- The session (tokens, email, chosen till) is persisted under
Handoff (SSO between apps)
- The dashboard calls
/merchant/auth/v1/handoffto get a 60-second, single-use code for audienceposoraccounting. - It opens the target app in a new tab with the code in the URL fragment (
#sso=<code>). The tab is opened synchronously on click to avoid popup blockers, and its opener is cut. - The target app removes the fragment with
replaceStatebefore calling…/handoff/exchange. It then gets its own token chain; the refresh token is never shared.
The POS performs the exchange in main.tsx before the first render, so the sign-in screen never flashes.
Access model
api-v2/src/shared/auth/shop-access.ts resolves every shop-scoped request to a ShopAccess:
- owner: implicitly holds every permission, never scoped.
- staff: a list of assignments. Each assignment has a role's permission codes, its branches (
nullmeans all branches), and its catalogue category roots.
Union of (permissions ∩ locations)
A staff member is allowed to do X at branch B only if one assignment grants X and covers B. Checking "has X somewhere" and "has B somewhere" separately would fail open.
Guards
| Guard | Meaning | Typical use |
|---|---|---|
requirePermission(code) | Code held at any branch | Shop-wide reads |
requireAnyPermission(codes) | Any of the codes | Media upload |
requireShopWidePermission(code) | Code held on an all-branches assignment | Shop-wide writes: prices, categories, discounts, sections, customers |
requirePermissionAt(code, loc) / …AtAll | Code held at that branch (or every branch named) | Branch writes, create-order, open-shift |
ownerOnly | Owner only | Shop settings, apps, PINs, accounting |
ownerScoped | The service checks ownership itself; closed to staff today | Order detail, refunds, returns, fulfilment, customer detail |
requireAppInstalled(app) | Is the app on for this shop (not "is the user allowed") | Reports, Jarde, Shelves |
test/unit/route-permissions.test.ts fails the build if any route lacks an authorisation decision.
Permission codes
26 codes, from STAFF_PERMISSIONS in contracts-merchant:
| Group | Codes |
|---|---|
| Orders | orders.view, orders.create, orders.cancel, orders.mark_paid, orders.fulfill |
| Products | products.view, products.write, products.delete, inventory.write |
| Catalogue | categories.write, sections.write, discounts.view, discounts.write |
| Operations | customers.view, customers.write, locations.write, staff.manage |
| Money | billing.view, analytics.view, reports.view, reports.financial |
| POS | pos.operate, shifts.open, shifts.manage, cash.move, pos.sections.manage |
In the apps
Dashboard. canAccess (src/components/nav.ts:250) gates both the sidebar and every route through ShopAccessGate. Nav items with requiresApp (Reports, Stock count) also need the app in getInstalledApps. Inside pages, only Inventory and Modifiers hide edit buttons by permission. Elsewhere the API refuses unauthorised actions.
POS. get-tills returns only the (shop, location) pairs where the user holds pos.operate. canManageSections decides whether Manage sections appears. A remembered till the server no longer offers is cleared with a toast.
Discount PINs
- Owners issue a 6-digit PIN per staff member (
issue-staff-discount-pin). It's shown once, and reissuing revokes the previous PIN. - PINs are stored as HMACs keyed from
JWT_SECRET, so rotating that secret invalidates every PIN. /pos/v1/verify-discount-pinlocks out after repeated failures (discount_pin_failure, HTTP 429).- The PIN travels with the sale and is checked again at
create-order. The approver's name is snapshotted onto the order.
Service credentials
| Credential | Grants | Mounted when |
|---|---|---|
OPS_TOKEN | /internal/ops/v1/* (catalogue import) | Set |
INTERNAL_SECRET_KEY | /internal/*, including the whole merchant API re-pathed under /internal/merchant/* with owner authority over every shop | Set |
Both are compared in constant time. The internal key's scope was accepted only for a non-production deployment; see Known gaps.