Skip to content

Auth & multitenancy

Purpose: how identity, roles, tenant isolation, and the login flows work on Appwrite. Status: as of 2026-09-26 (2FA: TOTP login works, web #1136; owner-as-operator rule 2026-08-18; terminal-onboarding pairing gate 2026-08-12; core model 2026-06-30).

  • Store = Appwrite Team. Each store is its own Appwrite Team — the tenant boundary. Membership roles are the POS roles cashier / supervisor / manager (Appwrite team roles are arbitrary strings).
  • Merchant = merchants collection. The merchant is an organization record grouping stores (stores.merchantId), not a team (Appwrite teams are flat). A merchant owner overseeing several stores is simply a manager member of each store team.
  • Single shared database. Isolation is by document-level permissions (RLS) — read Role.team(storeTeamId), write Role.team(storeTeamId, <role>). A query can never return another store’s rows because the requester isn’t in that team. (No DB-per-merchant, no merchant team.)
  • Store-team provisioning (creating a Team) needs the API key, so it’s a server op; routine reads/writes are client-direct under RLS.
  • Labels (Role.label(...)) are reserved for cross-merchant platform admins/support — not normal POS roles.

The full role catalog, permission matrix, label usage, tenancy tiers, and the future distributor network are specified in roles-permissions-and-labels.md. This section is the summary.

  • POS role = store-team membership role (cashier / supervisor / manager, all assignable). There are no pos_roles/pos_permissions collections.
  • The role → permission mapping is a code matrix (src/auth/roles/pos-roles.ts) — the single source of truth for both the Role.team(storeTeamId, <role>) writes each collection grants and the server-side assertStoreRole() checks.
  • Enforcement: Appwrite RLS for all data; a lightweight assertStoreRole() only inside the few server endpoints that remain (PIN mint, invites, user/team admin, durable workflows). No @RequireRole on CRUD.
  • Sensitive operations write to audit_log.
Client Mechanism Why
Android terminal Appwrite JWT (minted from the session) Native app; token also validated by NestJS for server-to-server calls.
Next.js web Secure cookie session (SSR) Cookies fit server-side rendering.
NestJS API Validates the incoming Appwrite token (JWT/session) via a custom guard Replaces the BetterAuth guard + passport strategies in terminales-api/src/auth/auth.guard.ts.

All built on Appwrite Account. These replace the BetterAuth equivalents.

Flow Where Notes
Email + password login Web, Mobile Standard Account session.
Magic link login Web, Mobile Passwordless email link.
Forgot / reset password Web, Mobile Account recovery.
2FA (MFA) Web, Mobile Authenticator app (TOTP), email or SMS. Enrol the app (QR / setup key → verify) or verify email/phone → enable MFA → challenge on login. The web picks the enrolled factor totp → email → phone (pickChallengeFactor, terminales-web #1136, R9 M15 — before it a TOTP-only account could not sign in); a TOTP challenge has no resend. MFA copy never names an authenticator brand. SMS needs a configured provider per env.
PIN login (cashier fast-login) API mints, Web + Mobile consume NestJS validates the employee PIN, then mints an Appwrite custom token the client exchanges for a session. Re-targets the legacy PIN-login work.
User management API endpoints + Web UI Create/list/update users; assign roles.
Invite users API flow + Web UI Invite with role into a team.
Presence Web Online-users via the Appwrite Presences API (GA 2026-05-25) — presence records + Channel.presences() + auto-expiry; scoped to the store team.
Flow Real paths
API token guard terminales-api/src/auth/appwrite-auth.guard.ts (+ spec) — validates the Appwrite token, replaces the BetterAuth guard (#114).
Web email/password + magic link terminales-web/src/app/login/{LoginForm.tsx,login.api.ts} + callback/MagicLinkCallback.tsx; SSR cookie helper src/core/api/session.ts (#101).
Mobile session/JWT terminales/app/.../auth/{SessionManager,EncryptedSessionStorage,JwtAuthInterceptor,SessionAccountClient,SessionClock}.kt — session lifecycle, encrypted local storage, JWT minted + attached to NestJS calls (#119).
Mobile 2FA + recovery terminales/app/.../auth/{AppwriteMfaRepository,MfaViewModel,AppwriteRecoveryRepository,RecoveryViewModel}.kt — email/SMS MFA factors + forgot/reset password (#120).

PIN login (custom token) and user-management/invite UIs remain to land.

Cashiers don’t type full credentials at the terminal. Instead:

  1. Client sends store + PIN to NestJS.
  2. NestJS verifies the PIN hash against employees, checks store/role.
  3. NestJS mints an Appwrite custom token for that user.
  4. Client exchanges the custom token for an Appwrite session (cookie on Web, JWT on Android).

This keeps PIN verification server-side while still producing a normal Appwrite session.

Owners sell too (PO 2026-08-18). Every person who operates the POS — the merchant owner included — needs an operator profile: an employees row with a scrypt PIN, created via POST /pos/employees (manager-min caller; links an EXISTING auth user, never mutates the membership). There is no separate owner POS role — the owner’s membership resolves to manager rank on the POS plane. Runbook until the web “Personal” UI ships: terminales-api/scripts/provision-employee.sh (self-provisioning supported); the e2e seed now creates the owner’s operator profile (PIN in seed-info.md).

Terminal onboarding — the official operator sequence (decided 2026-08-12)

Section titled “Terminal onboarding — the official operator sequence (decided 2026-08-12)”

A mobile terminal is only operational after two distinct provisioning steps, populated by two different mechanisms. Email/password sign-in is a bootstrap, not provisioning: it establishes an Appwrite session and stamps the offline tenant, but it does not pair a terminal and does not populate the operational session. Getting these wrong leaves a device inside the POS but with no store (“Sin tienda asignada”), no terminal (“Terminal: —”), and an empty catalog.

Provisioning step Created by Populates
Terminal pairing claim a pairing code → POST /pos/terminals/claim → TerminalIdentity{terminalId, storeId, deviceSerial} in TerminalIdentityStore isPaired, the PIN gate, the shift’s terminal, catalog/tenant scope
PIN login POST /pos/pin-login → { token, employee{storeId,storeTeamId}, shift } → TerminalSessionStore.unlock() the operational session: employee row, store, open shift (the shift/sale context resolvers)

Canonical sequence: (1) a manager pairs the terminal once — a supervisor issues the pairing code from the web admin (/admin/pos/terminals → Vincular terminal), the operator types it on the device; (2) cashier PIN login every shift; (3) select store (multi-store); (4) Abrir turno; (5) sell. Pairing codes are 8-char, 5-min TTL (terminal-pairing.constants.ts).

Decision (PO, 2026-08-12) — pairing is a required onboarding gate. This closes design gap #36 (“M05b”): pairing was previously only an in-shell redirect on the sale tabs, so an authenticated email/password + store-pick device slipped into the shell unpaired and the PIN gate (requiresPin = isPaired && !isUnlocked) was silently skipped. The gate now lives in MainActivity: an authenticated operator whose device has no TerminalIdentity (canEnterPos == false) is routed to TerminalSetupScreen before the shell — so no device can operate unpaired, and PIN login always runs. canEnterPos stays harness-overridable (BuildConfig.DEBUG && HarnessSeed.enabled) so tests keep pre-registering.

Token validation guard, PIN verification + custom-token minting, user-management/invite endpoints that need server authority, and anything touching PSP/secrets. See nestjs-dbos.

No feature flags gate these flows pre-production (no live users). At cutover, the production-flags ticket (Epic G) introduces appwrite_auth gating and a kill-switch. See open-questions-and-todos.