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).
Multitenancy model (decided 2026-06-19)
Section titled “Multitenancy model (decided 2026-06-19)”- 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 =
merchantscollection. 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 amanagermember of each store team. - Single shared database. Isolation is by document-level permissions (RLS) — read
Role.team(storeTeamId), writeRole.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 nopos_roles/pos_permissionscollections. - The role → permission mapping is a code matrix (
src/auth/roles/pos-roles.ts) — the single source of truth for both theRole.team(storeTeamId, <role>)writes each collection grants and the server-sideassertStoreRole()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@RequireRoleon CRUD. - Sensitive operations write to
audit_log.
Sessions: JWT vs cookie
Section titled “Sessions: JWT vs cookie”| 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. |
Login & account flows
Section titled “Login & account flows”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. |
Implemented (2026-06-30, first wave)
Section titled “Implemented (2026-06-30, first wave)”| 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.
PIN login (the one custom flow)
Section titled “PIN login (the one custom flow)”Cashiers don’t type full credentials at the terminal. Instead:
- Client sends store + PIN to NestJS.
- NestJS verifies the PIN hash against
employees, checks store/role. - NestJS mints an Appwrite custom token for that user.
- 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.
What stays in NestJS
Section titled “What stays in NestJS”Token validation guard, PIN verification + custom-token minting, user-management/invite endpoints that need server authority, and anything touching PSP/secrets. See nestjs-dbos.
Deferred until production
Section titled “Deferred until production”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.