# The frontend The renderer is the Next.js application in [`frontend/`](../../frontend). This page describes what that code is allowed to do: the boundary a new screen sits behind, how it decides what a user may see, what it may ask the host for, and where its state, its printing, and its messages live. Three subjects it does not own are documented elsewhere. The build-time fork, the IPC handler inventory, the title bar, and the Content Security Policy belong to [Desktop build and window surfaces](desktop-build.md). The permission catalog, its resolution order, and the in-app editor belong to [Roles and permissions](../reference/roles-and-permissions.md). The backend enforcement surface belongs to [Authentication and authorization](authentication-and-authorization.md). The print kernel, its renderers, and its transports belong to [Printing architecture](printing.md), and the message model belongs to [Internationalization](internationalization.md). This page covers the renderer side of each and links out for the rest. ## What the renderer is `frontend/src` is a client-only application. There is no server-side execution of any kind in the build the application ships. ### The static-export boundary [`frontend/next.config.ts`](../../frontend/next.config.ts) reads `NEXT_BUILD_MODE`. The root `build:frontend` script sets it to `desktop`, which switches `output` to `'export'`, sets `trailingSlash` to true, and sets `images.unoptimized` to true. The result is static HTML, CSS, and JavaScript in `frontend/out/`, served as files by three local servers: | Server | Default port | Serves | | ------------------------------------------------ | ---------------------------- | ------------------------------------------- | | [`main/server.ts`](../../main/server.ts) | `PORT`, else 3001 | the API under `/api` and the exported pages | | [`main/kds-server.ts`](../../main/kds-server.ts) | `KDS_PORT`, else 3002 | the standalone KDS | | [`main/server-app.ts`](../../main/server-app.ts) | `SERVER_APP_PORT`, else 3003 | the standalone Server App | Those are the base ports, not the addresses to code against. Each server resolves its port at bind time, and on `EADDRINUSE` or `EACCES` it increments and retries rather than failing. **Derive a client target from what the page was served from, never from a literal port.** The shared `api` client already does this with `` `${window.location.origin}/api` ``, the KDS socket derives its host from that `baseURL`, and a client that needs the KDS or Server App port asks for it: `get-kds-info` over IPC returns the bound KDS port and the machine's LAN address. See [the port retry ladder](../architecture/overview.md#the-port-retry-ladder) for the accessors and the ten-attempt bound. A page is a file, not a request that runs code. The export has no Next.js server runtime behind it, so there are no route handlers, no server actions, no middleware, and no server cookies in the shipped build, and a file that uses them compiles without complaint and is dead in every build that ships. [What does not exist in the desktop build](desktop-build.md#what-does-not-exist-in-the-desktop-build) enumerates the absences and why the build stays quiet about them. **A new screen cannot call the backend the way a web app would.** There are three routes out of the renderer: 1. **HTTP**, through the axios client in [`frontend/src/lib/api.ts`](../../frontend/src/lib/api.ts). 2. **WebSocket**, on the same base ports, for live updates. Two screens open one today, through two different clients; see [the KDS live feed](#the-kds-live-feed-is-a-websocket-not-a-request). 3. **Native capability**, through the `window.electronAPI` bridge described in [the interface contract](#the-interface-contract). Anything that needs a database row, an authorization decision, a tax calculation, or a print job is an Express route on the backend, not renderer code. See [Backend-authoritative security and tax](../decisions/0002-backend-authoritative-security-and-tax.md). The KDS window and the Server App page load without a preload bridge, so on those two surfaces the native route does not exist at all. ### The HTTP client `api` is the axios instance the authenticated pages share, and the default way to reach the API. - `baseURL` is `` `${window.location.origin}/api` ``, resolved at module load, so a page served from a LAN host reaches that host's API rather than a hard-coded address. - The request interceptor attaches `Bearer ` from `localStorage.token` when a token exists. - A `401` clears the token and hard-navigates to `/auth/login`, except on `/kds`, where the KDS view renders its own inline login, on the login page itself, where the login form reports the error, and when the request passes `skipAuthRedirect: true` (used by `loadFromStorage`'s startup `GET /auth/me` probe so [`AuthGuard`](../../frontend/src/components/layout/AuthGuard.tsx) routes cleanly after clearing the expired session). - A `403` carrying `{ code: 'permission_denied' }` dispatches a `flo:authorization-denied` window event. [`AuthGuard`](../../frontend/src/components/layout/AuthGuard.tsx) listens for it and calls `refreshAuthContext`, so an owner tightening a permission takes effect on the next request instead of at the next sign-in. Four things do not use it, and a new screen has to notice which kind it is: | Path | How it reaches the API | | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [`kds-standalone/page.tsx`](../../frontend/src/app/kds-standalone/page.tsx) | builds its own axios instance on its own origin, because it runs on the `:3002` base port rather than the POS window's `:3001`, and on a `401` clears the token and reloads instead of redirecting | | [`server-standalone/page.tsx`](../../frontend/src/app/server-standalone/page.tsx) | the same, against the filtered `:3003` proxy | | [`i18n/server-language.ts`](../../frontend/src/lib/i18n/server-language.ts) | bare `fetch`, because it reads the tenant's language and theme defaults before a session exists | | the KDS live feed | a WebSocket, described below | The other `axios` imports in the renderer are `axios.isAxiosError` for error shape, not a second client. ### Durable manual item-edit recovery is re-sent only by staff action The Orders pages retry an item edit whose browser response was lost, but never automatically. The saved record lives in `localStorage` under one key per order item, [`flo.orders.item-edit.attempt.v1::::`](../../frontend/src/lib/order-item-edit-attempt.ts). Enumeration ([`listOrderItemEditAttempts`](../../frontend/src/lib/order-item-edit-attempt.ts)) reads only keys in that exact tenant/actor/order prefix and validates the snapshot it finds there: the key's item suffix, the stored tenant/actor/order/item IDs, the declared edit mode, and a body operation matching that mode. The stored `path` must equal the canonical `PATCH /orders/:orderId/items/:itemId/edit` path derived from those IDs, so a tampered record cannot redirect the replay at an arbitrary URL. Malformed or mismatched records inside the current scope are left intact, counted as unreadable, and never sent or cleared; they produce a translated storage warning. Records in other scopes are ignored. Mounted order surfaces lazily run that enumeration and render one shared [`PendingOrderItemEdits`](../../frontend/src/components/orders/PendingOrderItemEdits.tsx) notice above their item lists (both the card and the detail layouts, outside the active/inactive line filter, so history-hidden superseded lines still surface their unresolved attempt). The notice renders for saved records, storage warnings, or outstanding resolution notices. The browser sends nothing until staff click _Resolve pending edit_. Resolving re-reads the record under the attempt's Web Locks critical section, verifies the re-read still matches the offered operation, then replays the stored path, frozen body, and idempotency key through the axios client. Because the idempotency key is reused, a replay of an edit that already committed is a no-op refresh, not a second mutation. Clearing is compare-and-clear: a 2xx for the saved request, or a definitive current-route rejection (400/404/409/422), clears the record only if storage still holds the same idempotency key. Transport timeouts and 5xx, authentication/permission/rate-limit responses (401/403/429), and failed local clears all preserve the record and surface an inline message (sign-in for 401, rate limit for 429, the generic hint plus any server message otherwise): the 403/429 classes cannot establish whether an earlier request committed, so no automatic retry timer runs for them. A confirmed edit whose order refresh failed is reported as a refresh failure, not an edit failure, and the cleared attempt is not re-created. The Orders page owns the data result: after a resolved edit it refetches the current order and the already-loaded list pages through its existing fetch/refresh routines. A failed detail refresh after either confirmation or definitive rejection leaves a visible refresh warning with a retry action; only the confirmed case says the edit was confirmed. Scoped native custom events (`flo.orders.item-edit.attempt.changed`) refresh mounted notices in the current tab after the edit modal persists or resolves an attempt, and the browser `storage` event covers other tabs. An identity change during an item-edit commit closes the stale editor and refreshes the current order or table context without installing the returned order from the previous identity. Recovery notices, inline issues, refresh errors, storage warnings, and in-flight resolution handlers are scoped to the selected `(tenantId, actorId, orderId)` so switching orders or identities clears transient notices and ignores late-settling responses from another order. ### Split-check availability is a state, not a hidden button The POS payment modal consolidates equal-amount shares and item-based check splitting into a unified Split card above the payment action, rather than splitting controls across separate panels. It derives item-based split-check availability through explicit states rather than a boolean that silently hid the control. The feature setting (`split_checks_enabled`) lookup reports loading, ready, and error states: while loading the item-split panel stays hidden, on failure it displays an inline error with a retry action, and a `settings.view` denial explains the account cannot check availability. When enabled, a two-tab mode switcher toggles between item-based check splitting and equal-amount shares. A successfully read `false` removes the item-split mode switcher and controls, leaving only the equal-amount share calculator when a balance remains. Both equal-amount sharing and [`SplitCheckModal`](../../frontend/src/components/pos/SplitCheckModal.tsx) provide `-` / `+` steppers for numeric values. Equal-share payer counts also have quick presets (`2`, `3`, `4`). In table mode, check allocators rebalance quantities across checks or reset evenly in a single tap without over-allocating. Eligibility resolves in a fixed order so staff always see the same reason for the same bill: billing permissions, an unresolved payment or split in progress, anything other than an untouched unpaid bill, a non-dine-in order type, fractional item quantities, fewer than two whole eligible units, and finally an incomplete bill/order projection, which is reported as an availability unknown with a check action, never as "cannot split". Definitive reasons render as visible text beside the disabled action, so no reason depends on hovering. Opening the allocator is guarded, not assumed. The modal re-fetches the bill (`GET /bills/:id`, with the existing whole-order fallback only for an as-yet-unsplit bill) and re-evaluates payment status, permissions, order type, and units from the fresh result before offering the split, passing the refreshed bill and its matching order projection to [`SplitCheckModal`](../../frontend/src/components/pos/SplitCheckModal.tsx). For a split-group child the child's own projection is authoritative; a paid sibling never leaks into the editor. Stale or racing lookups are ignored by lookup id, rapid clicks open at most one allocator, and while the explicit split-opening request is in flight competing payment and split mutations are disabled until it finally releases. ### Add-on groups have one editor Add-on groups are managed only in Products, on the tab selected by `/products?tab=addons`. The Products page reads `tab` (`products`, `categories`, or `addons`; anything else selects Products) inside a Suspense boundary and writes tab clicks back with `router.replace`, keeping other query parameters. The tab is shown to restaurants and to any business whose role grants `catalog.manage`. The legacy `/addon-groups` route is kept for bookmarks and the static export; it makes no API call and client-redirects to `/products?tab=addons` with `router.replace`. The group editor echoes each saved add-on's `tax_category_id`, `tax_behavior`, and `inherit_parent_tax_category` back on save, and sends `stock_quantity` only when the stock field was edited, so an ordinary save cannot overwrite stock moved since the editor opened. ### Charge identity is chosen, not typed [`ChargesSettingsCard`](../../frontend/src/components/settings/ChargesSettingsCard.tsx) offers Service, Packaging, and Custom kinds for a new charge. Service and Packaging use the reserved IDs `service_charge` and `packaging_charge` (which feed the standard report columns); if that ID already exists, choosing the kind opens the saved definition instead of applying defaults. Custom charges get an opaque `charge-<32 hex>` ID from `crypto.getRandomValues`, generated once per draft. IDs are never derived from names or editable, so renaming a charge keeps its identity. Choosing a kind writes nothing; only Save persists. Editing an existing charge reloads definitions before building the replacement list. An edit therefore does not recreate a charge already deleted on the server or replace other definitions from its old snapshot. A failed reload retains the draft and blocks saving until Retry succeeds. Creation retries reuse the draft's stable ID, including after a committed attempt's response is lost. ### The KDS live feed is a WebSocket, not a request Two surfaces open a socket to the same `/kds` path. The base ports for that path are `:3001` and `:3002`, and the retry ladder can change either bound port, so neither client may name a literal. Both derive the host from the origin they were served from. The two clients are not interchangeable. [`useKdsConnection`](../../frontend/src/hooks/useKdsConnection.ts) is the streaming one. The KDS views use it. It derives the host from the axios `baseURL` so a dev proxy reaches the right backend and falls back to the page origin, and it carries the order list in the frames themselves. Authentication is a message, not a header: the client sends `{ type: 'auth', token }` after `open` and must be authenticated within 5 seconds, or the server sends `auth_error` and closes with code `1008`. A socket that is refused, times out, or drops does not become a dead screen: the hook falls back to REST polling on a 5-second interval and reports which mode it is in through `connectionMode`. Reconnect uses exponential backoff from 1 to 30 seconds. The orders screen writes its own client inside the page effect instead. It connects to `window.location.host`, sends the same `auth` message, and uses the socket only as a signal: on `order_updated`, `orders`, or `initial_data` it refetches over HTTP rather than consuming the frame. It reconnects on a flat 3-second timer, not the hook's backoff, and a 10-second HTTP poll runs alongside it so a socket that never opens still refreshes the list. Which REST route a poll hits depends on the surface, because the hook takes its endpoints as a parameter. The in-dashboard KDS polls `GET /api/kitchen/orders`; `/kds-standalone` passes `orders: '/api/kds/orders'` and the rest of that base port's REST surface to the same hook. The frames, the handshake, and the server-side conditions live in [the API reference](../reference/api.md#kds-websocket-kds). ## The permission model ### Two catalogs, and which one you are looking at The codebase holds two different tables about who may do what. Confusing them is the main way a permission change goes to the wrong place. | | `shared/role-permissions.ts` | `shared/permissions.ts` | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | | Holds | the five role identities in `ROLE_DEFINITIONS`, the named role groups in `ROLE_ACCESS`, and the capability table `PERMISSION_CAPABILITIES` | `PERMISSION_DEFINITIONS`, one entry per runtime permission id, with its area, shipped default roles, `configurable` flag, and risk tier | | Owner-editable | no, the identity list is a constant in the module | the grants are editable; the ids are not, because they are persistence keys | | Who reads it | backend route middleware imports `ROLE_ACCESS` and `hasRole` | the backend resolves the effective set from SQLite on every protected action | What an owner can change is the grants, not the shape. From **Staff > Role permissions** the owner edits role defaults and per-user exceptions. Two entries in `PERMISSION_DEFINITIONS` carry `configurable: false` and are protected: `authorization.manage`, which decides who can reach that editor at all, and `staff.privileged.manage`, which decides who can modify an owner or manager account. Both always resolve to the active owner, with no override able to take them away. See [Resolution model](../reference/roles-and-permissions.md#resolution-model) for the precedence order and [Owner-configurable permissions with fixed context policy](../decisions/0005-owner-configurable-permissions-with-fixed-context-policy.md) for why the split exists. `PERMISSION_CAPABILITIES` is a third, older shape: each row names an `allowedRoles` group rather than being an owner-editable id. The renderer does not read it. Reach for `PERMISSION_DEFINITIONS` and a permission id when writing renderer code. ### The administrative floor and the two refusals Being protected and being administrative are different things. `configurable: false` marks the two permissions that always resolve to the active owner. Separately, the backend refuses a save that would leave no account able to manage staff, permissions, or store settings, using `ADMINISTRATIVE_PERMISSION_IDS` in [`main/services/authorization.ts`](../../main/services/authorization.ts): ```text authorization.manage staff.privileged.manage staff.operational.manage settings.manage ``` `PermissionMatrix` keeps its own copy of that list, mirroring the backend constant, and marks the row when a pending change would take one of the four away from the actor who is making it. It is a warning, not a block: the save goes out, and the server applies two separate guards that need to be told apart. | Guard | Refuses | Status | Can a PIN satisfy it | | ----------------------------------- | --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------ | | `assertAdministrationReachable` | a write that would leave the install with no account that can administer it | 400 `administration_unreachable` | no, nothing rescues a stranded store | | `assertActorKeepsOwnAdministration` | a write that would take any of the four away from the actor making it | 428 `self_privilege_change_requires_factor`, 403 if the factor is wrong, 409 if the actor has no PIN set | yes | The second guard's factor is the **actor's own staff PIN**, verified against `users.pin_hash` from the request body's `override_pin` and rate-limited per client IP. It is not the Master PIN, and the component that collects it is named `MasterPinPrompt` because the same component drives the Master PIN flows. **When another account can still administer the store, a confirmed self-access change succeeds**; the 400 is the case no factor can rescue. The store check runs first, so a stranded install is refused as a 400 naming the remedy rather than as a 428 naming a PIN. `preflightOverrides` in [`main/routes/authorization.ts`](../../main/routes/authorization.ts) runs both before the write, so a refusal stores nothing. ### How the renderer reads a permission [`frontend/src/lib/permissions.ts`](../../frontend/src/lib/permissions.ts) exposes one function: ```ts tenantCan(tenant, permissionId); ``` It returns `false` when there is no tenant. When the tenant payload carries a `permission_ids` array it answers from that; otherwise it falls back to the shipped default for the tenant's role. The array is what makes an owner-configurable grant visible to the renderer without a rebuild. This is a UX gate. It decides what to draw, not what is allowed, and the backend re-resolves live authorization on every protected request. A renderer that shows a control is not evidence that the call behind it will succeed, and a renderer that hides one is not a security control. Route gating lives in `AuthGuard`: - `AuthGuard` and the dashboard layout strip trailing slashes from `usePathname()` (preserving root `/`) before comparing routes. This keeps permission gates for `/staff` and `/settings` and POS/KDS/settings scroll-shell selection consistent with the desktop export's `trailingSlash` setting (see the [build-time fork](desktop-build.md#the-build-time-fork)). - `getLandingPage` returns the first page in a fixed list the tenant can reach. The list is ordered, not role-aware: `pos.use` is checked before `dashboard.view`, and `pos.use` ships to owners, so an owner lands on the POS like anyone else. The list is a fallback order for whichever surfaces are available, not a statement about what a role prefers. When `/auth/login` carries a `?redirect=` query parameter, `resolveSafeRedirect` accepts only a normalized same-origin internal path outside `/auth` and `/setup`; any other value falls back to `getLandingPage`. - `PAGE_PERMISSIONS` maps a guarded route to the single permission id it needs. `/staff` and `/settings` are the two exceptions and carry an any-of list instead, because either screen aggregates several permissions. Adding a route means adding it to one of those three; a route in none of them is behind authentication and nothing else. - `PUBLIC_PATHS` holds the unauthenticated surfaces: `/kds`, `/kds-standalone`, `/server-standalone`, `/customer-display`, `/auth/login`, `/auth/register`, `/auth/recover`, and `/setup`. The standalone KDS, Server App, and paired customer display pages run their own session and skip the shared auth store, so loading it there would clear their token; `/kds` loads the shared store only when both `token` and `tenant` are already in `localStorage`. - Once a user and tenant are resolved, the guard refreshes the auth context on window focus, every 30 seconds, and on `flo:authorization-denied`. The permission editor does not read the catalog out of the bundle. [`PermissionMatrix`](../../frontend/src/components/settings/PermissionMatrix.tsx) fetches `GET /authorization/catalog` and `GET /authorization/roles` and takes the role key list from `ROLE_KEYS`, so the matrix renders the effective state the backend resolves rather than the shipped defaults. ## The interface contract [`main/preload.ts`](../../main/preload.ts) exposes one object, `window.electronAPI`, through `contextBridge`. Its type is `ElectronAPI` in [`frontend/src/types/electron.d.ts`](../../frontend/src/types/electron.d.ts). **The compile-time checks over that boundary are partial, so verify both sides by hand.** [`electron-api-contract.ts`](../../frontend/src/types/electron-api-contract.ts) makes two different kinds of assertion: - `ElectronApiContractChecks` pins twelve exact signatures with `Equal<>`: `dbApplySafeFixes`, `getMasterPinStatus`, `getSettings`, `setSetting`, `setThemeEffective`, `getKdsInfo`, `openKdsWindow`, `openWhatsAppShare`, `getAppInfo`, `getPrinters`, `getStatus`, `onUpdateStatus`. - `ElectronApiMethodPresence` is a `Pick<>`, so the other listed members including `backupDatabase`, `restoreBackup`, `pickRestoreFile`, `dbHealthCheck`, `dbInitialize`, `getUpdateStatus`, `checkForUpdates`, and `restartAndInstall` are checked for **presence only**. On top of that, `preload.ts` is CommonJS and never mentions `ElectronAPI`, so it is not type-bound to the renderer declaration at all. A preload signature change can therefore compile with the renderer still expecting the old shape, and a presence-only member can have its arguments change without anything failing to build. `npm run test:electron-api-contract` is the check to run for the channel list and the absence assertions; argument and return-type changes on a presence-only member need a look at both files. Every privileged channel in [`main/ipc.ts`](../../main/ipc.ts) is registered through the `handle` wrapper, which calls `isTrustedMainWindowSender` before the listener runs and returns `{ error: 'Unauthorized sender' }` to anything else. `isTrustedMainWindowSender` requires the caller to be the active main `BrowserWindow`, its current top-level `mainFrame`, and `http:` on `localhost` or `127.0.0.1` at the active Express server port. That establishes **which window is calling**. To establish **who is signed in** for renderer mutations, privileged IPC channels (`set-setting`, `updates:set-auto-check`, `updates:set-beta-channel`) receive the caller's JWT forwarded from renderer storage via [`main/preload.ts`](../../main/preload.ts) and authorize it through `authorizeRendererPermission` in [`main/security/ipc-auth.ts`](../../main/security/ipc-auth.ts), checking active status, revocation, staleness, and permissions against the database. Registering a privileged channel with a bare `ipcMain.handle` skips the check. All privileged channels in [`main/ipc.ts`](../../main/ipc.ts) go through the `handle` wrapper, and direct update and status handlers in [`main/update-ipc.ts`](../../main/update-ipc.ts) go through `registerDirectUpdateIpcHandlers`, both using `isTrustedMainWindowSender`; any new channel must enforce sender validation. ### What the settings channel may write `set-setting` checks the key against `ALLOWED_IPC_KEYS`, which contains `theme_mode` (accepted values: `light`, `dark`, `system`) and `orders_layout` (accepted values: `split`, `cards`). Writes require `settings.manage` via `authorizeRendererPermission`. Before initial setup creates any users, unauthenticated writes are allowed for `theme_mode` only (`allowPreSetup: true`) so initial theme preference can be applied; once users exist, `settings.manage` is strictly enforced. Desktop update settings written via IPC (`updates:set-auto-check` and `updates:set-beta-channel` in [`main/update-ipc.ts`](../../main/update-ipc.ts)) similarly require sender validation and `settings.manage` authorization. Every other setting is written over a permission-gated HTTP route, where the acting user is known. The read side, `get-settings`, masks `jwt_secret`, `auth_clock_high_water_ms`, `cloud_api_key`, `cloud_device_secret`, `cloud_deletion_status_token`, and `cloud_last_error`, and collapses `cloud_last_error` to a generic string. ### The database channels Five channels do database work, and they are gated two different ways on purpose. | Channel | Gate | | --------------------- | ----------------------------------------------------------------------------------------------------------------- | | `db-health-check` | sender check only; it reports, it does not write | | `db-apply-safe-fixes` | sender check, then `authorizeMasterPin(pin, 'ipc:apply-safe-fixes')`, failing closed on a missing or wrong PIN | | `backup-database` | sender check, then `authorizeMasterPin(pin, 'ipc:backup')` | | `restore-backup` | sender check, then `authorizeMasterPin(pin, 'ipc:restore')` | | `db-initialize` | sender check, then `authorizeMasterPin(pin, 'ipc:initialize')` **and** the exact confirmation phrase `INITIALIZE` | `pick-restore-file` is the odd one out and is deliberately not Master-PIN gated. It opens the native file picker and returns a path bound to a single-use token; it performs no destructive step. The restore that path feeds is authorised over HTTP by a session holding `database.manage`, so the origin check is the right gate for the picker and adding a PIN prompt to it would be theatre. The HTTP twins of those repairs are gated separately, and not uniformly, by [`main/routes/database-tools.ts`](../../main/routes/database-tools.ts). Every one of its ten routes requires `database.manage`. Four of them add `requireMasterPin` (reading `master_pin` from the request body or `x-master-pin` header): | Route | Extra requirement | | --------------------------------------------- | ----------------------------------------------------- | | `POST /api/db-tools/initialize` | the Master PIN, then the `INITIALIZE` phrase | | `POST /api/db-tools/currency-reset` | the Master PIN | | `POST /api/db-tools/backups/:fileName/delete` | the Master PIN | | `POST /api/db-tools/master-pin/reset` | the current Master PIN when one is already configured | Of the remaining six, one matters here because its IPC twin is gated the other way: | Interface | Gate | | ----------------------------------------------- | ------------------------------------------------------------ | | `db-apply-safe-fixes` over IPC | sender check, then the Master PIN, failing closed without it | | `POST /api/db-tools/apply-safe-fixes` over HTTP | `database.manage`, and no PIN of any kind | A caller holding `database.manage` can run the repair over HTTP with no second factor, and cannot run it over IPC without one. Neither gate implies the other; do not reason about one from the other. ### Two privileged channels are absent `savePrinter` and `getDailySummary` are not on the bridge. Naming either in `ElectronAPI` would not compile, and the type file says so in a comment so the absence does not read as an oversight. `tests/electron-api-contract.test.ts` and `tests/printer-ipc.test.ts` assert that the `save-printer` and `get-daily-summary` channels are not registered, so the gap cannot quietly reopen. ### Adding a setting Write it on the HTTP settings route, gated on the same permission as its neighbours, and read it through `api`. Use the IPC settings channel for nothing. A genuinely new native capability needs three things together: a `contextBridge` entry in `preload.ts`, a `handle` wrapper in `ipc.ts` so `isTrustedMainWindowSender` applies, and a matching entry in `ElectronAPI`. If it is a privileged write, it also needs an authorization step that verifies the signed-in user against the backend permission model via `authorizeRendererPermission` in [`main/security/ipc-auth.ts`](../../main/security/ipc-auth.ts), because the window-origin check establishes only the calling window. ## State stores Zustand holds the renderer's client state. Five stores live in [`frontend/src/store/`](../../frontend/src/store), and the printer store lives with the printer hook because it carries the transport: | Store | Holds | Persistence | | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | [`auth.ts`](../../frontend/src/store/auth.ts) | `user`, `token`, `tenants`, `currentTenant`, `loading`, `isLocked`, and the locales that failed to warm at bootstrap | token and tenant in `localStorage`; throws `StorageUnavailableError` when storage is unavailable | | [`cart.ts`](../../frontend/src/store/cart.ts) | cart items with their addons, instructions and selected product variant, order type, table, customer, guest count, delivery details, and the subtotal and item count selectors | none | | [`held-orders.ts`](../../frontend/src/store/held-orders.ts) | suspended orders keyed by table, and the fetch, hold, restore, remove, and lookup actions over `/held-orders` | none | | [`pos-settings.ts`](../../frontend/src/store/pos-settings.ts) | the tenant's POS configuration, printer defaults, bill template and provenance, receipt and kitchen-ticket language policy, and idle `autoLockMinutes` | `persist` under the `pos-settings` key, version 6; `partialize` excludes `whatsappEnabled`, `billLanguagePolicy`, `kotLanguagePolicy`, and `autoLockMinutes` | | [`theme.ts`](../../frontend/src/store/theme.ts) | `mode`, whether this session made an explicit choice, and whether server theme info has synced | none in the store; `useThemeModeToggle` and Settings write `PUT /api/settings/theme_mode` (clearing `flo-kds-theme-override` after a successful save, while a failed save rolls back the mode and keeps the override; `useThemeModeToggle` also clears the override when the selected mode is already active), and the KDS header toggle persists a local override under `flo-kds-theme-override` | | [`usePrinterStore`](../../frontend/src/hooks/usePrinter.ts) | connection status, the hardware and WebUSB printer records, the print method, the last emitted bytes, and the print actions: `printBill`, `printTaxBill`, `printKot`, `printDeliverySlip` | `persist` | `held-orders.ts` exports `createHeldOrdersStore(apiClient)`, so a test can pass its own client instead of the shared one. ## Printing The renderer's print surface is a decision tree, not a single path. All of it lives in [`usePrinterStore`](../../frontend/src/hooks/usePrinter.ts), and the shared kernel, renderers, and transports are described in [Printing architecture](printing.md). Before dispatching a bill or tax bill, `usePrinterStore` awaits tenant-scoped receipt settings hydration (`ensureReceiptSettingsLoaded(expectedTenantId)`, backed by [`useReceiptSettingsSync`](../../frontend/src/hooks/useReceiptSettingsSync.ts)) so storefront branding and receipt metadata are loaded into `pos-settings.ts` from `GET /api/settings/business` before rendering. Switching `currentTenant` invalidates cached hydration and forces an immediate re-sync (bypassing the 60-second focus throttle), and any in-flight bill or tax-bill print whose active tenant changes aborts with `PrintCancelledOnTenantChangeError`. For a bill or a kitchen ticket, in the order the store tries them: 1. **Hardware thermal printer, ESC/POS.** `POST /api/printers/print-bill` or `/printers/print-kot`, which requires `printing.execute`. The backend encodes and sends. If the backend reports that no default printer is configured, the store falls through to browser printing and says so in a toast. 2. **Native raster, then WebUSB.** When the printer's profile advertises raster support, the renderer builds a `PrintDocument` or `KotDocument` and asks the main process to rasterize it through `rasterizePrintDocument` or `rasterizeKotDocument`, then writes the returned bytes over `navigator.usb`. A raster failure is not fatal: the store falls back to the encoder path and records a warning. 3. **WebUSB, encoder output.** `buildClassicReceiptBytes`, `buildCompactReceiptBytes`, `buildTaxBillBytes`, and `buildKotBytes` encode in the renderer, and `PrinterService` writes the bytes over `navigator.usb`. The store awaits any startup reconnect first, so a silent re-attach is not mistaken for an absent printer. 4. **Browser printing.** `lib/printer/web-print.ts` and `kot-web-print.ts` render HTML and open the system print dialog. This is also the fallback whenever no thermal transport is connected. ### The courier slip takes a shorter path `printDeliverySlip` is the fourth print action, and it skips step 2's main-process raster IPC. A delivery slip carries the address, the full contact block, payment/collection status, and order lines, and it resolves through three routes: 1. `POST /api/printers/print-delivery-slip` when a hardware printer is configured. 2. `buildDeliverySlipBytes` in [`delivery-slip-encoder.ts`](../../frontend/src/lib/printer/delivery-slip-encoder.ts) over WebUSB. 3. `generateDeliverySlipHtml` in [`delivery-slip-web-print.ts`](../../frontend/src/lib/printer/delivery-slip-web-print.ts) for the system print dialog. Both renderer-side slip files build the slip from a small order projection and the order's latest bill summary rather than from the kernel's `DeliverySlipDocument`. That model and `buildDeliverySlipDocument` exist in `shared/print/document.ts` and are consumed by the backend renderer [`main/printers/document-delivery-slip.ts`](../../main/printers/document-delivery-slip.ts), so the rule that a renderer consumes a document does not hold for the WebUSB and browser slip paths. The projection carries the order and delivery notes, and the summary from `GET /api/printers/delivery-slip-payment/:orderId` carries the expected collection method, which all three paths word through `deliverySlipExpectedPaymentText` in `shared/print/document.ts`. The cart records both delivery details and the delivery address in its order-details summary and drawer, and only for delivery orders. The action is reachable on an unpaid delivery order, because that is the workflow: the courier leaves before the customer settles. `OrderCard` renders it outside the payment branches, limited to delivery orders and hidden for cancelled ones. The slip prefers the address confirmed on that order and falls back to the customer's standing address, which is what every order created before the column existed resolves to. Whether the customer's phone number appears is the one place where the two slip paths are **not** reading a shared rule, and the duplication is load-bearing to know about. The bill paths call the kernel's `shouldShowCustomerNumber`: the renderer bill document in [`print-document.ts`](../../frontend/src/lib/printer/print-document.ts), the renderer tax-bill encoder, and the backend bill route in [`main/routes/printers.ts`](../../main/routes/printers.ts). The slip paths each re-implement the rule inline instead, with no call into the kernel: | Path | How it decides | | ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | | renderer slip, `printDeliverySlip` in [`usePrinter.ts`](../../frontend/src/hooks/usePrinter.ts) | `billDeliveryShowCustomerPhoneAlways \|\| billShowCustomerPhone` | | backend slip, `main/routes/printers.ts` | the same boolean, read from the settings table directly | The two agree today because the rule is two settings wide. They are separate code, so a change to one does not reach the other, and this is the duplication that let a slip lose the number when the delivery override was off. Treat a change to this rule as a change in four places, or fix the duplication first. [Product invariants](../reference/product-invariants.md#a-receipt-and-a-courier-slip-are-not-secure-artefacts) owns the rule itself. Warnings travel with every result. `hasFinancialPrintWarning` refuses the whole receipt rather than print a total the printer cannot represent, which is why a capability failure is a thrown error and not a toast. Print locales are warmed at authentication, not lazily at render time. `syncPrintPoliciesAtBootstrap` applies the tenant's receipt and kitchen-ticket language policy and loads the bundles it selects, and the auth store keeps the languages that failed. Every print action calls `ensurePrintLanguagesLoaded` again before it encodes, and a locale that still will not load becomes a receipt warning saying English labels were used. A receipt that renders in the wrong language is a financial document, not a cosmetic bug. ## Internationalization: 32 locales [`frontend/src/lib/i18n/languages.ts`](../../frontend/src/lib/i18n/languages.ts) is the renderer's registry. It holds 32 entries, and [`frontend/src/lib/i18n/messages/`](../../frontend/src/lib/i18n/messages) holds one JSON file per entry. Each registry entry carries the BCP-47 locale tag, the native name, the text direction, the `selectable` flag, and a dynamic chunk loader. [`loader.ts`](../../frontend/src/lib/i18n/loader.ts) imports English statically and seeds the cache with it, then loads every other locale through its dynamic import. In-flight requests are deduplicated, and an unregistered locale key falls back to English. [`I18nProvider`](../../frontend/src/components/providers/I18nProvider.tsx) wraps the tree in `use-intl`'s provider, holds the last language that actually loaded, and reverts the store to it when a later load fails, so a broken chunk leaves the screen in the language it had rather than in none. Direction is applied rather than assumed: `getLanguageDirection` feeds `HtmlLangSync`, the `Ltr` island wrapper, and `DirectionalToaster`, so a right-to-left locale mirrors the chrome and leaves numeric and Latin runs isolated. Backend enums reach the interface through [`frontend/src/lib/i18n-enums.ts`](../../frontend/src/lib/i18n-enums.ts), which maps each enum value to a message key rather than carrying English text. Adding a locale is a scripted procedure with its own verification commands, documented in [Adding a language](../guides/adding-a-language.md). `npm run i18n:check` is the gate: leaf parity against `en.json`, the derived print-label table, and a `tsc` pass over the frontend. ## Customer display `/customer-display` is a signed-in order-status board. Without a paired display token it loads the staff session from storage and redirects to `/auth/login?redirect=/customer-display` (or adds `select_tenant=true` when a user is signed in without a selected tenant) so signing in returns to `/customer-display`. Once signed in, it pages through `GET /orders` with `view=active`, `board_status` set to the configured statuses, and `type` set to the configured `order_types` via `before_id` cursor pagination (100 orders per page, up to 50 pages) so effective board status and order-type eligibility apply in SQL before pagination, projects effective board status from `order.items` for settled `completed` orders via `projectBoardStatusFromItems` in [`shared/order-item-status.ts`](../../shared/order-item-status.ts) (see [Order lifecycle](order-lifecycle.md#order-status)), and shows eligible orders in a Preparing column (`pending`, `preparing`) and a Ready column (`ready`). It re-reads `GET /settings/customer-display` on every poll cycle, so changes made in Settings > Kitchen Display apply without reloading the board. The tenant-wide config sets which statuses show (`statuses`, defaulting to `['pending', 'preparing', 'ready']`), which order types show (`order_types`, defaulting to `['takeaway']` so Dine-in, Online, and Delivery orders stay off the pickup screen unless enabled), the refresh interval (`refresh_seconds`), and whether the last-updated time is shown (`show_updated`). Deduplicated orders are sorted oldest first and applied atomically once all pages complete in a cycle, preserving the ten-second TV visual page rotation. At desktop widths, each column renders complete bill or order numbers in readable rows and independently rotates measured pages every ten seconds when needed, showing a multi-page indicator (`1 / N`, page dots, and a `+count` overflow pill) and a ten-second progress bar in the column header when orders span multiple pages. Newly ready orders take priority on the next rotation, interleaved with existing pages. At narrow widths the rows remain in one scrollable flow. A failed poll retains the last successful orders and reports that the feed is stale. Multi-page poll cycles automatically pace their interval above the configured refresh time when necessary to stay within the 120 requests per minute API limit. The same settings card shows the display URL with copy and open actions. Strings come from the `customerDisplay` message namespace. An owner or manager can also pair a standalone device (a TV or tablet browser) from the same card. The card loads the POS server's LAN addresses and uses the selected address for both the display URL and pairing link. The primary LAN address is selected first; other network interfaces, mDNS, and a clearly labelled local preview can be selected before pairing. Switching addresses updates the one-time link without creating another token. If no LAN address is available, the card says so and offers only the local preview; if address lookup fails, it shows an error and retry action. The board moves the token from the URL fragment into `localStorage` and polls `GET /api/customer-display/feed` ([API reference](../reference/api.md#customer-display-feed)) with it instead of using a staff session, so it needs no login and can read nothing but order numbers and statuses. It follows `next_cursor` across pages (100 orders per page, up to 50 pages) to display the complete eligible queue, deduplicating orders and updating the board state atomically per poll cycle. When the feed answers `401` (the token was revoked), in-flight requests abort immediately, the board clears the stored token and orders, and shows an unpaired screen. ## Tech stack | Layer | Technology | | ---------------- | ---------------------------------------------------------------------------------- | | Framework | Next.js 16 App Router | | UI runtime | React 19 | | State | Zustand 5 | | Styling | Tailwind CSS v4 | | Components | shadcn/ui primitives vendored in `src/components/ui`, built on `radix-ui` | | Icons | lucide-react | | API client | axios | | Notifications | react-hot-toast | | Messages | use-intl | | Drag and drop | `@dnd-kit/react` and `@dnd-kit/dom` | | Drawer | vaul | | Receipt encoding | `@point-of-sale/receipt-printer-encoder`, plus the shared kernel in `shared/print` | | Device I/O | WebUSB (`navigator.usb`) and the `window.electronAPI` bridge | | Image handling | react-dropzone, react-easy-crop | | Phone numbers | libphonenumber-js | The `shadcn` CLI can be invoked on demand via `npx shadcn` to add primitives; the primitives under `src/components/ui` and the Tailwind utilities in `src/app/shadcn-tailwind.css` are vendored in the repository and edited in place. ## Project structure ```text frontend/src/ ├── app/ App Router routes │ ├── (dashboard)/ Authenticated shell: AuthGuard, title bar, sidebar, status bar │ │ ├── addon-groups/ customers/ dashboard/ inventory/ kds/ │ │ ├── order-history-demo/ orders/ pos/ print-test/ products/ │ │ ├── settings/ staff/ support/ tables/ whatsapp/ │ ├── auth/ login, register, recover │ ├── customer-display/ Customer-facing order-status board │ ├── kds-standalone/ Standalone KDS, served by the :3002 base port │ ├── server-standalone/ Standalone Server App, on the :3003 base port │ └── setup/ First-run setup wizard ├── components/ │ ├── dashboard/ Cash close, cash drawer movement, shift open and close │ ├── kds/ Kitchen display, login, tabs and kanban views │ ├── layout/ Title bar, sidebar, status bar, auth guard, theme sync │ ├── orders/ Order card, history grid, refund modal │ ├── pos/ Cart, product grid, payment, number pad, checkout modals, │ │ payment queue drawer, payment ticket modal │ ├── products/ Product image uploader │ ├── providers/ I18nProvider │ ├── settings/ The settings screen's building blocks: │ │ BetaChannelToggle, ChargesSettingsCard, CountersSettingsTab, │ │ CurrencyResetDialog, DatabaseSettingsTab, GeneralSettingsTab, │ │ HealthCheckDialog, InitializeDatabaseDialog, LocalePreferencesPanel, │ │ MasterPinPrompt, PaymentMethodsSettings, PermissionAuditLog, │ │ PermissionMatrix, PrintersSettingsTab, SettingsTabShell, │ │ TaxConfigurationPanel, Toggle, WhatsAppEnableCard │ ├── support/ The support hub: SupportTicketForm, DiagnosticsPanel │ ├── tables/ Floorplan editor, table move modal, table turnover badge │ ├── ui/ shadcn/ui primitives │ └── updates/ Update download prompt and install guard dialog ├── hooks/ Printer, KDS, theme, update, cash, approver, and formatting hooks ├── lib/ │ ├── i18n/ Language registry, loader, and the 32 message files │ ├── printer/ Encoders, document builders, warnings, WebUSB bridge, │ │ and the delivery-slip encoder and browser renderer │ ├── updates/ Beta channel, auto-check, and restart-and-install helpers │ ├── api.ts The shared axios client for the authenticated pages │ ├── api-error.ts Localized text for a backend error │ └── permissions.ts tenantCan ├── store/ auth, cart, held-orders, pos-settings, theme └── types/ Electron bridge declarations and the type-level contract ``` ## Verification | Change | Check | | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | Any renderer change | `npm run lint` and `npm run build:frontend` | | A message key, a new locale, or a changed label | `npm run i18n:check` | | A permission the renderer gates on | `npm run test:authorization-permissions` and `npm run test:auth-ui-deterministic` | | A new or renamed IPC channel | `npm run test:electron-api-contract` and `npm run test:printer-ipc` | | An IPC permission or settings authorization gate | `npm run test:settings-ipc-rbac` and `npm run test:update-ipc-sender-validation` | | The sender check on a database channel | `npm run test:kds-window-hardening`, which drives `pick-restore-file`, `backup-database`, `restore-backup`, and `db-initialize` from an untrusted sender | | A print path, template, or column width | `npm run test:receipt-column-oracle` | | The courier slip | `npm run test:delivery-slip` | | A user-facing flow | `npm run test:e2e:browser` from the repository root | `npm run build:frontend` is the check that catches a server-side construct: it is the only build that switches the export target on, so run it rather than a bare `next dev`. ### Lint engine Oxlint lints both scopes, pinned to an exact version in each `package.json`. `npm run lint:backend` runs `main/` and `shared/` against the root `.oxlintrc.json`; `npm run lint` adds the renderer against `frontend/.oxlintrc.json`. Both configs switch every Oxlint category off and name each rule with its severity, so a rule runs only because the config lists it. Explicit `any` is a warning under `main/` and an error under `shared/`, which is also where the pure-kernel import boundary is enforced at error severity. Renderer declarations are linted except `next-env.d.ts` and `src/types/receipt-printer-encoder.d.ts`. Oxlint reports a false `TS(2309)` for that ambient module declaration ending in `export =`, which `tsc` accepts. `@next/next/no-img-element` stays off because the static export cannot use `next/image`. React and React Hooks rules run directly as native Oxlint rules without a JS plugin bridge. `npm run lint:budget` reads the Oxlint JSON report for each scope and fails on an error-severity diagnostic, on a run whose report it cannot read, and on warnings above `scripts/ci/lint-budget.json`. Those limits are ceilings that ratchet down manually as warnings are fixed incidentally, not targets to spend.