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

StepEndpoint (merchant / pos / storefront)Rules
Request code…/send-otp6-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-tokenRotates the refresh token. Presenting a used one revokes all of the user's sessions.
Sign out…/logoutRevokes 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 claims sub (userId) and userTypeId. 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.
  • 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.

Handoff (SSO between apps)

  1. The dashboard calls /merchant/auth/v1/handoff to get a 60-second, single-use code for audience pos or accounting.
  2. 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.
  3. The target app removes the fragment with replaceState before 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 (null means 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

GuardMeaningTypical use
requirePermission(code)Code held at any branchShop-wide reads
requireAnyPermission(codes)Any of the codesMedia upload
requireShopWidePermission(code)Code held on an all-branches assignmentShop-wide writes: prices, categories, discounts, sections, customers
requirePermissionAt(code, loc) / …AtAllCode held at that branch (or every branch named)Branch writes, create-order, open-shift
ownerOnlyOwner onlyShop settings, apps, PINs, accounting
ownerScopedThe service checks ownership itself; closed to staff todayOrder 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:

GroupCodes
Ordersorders.view, orders.create, orders.cancel, orders.mark_paid, orders.fulfill
Productsproducts.view, products.write, products.delete, inventory.write
Cataloguecategories.write, sections.write, discounts.view, discounts.write
Operationscustomers.view, customers.write, locations.write, staff.manage
Moneybilling.view, analytics.view, reports.view, reports.financial
POSpos.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-pin locks 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

CredentialGrantsMounted 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 shopSet

Both are compared in constant time. The internal key's scope was accepted only for a non-production deployment; see Known gaps.

Previous
Shared contracts