# Authentication and authorization FloCafe authorizes in the backend. The renderer renders, and never decides. This page describes the authentication lifecycle, the shape of the authorization surface, the configurable permission layer, and the boundaries the product deliberately leaves open. For the in-app permission editor and UI capability matrix, see [the roles and permissions reference](../reference/roles-and-permissions.md). For the product rules that constrain authorization, see [the product invariants reference](../reference/product-invariants.md). ## Roles FloCafe has five fixed staff identities, declared in [`shared/role-permissions.ts`](../../shared/role-permissions.ts): `owner`, `manager`, `cashier`, `server`, and `chef`. The identity a request acts under is read from the `users.role` column at request time, not from the token, and there is no role editor — an install cannot add a sixth identity or rename one. What each identity is _allowed to do_ is a separate, configurable layer. See [Configurable permissions](#configurable-permissions) below. `shared/role-permissions.ts` also exports `ROLE_ACCESS`, a map of ten named role groups. Those groups now serve two purposes: they are the _shipped default_ `defaultRoles` values in [`shared/permissions.ts`](../../shared/permissions.ts) (`PERMISSION_DEFINITIONS`), and they remain the direct gate for the small set of context-policy checks described below that are role-based by product decision rather than by omission. ## Configurable permissions Almost every protected action — 245 call sites — is gated by `requirePermission(id)` or `requireAnyPermission(...ids)` from [`main/services/authorization.ts`](../../main/services/authorization.ts), not by a fixed role list. `requireRole` (in `main/middleware/security.ts`) still exists, but a static audit test (`tests/authorization-static-audit.test.ts`) fails the build if a new call site under `main/routes/` uses it; the few remaining direct role checks are reviewed context policy, covered in their own sections below or in the owning feature documentation (KDS station/category scope, refund approver identity, the Staff Approval PIN holder, the last-active-owner/staff-target rule, own-shift vs manager/owner cash-shift access, owner-only counter and refund-policy settings, and owner-only `z_report_day_close` configuration in [WhatsApp receipt delivery](cloud-integrations.md#whatsapp-receipt-delivery)). **Resolution.** For every permission id, `resolveEffectivePermissions(userId)` evaluates, in order: the protected-owner rule (for `authorization.manage` and `staff.privileged.manage` only, always true for an active owner and never grantable to anyone else); an explicit user override; an explicit role override; then the shipped default. It re-reads current SQLite state — an `Authorization` header's JWT role claim is never authoritative, and neither is a cached `permission_ids` list. `hasPermission(userId, id)` and `requirePermission(id)` are thin wrappers around this resolver; `requireAnyPermission(...ids)` passes if any one resolves true. **Storage.** `role_permission_overrides` and `user_permission_overrides` are sparse override tables (schema migration v93): a missing row means "use the shipped default," so a permission introduced by a later upgrade is judged on its own reviewed default rather than inheriting whatever an old override happened to resolve to. `authorization_audit_log` records every write: actor, target (role or user), permission id, previous effect, next effect. Writes go through the owner-only `/api/authorization` API, one SQLite transaction per save, gated on a last-seen `revision` string to reject a stale concurrent editor with `409` instead of silently overwriting it. **Migration guarantee.** The shipped defaults in `PERMISSION_DEFINITIONS` were derived from the `ROLE_ACCESS` groups the routes used before this layer existed, so an installation with zero override rows resolves identically to the fixed-role system it replaced. `authorization.manage` and `staff.privileged.manage` are the two exceptions: they are `configurable: false` and always resolve for an active owner, so an override can never remove the last administration path or hand owner/manager account control to another role. See [the roles and permissions reference](../reference/roles-and-permissions.md) for the editor itself, the full permission catalog rendered as a matrix, and the owner-only management API. ## JWT lifecycle **Secret.** `getJWTSecret()` in [`main/security/jwt-secret.ts`](../../main/security/jwt-secret.ts) reads `process.env.JWT_SECRET` when set and otherwise reads the secret from Electron `safeStorage` (the encrypted `jwt-secret.enc` file in the user-data directory) in packaged builds, generating one on first launch. The `jwt_secret` settings row then holds only the marker `safe-storage:v1`; a packaged install whose row still holds a plaintext secret is migrated on its first read. Where OS encryption is unavailable, in unpackaged development runs, and in the Electron-less dev server, the secret stays in the `jwt_secret` settings row so development runs sharing `flo.db` remain compatible. If an unpackaged development run encounters the `safe-storage:v1` marker, it recovers the secret from development secure storage back into SQLite so `node dev-server.js` continues to function. If the secure file becomes unreadable, a new secret is generated and existing sessions end. The value is cached in a module-level variable, so each install has its own secret and a token from one install does not validate against another. That module owns the only copy of the cache; `clearJWTSecretCache()` in the same module invalidates it, and a restore that restored a rotated secret must go through it or pre-rotation tokens would keep validating. **Issuance.** `jwt.sign` runs across all login, refresh, setup, and self-service credential-change paths (`POST /api/auth/login`, `POST /api/auth/pin-login`, `POST /api/auth/tenants/select`, `POST /api/auth/refresh`, `POST /api/auth/password/change`, `POST /api/auth/pin/change`, and `POST /api/auth/setup/initialize` on `:3001`, plus `POST /api/auth/login`, `POST /api/auth/pin-login`, `POST /api/auth/password/change`, and `POST /api/auth/pin/change` on `:3002` KDS and `:3003` Server App, and `PUT /api/staff/:id` when an administrator updates their own credentials) using `nextTokenIatSeconds()`, which stamps a monotonically advancing sub-second `iat` claim, while `exp` is computed explicitly from wall-clock `Date.now()` (`Math.floor(Date.now() / 1000) + ttlSeconds`) so clock corrections do not extend session lifetime. A token without the remember option expires in 24 hours of wall-clock time; with it, in 10 days. Self-service password and PIN changes advance `tokens_valid_after` first and then mint a fresh replacement token preserving the caller's session claims, keeping the caller's current session authenticated while invalidating every token issued before the change. **Verification.** `requireAuth` in [`main/server.ts`](../../main/server.ts) guards paths under `/api`. It passes through `/api/health`, everything under `/api/auth` (which verifies its own tokens), product image GETs, and a fixed list of pre-login support-ticket paths. Those pre-login paths are matched by exact path and by a UUID pattern rather than by prefix, so a look-alike path cannot skip authentication. A request that presents a bearer token is rejected unless all of the following hold: 1. `isTokenRevoked(token)` is false. 2. `jwt.verify` succeeds against the install secret. 3. `getUserAuthStatus` reports an existing, active user. 4. `isTokenStale(decoded.iat, tokensValidAfter)` is false. **Revocation.** `revokeToken` records the SHA-256 hash of the token in the `revoked_tokens` table with the token's expiry, so revocation stops mattering on its own. A `Set` of raw tokens in memory is the fast path, bounded at 5,000 entries. `isTokenRevoked` fails closed: if the database lookup throws, the token is rejected rather than accepted. **Staleness.** `tokens_valid_after` on the `users` row is written via `nextTokensValidAfterTimestamp()` as a monotonically advancing UTC millisecond timestamp (`YYYY-MM-DD HH:MM:SS.SSS`) using the same shared monotonic clock (`nextAuthTimestampMs()`) as `nextTokenIatSeconds()`, where the last logical timestamp (even across backward system clock jumps), the maximum persisted `users.tokens_valid_after` cutoff, and the persisted `auth_clock_high_water_ms` setting in SQLite (persisted across process restarts, redacted from database exports, preserved locally across structured imports, and reset on SQLite backup restores) serve as lower bounds that the next value is strictly greater than. Password changes, PIN changes, owner recovery, account deactivations, and backup restores bump this column, invalidating every token issued before the update without needing to enumerate them. `isTokenStale` compares sub-second `iat` claims against `tokens_valid_after` with `<=` at millisecond resolution so pre-change tokens are invalidated and fresh post-change logins succeed even within the same UTC second or millisecond; legacy integer-second `iat` claims (and whole-second `tokens_valid_after` strings) are compared at second resolution with `<=`. **Role freshness.** The role attached to the request is the value read from the database, not the role claim inside the token, so a role change takes effect on the next request. `getUserAuthStatus` caches active status, role, and `tokens_valid_after` for 30 seconds to bound how long a deactivated or demoted user's tokens keep working. KDS, kitchen, and order-item requests pass `fresh: true` and bypass that cache, because a stage transition that a demoted chef can still perform is a real security gap. ## The shape of the authorization surface There is no single authorization layer to read. Authorization is expressed as roughly 245 `requirePermission(`/`requireAnyPermission(` call sites across `main/routes/`, plus a small, reviewed set of direct role checks left in place as context policy (see [Configurable permissions](#configurable-permissions)). A gate is not the only way access is checked. This section exists because the shape matters when you change an endpoint, not because the count does — unlike the fixed-role predecessor, the count moves in both directions as an owner-configurable feature area is added or a formerly-fixed check gets its own permission id. **Router-level gates.** The common case. `requirePermission(id)` is a factory that returns Express middleware and must follow `requireAuth`. It answers 401 when there is no authenticated user and 403 when the resolved effective permission set does not include `id`. **Inline gates on the app object.** Seven endpoints are registered directly on `app` rather than on a router, in [`main/routes/index.ts`](../../main/routes/index.ts). A static search for `router.` does not find them. **Gates inside transaction bodies.** The most important case. Some endpoints perform the permission check inside the `withTxn` callback, after the transaction opens, using `hasPermission(actorId, id)` against state read in the same transaction. `PATCH /api/orders/:id/status`, `PATCH /api/orders/:orderId/items/:itemId/cancel`, the matching `.../restore` endpoint in `main/routes/orders.ts`, and the bill payment kitchen-delivery override and charge waiver override in `main/routes/bills.ts` do this, and their override-PIN paths (along with paid-bill payment-method correction on `PATCH /api/bills/:id/payments/:index/method`, which also requires the authenticated caller itself to satisfy `hasRole(role, ROLE_ACCESS.ownerManager)` and hold `payments.take`) additionally require the presenting user to satisfy `hasRole(role, ROLE_ACCESS.ownerManager)` - a deliberate context-policy role check, not a permission, because the Staff Approval PIN belongs to a person acting in an owner/manager capacity regardless of what their individual permission grants say. Even though any staff role (`owner`, `manager`, `cashier`, `server`, `chef`) may hold a 4-6 digit `pin_hash` for quick user switching, candidate queries and in-transaction re-validations filter for active `owner` and `manager` accounts so a `cashier`, `server`, or `chef` PIN can never satisfy an approval check. To prevent event loop starvation and transaction lock contention during CPU-intensive bcrypt evaluations, Staff Approval PINs are verified asynchronously via `verifyPinAsync` outside the transaction, serialized per client IP and action key with `withPinVerificationLock` from [`main/middleware/security.ts`](../../main/middleware/security.ts) (re-checking client-IP rate limits inside the lock before executing bcrypt). Candidate filtering during pre-verification enforces the role and permissions required by the transition (including `payments.take` when completion is blocked by unsettled payment), ensuring fallback PIN scanning only matches eligible approvers. The pre-verified approver is then atomically re-validated inside `withTxn` against current SQLite state (active status, owner/manager role, required permissions, and matching `pin_hash`). The check cannot be hoisted into middleware without changing the read the decision is based on, and it is invisible to anything that inspects the route's middleware chain. **KDS WebSocket authorization.** The KDS socket authenticates with the same bearer token but authorizes per message. See below. ### Three separate token-verification middlewares `isTokenRevoked`, `isTokenStale`, `getJWTSecret`, `rateLimit`, `authRateLimit`, `loginAuthRateLimit`, and `staticRouteRateLimit` are exported from one module and imported by all three servers, so the primitives are shared. The _middleware body_ that sequences them is not: each server writes its own. | Server | Middleware | Shape | | ------- | --------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | | `:3001` | `requireAuth` in [`main/server.ts`](../../main/server.ts) | Guards `/api`, with an unauthenticated-path allowlist, then revocation, verify, cached user lookup, staleness. | | `:3002` | `requireAuth` closure inside the server factory in [`main/kds-server.ts`](../../main/kds-server.ts) | Revocation, verify, uncached user lookup, staleness, then `hasPermission(user.id, 'kitchen.use')` and station resolution. | | `:3003` | `requireServerAppAuth` in [`main/server-app.ts`](../../main/server-app.ts) | 404-when-disabled, then revocation, verify, uncached user lookup, staleness, then `hasPermission(user.id, 'server-app.use')`. | The three already differ in one observable way. `:3001` reads user active status, role, and `tokens_valid_after` through `getUserAuthStatus`, which caches for 30 seconds. `:3002` and `:3003` query the `users` row on every request and do not cache. That divergence is a correctness risk, not a style preference. A change to the order of the checks, to the status codes, or to the role gate has to be made in three places, and the three are free to drift apart without any test failing. Treat them as a set. Note also that `:3002` re-checks `is_active`, revocation, and staleness a second time inside its status-update transaction (`PATCH /api/kds/items/:id/status` in `main/kds-server.ts`), which is a defence in depth rather than a duplicate bug. ### Shared login attempt rate limiting across servers All three HTTP listeners (`:3001` Main API, `:3002` Standalone KDS, and `:3003` Server App) share an install-wide login attempt rate limiter exported from [`main/middleware/security.ts`](../../main/middleware/security.ts): 1. **Volume rate limiter (`loginAuthRateLimit`):** Used on all three default login endpoints, `loginAuthRateLimit()` reuses a shared singleton instance so total authentication request volume across listeners shares a single budget (default 10 requests per 15 minutes, configurable via `FLO_AUTH_RATE_LIMIT_MAX`). Non-login authentication routes (e.g. staff management, password and PIN changes) use independent rate-limiter instances. 2. **Shared failed-login lockout (`checkLoginRateLimit` / `incrementFailedLogin`):** Keyed to the canonical client IP (`normalizeIpForRateLimit`, which lowers case, strips `::ffff:` IPv4-mapped IPv6 prefixes, and canonicalizes IPv6 loopback `::1` and `0:0:0:0:0:0:0:1` to `127.0.0.1` so IPv4 and IPv6 representations share the exact same bucket). Five failed credential attempts across any combination of the three listeners triggers a 15-minute lockout (HTTP `429`). During the lockout, any further login attempt on any listener is rejected before password evaluation. 3. **Reset on success (`resetSuccessfulLogin`):** A successful login from any listener immediately clears the failed-attempt count for that client IP once role and permission checks pass. 4. **Role and permission decoupling:** Non-credential rejections (such as HTTP `403` when valid credentials lack `kitchen.use` on `:3002` or `server-app.use` on `:3003`) do not count as credential failures and do not clear previously accumulated failed-login attempts. ### PIN quick user switch and terminal lock Any active user in any staff role (`owner`, `manager`, `cashier`, `server`, `chef`) may set or change their own 4-6 digit PIN via `POST /api/auth/pin/change` (verifying `current_pin` when a PIN is already set, or `current_password` when setting a PIN for the first time), and owners (or managers granted `staff.credentials.reset` for operational staff) may set, reset, or clear a 4-6 digit PIN (`users.pin_hash`) in Staff management. That PIN serves as the user's sign-in PIN on shared terminals, while approval authority stays restricted to active `owner` and `manager` accounts: 1. **Quick-switch tile directory (`GET /api/auth/pin-users`):** Served on `:3001`, `:3002`, and `:3003`. Returns active users who have a `pin_hash` set, exposing only `{ id, name, role }`. On `:3001` with `?scope=kds` and on `:3002`, the list is narrowed to accounts holding `kitchen.use` with at least one active kitchen station when station assignments are configured (`:3001` returns `{ users: [] }` and `:3002` returns `404` when KDS is disabled); on `:3003`, it is narrowed to accounts holding `server-app.use` (`404` when Server App is disabled). Users without a PIN are omitted from the picker and sign in with email and password. When PIN-eligible users exist, the sign-in screens on POS (`/auth/login`), KDS (`/kds` and `/kds-standalone`), and Server App (`/server-standalone`) show a segmented toggle between **Quick Sign-In** (default) and password sign-in. 2. **Selected-user PIN sign-in (`POST /api/auth/pin-login`):** Served on `:3001`, `:3002`, and `:3003`. Accepts `{ user_id, pin, rememberMe }` (or `remember_me` on `:3002` and `:3003`) and verifies the PIN strictly against the selected `user_id` via `verifyPinAsync` without scanning other users, so two staff members may share the same PIN. On `:3002` and `:3003`, it also enforces the surface's permission and station gates (`kitchen.use` and active station check on `:3002`; `server-app.use` on `:3003`) before resetting the failed-attempt counter or issuing a token. Concurrent bcrypt verifications for a target account remain serialized per user (`pin-user:${userId}`) with `withPinVerificationLock`, while the 5-attempt / 15-minute failed-attempt lockout is scoped per normalized client IP and user ID (`${normalizeIpForPinRateLimit(ip)}:pin:${userId}`, collapsing non-loopback IPv6 addresses to their canonical `/64` prefix while keeping individual IPv4 addresses and canonicalized loopback `127.0.0.1` / `::1` in their own buckets without an authentication exemption), alongside the endpoint volume limiter. Scoping lockouts per `(client IP, user ID)` rather than install-wide per account protects main POS terminal availability when another device on a shared restaurant LAN submits wrong PINs, at the explicit trade-off of reduced resistance to PIN guessing distributed across multiple LAN devices. Every attempt is recorded in `auth_audit_log` (`event_type = 'pin_login'`, `outcome` in `'success'`, `'failure'`, or `'rate_limited'`). 3. **Terminal lock and session boundary:** Only one user is signed in on a device at a time. The manual **Lock** action in the POS topbar/sidebar (and the optional `auto_lock_minutes` idle setting in **Settings > POS**, off by default with choices `1`, `5`, `15`, or `30` minutes, readable by all authenticated staff independently of feature permissions) revokes the active token without closing the open cash shift. Unfinished POS cart items remain on the terminal across lock and user switch, while privileged cart adjustments (line price overrides and charge waivers), cached override PINs, and open privileged modals are cleared. Pending asynchronous checkout continuations are guarded by session epoch and originating tokens so a user switch halts downstream billing and payment transitions under the new session, clears any untouched matching cart to prevent duplicate ordering, and preserves committed-order recovery state under the originating user's attempt record. 4. **Approval boundary:** `GET /api/staff/approvers` and every Staff Approval PIN check (refunds, item voids, order/item/bill discounts, order-status overrides, kitchen-delivery overrides, charge waivers, and paid-bill payment-method corrections) enforce active `owner` or `manager` role eligibility both outside and inside write transactions. A valid `cashier`, `server`, or `chef` PIN is rejected with `403` on every approval path. ## Password policy A password must be at least 4 characters long. There are no uppercase, lowercase, digit, or symbol requirements. The rule lives only in [`shared/password-policy.ts`](../../shared/password-policy.ts); the backend (`validatePassword` re-exported from `main/middleware/security.ts`) and every frontend form that sets a password (first-run setup, self-service password change, staff create and edit, staff password reset, recovery) import it, and the `password-policy` test fails if a source file re-implements a password rule. Every route that writes a password enforces it: setup, staff create and update, password change, and both recovery paths. The 4-digit Master PIN and 4-6 digit staff PINs are separate secrets with their own rules. ## Owner password recovery A locked-out owner has two recovery paths, both local-only requests (loopback) with no JWT, and both ending in the same write (`applyOwnerRecovery` in [`main/routes/auth.ts`](../../main/routes/auth.ts)): the new bcrypt hash, a bumped `tokens_valid_after`, and the `last_password_recovery_*` audit settings. - **Master PIN:** `POST /api/auth/recover-password` with the owner email, the Master PIN, and the new password. Works offline. - **FloAdmin email approval:** available when cloud account services are on, the cloud server URL is the official FloAdmin endpoint in both SQLite and active runtime state, and an active owner account matches the registered owner email (`cloudSync.isEmailRecoveryAvailable()`, advertised as `cloudRecoveryAvailable` by `GET /api/auth/setup/status`). Updating an active owner's email in staff management keeps `settings.email` in sync. Structured database imports preserve and resync `settings.email` from the active owner account; full backup restore is authoritative and instead applies the backed-up owner and settings state. If FloAdmin has not verified that email, the request first sends the verification email (`step: 'verify_email'`) and the recover page polls `GET /api/auth/recover-password/cloud/verification`; once verified it requests the reset automatically. FloAdmin then emails the registered owner a link, the POS polls the approval status, and after approval the owner chooses the new password on the POS. `POST /api/auth/recover-password/cloud/complete` resolves the active owner whose email matches the registered cloud email, redeems the single-use grant with FloAdmin, and only then writes the password. The password is never sent to FloAdmin. Recovery requests are pinned to the trusted FloAdmin server URL and signed with the install's `api_key` like every other `/api/pos/*` call. If the owner email matches no active owner, nothing is redeemed. When no active administrator remains, the Master PIN path restores owner access to the matched active account; the FloAdmin email path always requires an active owner matching the registered email and never promotes a non-owner account. ## Master PIN as a second factor The master PIN is a 4-digit PIN, separate from a staff password, used to gate destructive operations. It is enforced by `requireMasterPin` in [`main/middleware/master-pin.ts`](../../main/middleware/master-pin.ts), which delegates the decision to `authorizeMasterPin` in [`main/services/master-pin.ts`](../../main/services/master-pin.ts). `authorizeMasterPin` is a fail-closed ladder, in order: | Condition | Result | | ------------------------------------------------- | ------------------------------------------------------------------- | | OS-backed encryption unavailable | 503, and the gated operation is blocked rather than allowed through | | PIN not set on this device | 409 | | Rate limit reached for this key | 429 | | PIN absent, not a string, or not exactly 4 digits | 403 | | PIN does not verify | 403, and the attempt is recorded; 429 once the limit is reached | The PIN hash is stored through Electron `safeStorage` in `master-pin.enc` under the app's `userData` directory, written with mode `0o600`. Rate limiting is in memory, at 5 attempts per 15 minutes per key. For HTTP routes (`requireMasterPin`) and local password recovery (`POST /api/auth/recover-password`), the rate-limit key is `master-pin:${normalizeIpForRateLimit(ip)}`, so all HTTP Master PIN endpoints, parameterized routes (such as `POST /api/db-tools/backups/:fileName/delete`), trailing slashes, path casing, and equivalent IP representations (`::1`, `127.0.0.1`, `::ffff:127.0.0.1`) share a single 5-attempt budget per normalized client IP. `requireMasterPin` reads `master_pin` from the request body first and falls back to the `x-master-pin` request header (used by bodyless requests such as `GET /api/db/download`); it never reads URL query parameters. Electron IPC handlers pass channel-scoped `ipc:*` keys to `authorizeMasterPin`. ## KDS station and category narrowing KDS access requires `hasPermission(userId, 'kitchen.use')` (shipped to owner, manager, chef, and independently configurable) and is then narrowed twice more, in [`main/services/kds.ts`](../../main/services/kds.ts). **Category narrowing.** Whether the caller is unrestricted is decided by `hasRole(role, ROLE_ACCESS.ownerManager)` - a deliberate role check, not a permission, because category scope is about which identity the KDS trusts to see the whole kitchen, independent of whatever `kitchen.use` an owner may have granted to some other role. Owner and manager are unrestricted; a chef is narrowed to the categories (and child subcategories) resolved from `users.category_ids` (see [KDS category and station scoping](../reference/api.md#kds-category-and-station-scoping)). **Station narrowing.** The user's assigned stations come from `getUserKdsStationIds`. If station assignments are configured for the user and the resolved list is empty, authentication fails rather than granting unrestricted access. An exception is thrown if the station lookup itself returns `null`, so a failed query is an error, not a grant. **The gate is re-read inside the transaction.** The station and category decision for a status update is made inside a `withTxn` callback (on item-level KDS endpoints and across all `PATCH /api/orders/:id/status` transitions), after resolving the order's `kitchen_station_id` and calling `getKdsStationRoutingScope` and `isKdsStationItemAllowed`. Re-reading inside the transaction is what makes the decision correct against the data being written. Do not cache the station scope across the transaction boundary or move this check above it. ## Server App authorization The standalone Server App on `:3003` is a filtered proxy. It is gated twice: `hasPermission(user.id, 'server-app.use')`, shipped by default to server, manager, and owner and independently configurable, and when the feature is disabled every route returns 404 rather than 403, so a disabled surface is not discoverable. The 404 check appears in three places in `main/server-app.ts`, including the one that decides whether a path is proxied at all. ## Audit attribution Every write records the authenticated actor. `order_audit_log` stores `actor_user_id`, `action`, and a details payload per order or order item, indexed on both the order and the actor. Stock movements record the same attribution: `applyStockChange` and `applySupplyStockChange` both require an `actorUserId` and throw rather than write a movement without one. This is the answer to "who did this", and it is why orders are not hidden between staff. The authorization model restricts by permission and, for kitchen work, by station and stage. It does not restrict by who created a record. See [the product invariants reference](../reference/product-invariants.md). ## Fail-closed inventory behaviour Two inventory services write append-only movement ledgers, and both refuse to write a row without attribution: - `adjustProductStock` in `main/services/inventory.ts` throws a 400 `InventoryServiceError` when `actorUserId` is missing, before any `UPDATE`. - `applySupplyStockChange` in `main/services/supplies.ts` throws a 400 `SupplyServiceError` when `actorUserId` is missing, and resolves the resulting stock level inside the caller's transaction. Recipe depletion uses a snapshot rather than a live recipe. `buildRecipeSnapshot` captures the components scaled to the ordered quantity, and `applyRecipeSnapshot` applies the deltas from that snapshot. A recipe edited after an order was placed does not change what that order consumes. `parseRecipeSnapshot` returns `null` for anything it cannot parse, so a corrupt snapshot skips depletion instead of guessing at quantities. ## Boundaries we do not harden These are deliberate product decisions, not gaps that were overlooked. Each one is a place where FloCafe relies on the deployment environment rather than on the application. **LAN traffic is unencrypted.** The API server binds `0.0.0.0` and speaks HTTP and unencrypted WebSocket. The CORS origin check restricts which browser origins may call the API, and CORS is not transport security: it does not stop an attacker on the same network from reading a bearer token from the wire. The deployment rule is to treat the LAN as trusted. Do not expose the API or KDS port to a guest or shared network. **The GPU process sandbox is relaxed on Windows.** The Windows build appends Chromium's `disable-gpu-sandbox` switch as a compatibility workaround. The main window itself runs with `sandbox: true`, `contextIsolation: true`, and `nodeIntegration: false`; its preload uses only `electron` and web APIs. The CSP, the external-window URL allowlist, and in-window navigation confinement add to that isolation. **The WhatsApp session is not encrypted at rest.** `main/services/whatsapp.ts` creates the credential directory with mode `0o700`, so another OS user cannot read it, but the session files themselves are not encrypted with the OS keychain. Malware or another process running as the same user can copy the linked session. **Inline script is allowed by the Content Security Policy.** `main/csp.ts` emits `script-src 'self' 'unsafe-inline'`. Remote script and `eval` are blocked; inline script is not. `connect-src` is widened to the request's own origin when the `Host` header matches a safe pattern, so that LAN devices can connect. The policy is pinned by `tests/csp-lan-header.test.ts`. **Rate limiting is asymmetric.** The general API limiter in `main/middleware/security.ts` bypasses requests from private, loopback, and Tailscale addresses, because a busy in-store POS and a kitchen display share one network. The authentication limiter deliberately does not: it sets `bypassPrivateIp: false`, so login attempts from a LAN address are limited like any other. A rate limit that exempted the LAN would exempt exactly the attacker standing next to the till.