Ctrl K
ComunidadGuías…

docs/architecture/authentication-and-authorization.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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.<verb>` 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.