# Running a hub A hub lets several tablets and phones work as one shop. A waiter's phone opens a table that the counter tablet charges, held orders show on every device, and the menu and staff are shared. Every device keeps selling when the hub is down, and catches up when it is back. One hub can serve several separate shops. Each shop's data stays in its own file, and devices only ever see their own shop. ## Start a hub Install it with Docker, the Linux installer or a hosting platform: see [Self-hosting](https://docs.openkedai.com/self-hosting/). For example, on a Linux machine at the shop: ```sh curl -fsSL https://raw.githubusercontent.com/OpenKedai/openkedai/main/deploy/install.sh | sudo sh ``` From a clone of the repository, with Node.js 22: `pnpm install`, `pnpm build`, then `node apps/server/dist/main.js` (data in `./data`). The hub listens on port 8080. Its log prints a **setup code**: ```text To add a shop to this hub, open it in a browser and choose Set up a new shop, or on the shop's POS app go to Settings → Hub → Move this shop to a hub. Setup code: K7QM-2XPA ``` The setup code is the hub owner's key for adding shops, so keep it to yourself. ## Set up a new shop in the browser Open the hub's address (for example `http://192.168.1.20:8080`) in a browser and choose **Set up a new shop**. The wizard asks for the setup code, the shop's details and the owner's email and password, then shows a QR code to pair the first device. See [the back office](https://docs.openkedai.com/back-office/). ## Move a shop to the hub On the shop's existing device (the one with its menu and sales): 1. Open **Settings → Hub**. 2. Enter the hub's address, for example `192.168.1.20` (port 8080 is assumed), and the setup code. 3. Tap **Move this shop to a hub**. The device uploads its menu, staff and settings, then its sales, and keeps a copy of everything. **Settings → Hub** then shows "Up to date" and "Live updates on". ## Add more devices 1. In the back office, open **Devices → Pair a device**, or on a device already on the hub, an owner or manager opens **Settings → Hub → Pair a device**. A QR code and a code such as `JY29-G2ZN` appear. The code works once, for 10 minutes. 2. On the new device, on the first screen, choose **Join an existing hub instead**. Scan the QR code, or type the hub's address and the code, and give the device a name. Hubs found on the network are listed under **Hubs on this network**; tap one to fill in its address. 3. Sign in with an existing staff PIN. The device gets its own code (T2, T3…) for receipt numbers. A device that already has sales of its own can't join another shop's hub. Use a new device, or one that has never sold. ## Remove a device In the back office, open **Devices**, pick the device and choose **Remove**; or on another device, an owner or manager opens **Settings → Hub → Devices**. The removed device stops syncing at once and says so. Its sales stay, and it can be paired again with a new code from **Settings → Hub** on that device. ## When the hub's address changes Devices find their hub again on their own. When the hub stops answering, each device looks for it on the shop's network (over mDNS) and follows it to its new address, usually within half a minute of the hub coming back. **Settings → Hub** says "Looking for the hub on this network…" meanwhile. Sales made in the meantime are sent once it's found. A device only follows its own hub: - each hub has an id, announced with it on the network; - before sending its key, the device checks that the hub there really holds it. Another hub on the same network, or something pretending to be the hub, is ignored (ADR 0022). This needs the devices and the hub on the same network. mDNS doesn't cross routers or VLANs, and some guest Wi-Fi blocks it. In those cases, or for a hub at an `https://` address, open **Settings → Hub → Change hub address** on each device and enter the new address. Hubs found nearby are listed there to tap. Before sending its key, the device checks that the hub there already holds it (and has the same hub id, when the device knows it), then that the hub knows it as this shop's device. It keeps its pairing. A fixed address in your router (a "DHCP reservation") still saves devices the search. ## When the hub or the network is down Devices sell, print and close shifts as usual. **Settings → Hub** shows what is waiting to be sent, and the register shows a **Hub offline** button. When the hub is back, devices send what they have and fetch what they missed. Nothing is lost and nothing is counted twice. Menu and staff changes made on two devices while offline can overwrite each other: the first to reach the hub wins. Sales never do. ## Settings and backups Every setting is an environment variable with a safe default; see [Configuration](https://docs.openkedai.com/self-hosting/configuration/). The hub takes a snapshot of every shop each day and can copy it to S3-compatible storage; see [Backups](https://docs.openkedai.com/self-hosting/backups/). ## Security - Devices hold a token the hub gave them when they paired. The hub stores only a hash of it. - On a shop LAN the hub speaks plain HTTP, so anyone on the same network could watch the traffic. Keep the hub on the shop's own network. To reach a hub over the internet, put HTTPS in front of it and set `OPENKEDAI_PUBLIC_URL` to the HTTPS address; see [HTTPS and remote access](https://docs.openkedai.com/self-hosting/https/). - Too many wrong setup or pairing codes from one address are refused for a while. --- # The back office The back office is a website for owners and managers, served by the hub on the same address and port as everything else (for example `http://192.168.1.20:8080`). Use it on a laptop or a phone to set up a shop, edit the menu and staff, change shop settings, pair and remove devices, and see sales. A POS device on its own (single-device mode) has no back office; its owner does all of this in the app's Settings. Move the shop to a [hub](https://docs.openkedai.com/hub/) to use the back office. > Status: pre-alpha. In English, Bahasa Melayu and 简体中文: choose a language below the sign-in > form, or under **Account** once signed in. ## Getting in There are three ways to sign in for the first time: - **A new shop.** Open the hub's address in a browser and choose **Set up a new shop**. The wizard asks for the hub's setup code (printed in the hub's log when it starts, or set with `OPENKEDAI_SETUP_CODE`), then the shop's name, SST registration and service charge, your name and POS PIN, and an email and password. It ends on the pairing page, ready for the first POS device. - **A shop moved to the hub from its POS device.** On that device, signed in as an owner or manager, go to **Settings → Hub → Back office → Get a sign-in code**. In the browser, choose **I have a code from the POS**, enter the code, and choose an email and password. The code works once, for 30 minutes. - **A manager.** An owner opens **Staff**, picks the manager and sets an email and password for them. **Forgot your password?** Get a new code from a POS device as above. It sets a new password and signs you out everywhere else. One account can run several shops on the same hub: **Account → Add another shop** runs the wizard again, and **Switch shop** moves between them. Only owners and managers can sign in. Making someone a cashier, or removing them from the staff, closes the back office for them at once. ## What's in it | Page | | | --- | --- | | **Reports** | Sales for a day or a period: totals, average order, by payment (including plugin payment types), by order type, SST by code, by item, by day. CSV exports of orders, items sold, and payments with refunds | | **Shifts** | Every device's shifts, newest first, with each X- or Z-report: sales, payments, cash drawer, counted cash and the difference | | **Menu** | Items with price, tax code, category, service charge, kitchen printing and modifier groups; categories with colours; modifier groups and their prices | | **Staff** | Names, roles and POS PINs; who can sign in to the back office | | **Shop settings** | Name, receipt code, address, SST number, menu prices with or without tax (per order type), service charge, cash rounding, blind close, receipt header and footer, receipt and kitchen ticket languages, DuitNow QR image | | **Devices** | Pair a device with a QR code or a code, rename devices, remove a device | | **Plugins** | Installed plugins, their settings, and a way past an event a plugin keeps failing on | Changes to the menu, staff and settings reach every device at once, or as soon as it's back online. Reports are worked out from the shop's sales as stored on the hub, so a device that is offline shows up once it has synced. A sale counts on the day it was closed on its device, by that device's clock. Deleting a menu item, category or modifier group hides it; past receipts keep their own copy of names and prices. ## Languages The back office speaks English, Bahasa Melayu and 简体中文. Choose yours under **Language** on the sign-in page or on **Account**; it changes at once and is remembered in that browser. Each POS device chooses its own language too, on the PIN pad or in **Settings → Language**. What prints is set for the whole shop in **Shop settings → Receipt**: - **Receipt language:** the language receipts print in. - **Second language:** optional. Labels such as Total then print in both languages ("Total / Jumlah"), and so do labels before a value ("Receipt / Resit K1-0042"). Tax lines, which hold several numbers, stay in the first language. - **Kitchen ticket language:** for the kitchen; by default the same as receipts. Item names print as you typed them. To show a dish in two languages, put both in its name, for example "Char Kuey Teow 炒粿条". ## Removing a device On **Devices**, pick the device and choose **Remove**, or on another POS device go to **Settings → Hub → Devices**. The device stops syncing at once and shows **Removed from hub**. It keeps selling on its own, and its sales wait on it. To bring it back, give it a new pairing code: on the removed device, **Settings → Hub** asks for one. Its waiting sales go up to the hub then. A device can't remove itself, so a shop never locks itself out by accident. ## Security - Passwords are at least 8 characters and are stored only as a hash (scrypt). - Signing in sets a cookie that lasts 30 days from last use. Signing out, or changing the password, ends it. - Too many wrong passwords or codes from one address, or for one email, are refused for a while. - On a shop network without HTTPS, anyone on the same network could watch the traffic. To use the back office over the internet, put HTTPS in front of the hub (see [Running a hub](https://docs.openkedai.com/hub/)). --- # Printing OpenKedai prints receipts and kitchen tickets on ESC/POS thermal printers. It talks to printers directly, with no printer apps or drivers to install, and printing never holds up a sale: if a printer is off or out of paper, the job waits and retries, and the sale carries on. ## Printers that work - **Network printers (LAN or Wi-Fi)** that accept raw ESC/POS on TCP port 9100. That is almost every receipt printer with an Ethernet or Wi-Fi port, including Xprinter (XP-58, XP-80, XP-Q200…), Epson TM series (TM-T82, TM-m30…), Rongta, Gprinter, HPRT and most unbranded 58 mm and 80 mm models. - **Sunmi built-in printers** on Sunmi Android terminals (T2, V2, D2 mini, the P-series and similar). USB and Bluetooth printers are not supported yet. ## Adding a network printer 1. **Connect the printer** to the same network (router or Wi-Fi) as the tablet or phone. 2. **Find its IP address.** Turn the printer off, then hold the FEED button while turning it on, and release after about two seconds. It prints a self-test page that shows the IP address. Many Xprinter models come set to `192.168.123.100`. If that isn't on your network's range (for example `192.168.1.x`), change the printer's address in its web page, or turn on DHCP there and give it a fixed address in your router, so it doesn't change after a power cut. 3. In OpenKedai go to **Settings → Printers → Add printer** and enter: - a **name**, such as "Counter" or "Kitchen"; - the **IP address**, with port 9100; - the **paper width**, 80 mm or 58 mm; - what it **prints**: receipts, kitchen tickets, or both. 4. Tap **Test print**. The page shows a ruler. If the ruler wraps onto a second line, lower **Characters per line**. If it stops well short of the edge, raise it. On iPhone and iPad, the first test print asks for permission to find devices on your local network. Allow it, or printing can't reach the printer. ## Settings explained | Setting | Default | When to change it | | --- | --- | --- | | Characters per line | 48 (80 mm), 32 (58 mm) | Some 80 mm printers fit 42 | | Cutter | Partial cut (80 mm), none (58 mm) | "Full cut" if the receipt doesn't come off cleanly; "No cutter" if there is none | | QR codes | As image | "Printer's own" gives sharper codes on printers that support it; "Don't print" if QR codes come out as garbage | | Chinese characters | As image | "Printer's own font" on a printer made for the Chinese market (GB18030): sharper and faster. Leave "As image" if Chinese comes out as garbage | | Open the cash drawer | Off | Turn on if a cash drawer is plugged into this printer | ## Chinese and other languages Receipts print in the shop's receipt language, with an optional second language for labels, and kitchen tickets in the kitchen's language (back office **Shop settings → Receipt**, or the POS's **Settings → Shop**). Z-reports and test pages print in the device's own language. Chinese prints on any printer. By default each line with Chinese characters is sent as a small image, drawn with GNU Unifont, so it works on printers without a Chinese font. Printers made for the Chinese market have their own font; set **Chinese characters** to **Printer's own font** for those. Simplified and traditional characters (GBK) are covered; anything else, such as emoji, prints as "?". ## Kitchen tickets Items go to the kitchen printers when you tap **Send to kitchen**, **Hold** or **Charge**. Only new items are sent, so nothing prints twice. Drinks or goods that don't need preparing can be excluded per item (Settings → Menu → item → "Print in kitchen"). Voiding an item that was already sent prints a VOID ticket. ## When something goes wrong If a job can't print, OpenKedai tries again after 2, 5, 15, 30 and 60 seconds. If it still fails, a red **Printer problem** button appears on the register. **Settings → Printers** then shows each recent job and why it failed, with a **Retry** button. | Problem | Likely cause | | --- | --- | | "Can't reach the printer" | Printer off, wrong IP address, or the device is on a different network | | Strange characters | The printer is in Chinese mode or uses another code page. OpenKedai resets this at the start of every job; if it persists, check the printer's settings page | | QR code prints as random text | Set QR codes to "As image" | | Receipt doesn't cut | Set the cutter to "Full cut", or "No cutter" if the printer has none | | Lines wrap or look misaligned | Adjust characters per line with a test print | ## Sunmi terminals On Sunmi devices, add a printer and choose **Built-in (Sunmi)**. OpenKedai uses Sunmi's own print service, which comes preinstalled on these devices. ## Testing without a printer Developers can run a pretend network printer on a computer on the same network: ```sh node scripts/virtual-printer.ts ``` It listens on port 9100 and shows every job it receives as readable text. Add it in the app as a network printer with the computer's IP address. --- # Self-hosting OpenKedai A **hub** lets several tablets and phones work as one shop, and serves the web back office. The POS app works without one (single-device mode); add a hub when a second device arrives, or when you want the back office. See [Running a hub](https://docs.openkedai.com/hub/) for what it does. The hub is one small program with one port (8080) and one data folder. It needs no database server: each shop is a SQLite file. A $5 server, a Raspberry Pi or a mini PC at the shop runs it. ## Where to run it | Where | Good for | Guide | | --- | --- | --- | | **A computer at the shop** (mini PC, Raspberry Pi 4/5, NAS) | Shops with several devices: everything keeps working when the internet is down | [Docker](https://docs.openkedai.com/self-hosting/docker/) or [Linux installer](https://docs.openkedai.com/self-hosting/linux/), then [On the shop's network](https://docs.openkedai.com/self-hosting/lan-hub/) | | **A cloud server** (any VPS, DigitalOcean Droplet) | One device or a few; the back office from anywhere | [Linux installer](https://docs.openkedai.com/self-hosting/linux/), [Docker](https://docs.openkedai.com/self-hosting/docker/), [DigitalOcean](https://docs.openkedai.com/self-hosting/digitalocean/) | | **A hosting platform** | No server to look after | [Fly.io](https://docs.openkedai.com/self-hosting/fly/), [Render](https://docs.openkedai.com/self-hosting/render/), [Railway](https://docs.openkedai.com/self-hosting/railway/) | A hub in the cloud works, but devices then need the internet to see each other's orders (a waiter's phone sending to the counter, shared tables). Every device keeps selling and printing when the hub can't be reached, and catches up afterwards. For a café or restaurant with several devices, a hub at the shop is best. ## After it starts 1. Find the **setup code** in the hub's log (a line ending `Setup code: K7QM-2XPA`). It's the hub owner's key for adding shops; keep it to yourself. 2. Open the hub's address in a browser and choose **Set up a new shop**, or move an existing POS device's shop to it (**Settings → Hub**). See [the back office](https://docs.openkedai.com/back-office/). ## Looking after it - **Backups:** the hub takes a snapshot of every shop every 24 hours and keeps the last 7. You can also copy them to S3-compatible storage. See [Backups](https://docs.openkedai.com/self-hosting/backups/). - **Updates:** each guide says how. Updates run database changes on their own, after keeping a copy of each shop's file. - **Settings:** every setting has a safe default; see [Configuration](https://docs.openkedai.com/self-hosting/configuration/). - **HTTPS and remote access:** see [HTTPS and remote access](https://docs.openkedai.com/self-hosting/https/). ## What it uses Measured on every release (`pnpm budget`): about 120 MB of memory at idle, under 256 MB with a busy shop, almost no CPU when idle, a start in under a second, and an image of about 80 MB. --- # Docker The image `ghcr.io/openkedai/openkedai` runs on `linux/amd64` and `linux/arm64` (Raspberry Pi 4/5, ARM servers, most NAS boxes). Everything the hub keeps is in `/data`. ## With Docker Compose (recommended) ```sh mkdir openkedai && cd openkedai curl -fsSLO https://raw.githubusercontent.com/OpenKedai/openkedai/main/deploy/compose.yaml docker compose up -d docker compose logs openkedai | grep "Setup code" ``` Then open `http://:8080` and choose **Set up a new shop**. The compose file uses **host networking**, so devices on the shop's network find the hub on their own (mDNS) and pairing QR codes carry this machine's real address. Host networking works on Linux. On Docker Desktop (Mac, Windows), edit `compose.yaml`: remove `network_mode: host`, uncomment `ports`, and set `OPENKEDAI_PUBLIC_URL` to the computer's address (e.g. `http://192.168.1.20:8080`). Settings go under `environment:` in `compose.yaml`; see [Configuration](https://docs.openkedai.com/self-hosting/configuration/). After a change: `docker compose up -d`. ## With docker run ```sh docker run -d --name openkedai --restart unless-stopped \ --network host -v openkedai-data:/data \ ghcr.io/openkedai/openkedai:latest ``` On a server where devices connect over the internet, publish the port instead and say where the hub is reached: ```sh docker run -d --name openkedai --restart unless-stopped \ -p 8080:8080 -v openkedai-data:/data \ -e OPENKEDAI_PUBLIC_URL=https://pos.example.my \ ghcr.io/openkedai/openkedai:latest ``` ## Updating ```sh docker compose pull && docker compose up -d ``` Tags: `latest`, a minor version such as `0.9` (patch updates only), or an exact version such as `0.9.0`. Pin one of the last two if you prefer to update on your own schedule. ## Backups and files Snapshots are in the volume's `backups/` folder. To copy them out: ```sh docker run --rm -v openkedai-data:/data -v "$PWD":/out alpine cp -r /data/backups /out/ ``` See [Backups](https://docs.openkedai.com/self-hosting/backups/) for copying them to S3-compatible storage and for restoring. ## Building the image yourself From a clone of the repository: ```sh docker build -f deploy/Dockerfile -t openkedai . ``` The image runs as the unprivileged `node` user, with no source maps loaded (they cost about 75 MB of memory). For readable stack traces while debugging, add `-e NODE_OPTIONS=--enable-source-maps`. --- # Linux server or Raspberry Pi (no Docker) The installer sets the hub up as a system service on a 64-bit Linux with systemd: Debian, Ubuntu, Raspberry Pi OS (64-bit) and most others. ```sh curl -fsSL https://raw.githubusercontent.com/OpenKedai/openkedai/main/deploy/install.sh | sudo sh ``` It: - downloads Node.js 22 (from nodejs.org) and OpenKedai (from the project's GitHub releases), checking both against their published checksums; - installs them in `/opt/openkedai`, with your data in `/var/lib/openkedai` and settings in `/etc/openkedai/openkedai.env`; - creates an `openkedai` system user and the `openkedai` service, which starts on boot; - prints the back office's address and the setup code. Then open the address in a browser and choose **Set up a new shop**. ## Everyday commands ```sh sudo systemctl status openkedai # running? journalctl -u openkedai -f # the log journalctl -u openkedai | grep "Setup code" sudo nano /etc/openkedai/openkedai.env # settings, then: sudo systemctl restart openkedai ``` ## Updating Run the installer again. Your data and settings stay; the previous version is kept in `/opt/openkedai/releases/`. To install a particular version: ```sh curl -fsSL https://raw.githubusercontent.com/OpenKedai/openkedai/main/deploy/install.sh | sudo OPENKEDAI_VERSION=0.9.0 sh ``` To go back to the previous version: point `/opt/openkedai/app` at the other folder in `/opt/openkedai/releases/` (`sudo ln -sfn /opt/openkedai/releases/ /opt/openkedai/app`) and restart. An update that changed a shop's database kept a copy of it first, in `/var/lib/openkedai/backups/pre-migration/`. ## Raspberry Pi - Use **Raspberry Pi OS (64-bit)** on a Pi 4 or 5. A 32-bit system isn't supported. - SD cards wear out and fail. Turn on [copies to S3-compatible storage](https://docs.openkedai.com/self-hosting/backups/), or keep the data on a USB SSD. - At a shop, give the Pi a fixed address in the router: see [On the shop's network](https://docs.openkedai.com/self-hosting/lan-hub/). ## Uninstalling ```sh sudo systemctl disable --now openkedai sudo rm -rf /opt/openkedai /etc/systemd/system/openkedai.service /etc/openkedai sudo userdel openkedai ``` That leaves your data in `/var/lib/openkedai`; delete it too once you're sure you have a copy. --- # A hub on the shop's network A hub at the shop keeps every device in step without the internet: a waiter's phone sends an order to the counter, and held orders show everywhere, even when the shop's internet is down. ## What to run it on A Raspberry Pi 4 or 5, a mini PC, a NAS that runs Docker, or any small always-on computer. It needs very little: about 120 MB of memory. Install it with [Docker](https://docs.openkedai.com/self-hosting/docker/) (host networking) or the [Linux installer](https://docs.openkedai.com/self-hosting/linux/). Plug it into the router with a cable if you can, and keep it on a UPS or a separate socket from the kitchen equipment. ## Give it a fixed address (recommended) Routers hand out addresses that can change, for example after a power cut. Devices find their hub again on their own when that happens (they look for it on the network, usually within half a minute), but a fixed address avoids the wait and makes the back office's address easy to remember. In the router's admin page (often `192.168.1.1` or `192.168.0.1`, see the label on the router), look for **DHCP reservation**, **Static lease**, **Address reservation** or **Bind IP to MAC**, pick the hub from the list of connected devices, and save. Restart the hub so it gets the reserved address. ## Finding the hub from a device - When pairing, the POS app lists **Hubs on this network**; tap one to fill in its address. - If the hub moves to another address, paired devices follow it by themselves. **Settings → Hub** shows "Looking for the hub on this network…" meanwhile. - If devices can't see the hub on the network (some guest Wi-Fi blocks this, as do separate networks for staff and customers), put the hub and the devices on the same network, or use **Settings → Hub → Change hub address**. ## Reaching the back office from home Use a private network such as Tailscale, or a Cloudflare Tunnel; see [HTTPS and remote access](https://docs.openkedai.com/self-hosting/https/). Don't open the hub's port on the router to the internet. --- # Fly.io Fly runs the hub's image close to you (Singapore by default) with a small volume for its data. The machine stops when nothing is connected, for example overnight, and starts again on the first request in under a second. You need the `fly` command ([install it](https://fly.io/docs/flyctl/install/)) and a Fly account. ```sh mkdir openkedai-fly && cd openkedai-fly curl -fsSLO https://raw.githubusercontent.com/OpenKedai/openkedai/main/deploy/fly.toml fly apps create kedai-hub # any free name # In fly.toml, set: app = "kedai-hub" fly volumes create openkedai_data --region sin --size 1 fly deploy fly logs | grep "Setup code" ``` Then open `https://kedai-hub.fly.dev` and choose **Set up a new shop**. The hub already knows its address (from Fly), so pairing codes and QR codes use it. ## Settings ```sh fly secrets set OPENKEDAI_BACKUP_S3_ACCESS_KEY_ID=… OPENKEDAI_BACKUP_S3_SECRET_ACCESS_KEY=… ``` Plain settings can go under `[env]` in `fly.toml`; see [Configuration](https://docs.openkedai.com/self-hosting/configuration/). Keep the S3 keys in secrets. ## Updating `fly deploy` again pulls the newest image. To stay on a version, set `image` in `fly.toml` to `ghcr.io/openkedai/openkedai:0.9`. ## Good to know - One machine only: the data is on its volume. Don't scale out. - 256 MB of memory is enough for a shop. Add memory if you run plugins (`fly scale memory 512`). - Fly snapshots volumes daily; the hub's own snapshots are on the same volume, so also turn on [copies to S3-compatible storage](https://docs.openkedai.com/self-hosting/backups/). - To build from a clone of the repository instead of the published image: `fly deploy --config deploy/fly.toml --dockerfile deploy/Dockerfile` from the repository root. --- # Render [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/OpenKedai/openkedai) The button creates a web service from `render.yaml`: it builds the image from `deploy/Dockerfile` and attaches a 1 GB disk for the hub's data. **A disk needs a paid instance** (the Starter plan). On Render's free plan every restart or deploy would erase the hub's shops, so the blueprint doesn't use it. 1. Click the button, sign in, and confirm the service. 2. When it's live, open **Logs** and find the line ending `Setup code: …`. 3. Open the service's `onrender.com` address and choose **Set up a new shop**. The hub knows its Render address on its own, so pairing codes use it. ## Settings In the service's **Environment** tab; see [Configuration](https://docs.openkedai.com/self-hosting/configuration/). For backups to S3-compatible storage, add the `OPENKEDAI_BACKUP_S3_…` variables ([Backups](https://docs.openkedai.com/self-hosting/backups/)). ## Updating Render deploys the newest commit of the branch it follows. To update on your own schedule, turn off **Auto-Deploy** and use **Manual Deploy** when you want a new version. ## Good to know - One instance only: the data is on its disk. - A disk-backed service restarts with a short pause on each deploy; devices keep selling and catch up afterwards. --- # Railway Railway builds the hub from the repository (`railway.json` points it at `deploy/Dockerfile`). 1. In Railway, choose **New project → Deploy from GitHub repo** and pick OpenKedai (or your fork). 2. In the service, add a **Volume** mounted at `/data`. Without it, every deploy erases the hub's shops. 3. Under **Variables**, add `OPENKEDAI_DATA_DIR=/data`. 4. Under **Settings → Networking**, choose **Generate Domain**. The hub uses that address in pairing codes on its own. 5. Redeploy, then find `Setup code: …` in the **Deploy Logs**, open the domain, and choose **Set up a new shop**. ## Settings Under **Variables**; see [Configuration](https://docs.openkedai.com/self-hosting/configuration/). For backups to S3-compatible storage, add the `OPENKEDAI_BACKUP_S3_…` variables ([Backups](https://docs.openkedai.com/self-hosting/backups/)). ## Updating Railway deploys new commits of the branch it follows. Turn that off under **Settings** to update on your own schedule. ## Good to know - One replica only: the data is on its volume. - A Railway template (one-click deploy with the volume already set up) will come with the first release. --- # DigitalOcean Use a **Droplet** (a small virtual server). DigitalOcean's App Platform doesn't keep files between deploys, so it can't hold the hub's data. 1. **Create → Droplets**. Choose: - region: **Singapore** (or the one nearest the shop); - image: **Ubuntu 24.04**; - size: the smallest Basic plan is plenty (512 MB or 1 GB of memory); - authentication: an SSH key. 2. Under **Advanced options → Add initialization scripts (user data)**, paste the contents of [`deploy/digitalocean/cloud-init.yaml`](https://github.com/OpenKedai/openkedai/blob/main/deploy/digitalocean/cloud-init.yaml). It runs the [Linux installer](https://docs.openkedai.com/self-hosting/linux/) on first boot. 3. Create the Droplet. After two or three minutes, open `http://:8080` and choose **Set up a new shop**. The setup code: ```sh ssh root@ 'journalctl -u openkedai | grep "Setup code"' ``` Without the user data step, you can SSH in and run the installer yourself: `curl -fsSL https://raw.githubusercontent.com/OpenKedai/openkedai/main/deploy/install.sh | sh`. ## Next steps - Put the hub behind HTTPS with a domain name: [HTTPS and remote access](https://docs.openkedai.com/self-hosting/https/). Then set `OPENKEDAI_PUBLIC_URL` so pairing codes use it. - Turn on DigitalOcean's weekly backups for the Droplet, or the hub's own [copies to S3-compatible storage](https://docs.openkedai.com/self-hosting/backups/) (DigitalOcean Spaces works). - Updating, settings and logs: see [Linux installer](https://docs.openkedai.com/self-hosting/linux/). --- # Backups Every POS device also keeps a full copy of its shop's sales, so a lost hub doesn't lose sales. Backups of the hub keep the rest: the menu and staff as the back office left them, back office accounts, device pairings and plugin data. ## Snapshots (on by default) Every 24 hours the hub takes a snapshot of every database in its data folder into `/backups//`, and keeps the last 7. The first one is taken a minute after the hub starts, unless a recent one exists. A snapshot is a consistent copy taken while the hub keeps running (SQLite's online backup), one gzip file per database, plus a `manifest.json` written last. A folder without a manifest is unfinished; ignore it. | Setting | Default | | --- | --- | | `OPENKEDAI_BACKUP_INTERVAL_HOURS` | `24`; `0` turns snapshots off | | `OPENKEDAI_BACKUP_KEEP` | `7` snapshots | Snapshots are in the same place as the data, so they protect against mistakes and bad updates, not against losing the machine or its disk. For that, copy them elsewhere. ## Copies to S3-compatible storage (optional) The hub can upload every snapshot to a bucket you own: Cloudflare R2, Backblaze B2, AWS S3, DigitalOcean Spaces, MinIO and others. Nothing leaves the hub unless you set this up. 1. Create a bucket, and an access key that can write to it (read and delete aren't needed). 2. Set: | Setting | Example | | --- | --- | | `OPENKEDAI_BACKUP_S3_URL` | the bucket's URL: `https://.r2.cloudflarestorage.com/kedai-backups`, `https://s3.us-west-004.backblazeb2.com/kedai-backups`, `https://kedai-backups.s3.ap-southeast-1.amazonaws.com` | | `OPENKEDAI_BACKUP_S3_ACCESS_KEY_ID` | the key's id | | `OPENKEDAI_BACKUP_S3_SECRET_ACCESS_KEY` | the key's secret | | `OPENKEDAI_BACKUP_S3_REGION` | optional: read from AWS and Backblaze URLs, else `us-east-1` (R2 and MinIO accept it) | | `OPENKEDAI_BACKUP_S3_PREFIX` | optional, default `openkedai/` | 3. Restart the hub. Its log says `backups` with the bucket on start, and `backup uploaded to S3` after each snapshot. Objects go to `/…`, with `manifest.json` last. Old snapshots stay in the bucket: set a lifecycle rule on the bucket (e.g. delete after 30 days) to keep its size in check. ## Before updates When an update changes a shop's database, the hub first keeps a plain copy of the file in `/backups/pre-migration//`. These aren't deleted automatically. ## Restoring 1. Stop the hub (`docker compose stop`, `sudo systemctl stop openkedai`…). 2. Move the current data folder aside, keeping it until you're sure. 3. For each `.gz` file in the snapshot, unzip it to the same path in a new data folder. With the snapshot folder as the current directory: ```sh for f in $(find . -name '*.sqlite.gz'); do mkdir -p "/var/lib/openkedai/$(dirname "$f")" gunzip -c "$f" > "/var/lib/openkedai/${f%.gz}" done chown -R openkedai:openkedai /var/lib/openkedai # installer: the service's user ``` (With Docker, unzip into the `openkedai-data` volume the same way, e.g. from a temporary `alpine` container with the volume mounted.) 4. Start the hub. Every snapshot carries its own log id, so when a device next syncs, it sees the hub was put back from a backup. It sends all of its own sales and its copy of the menu again, and reads the hub's records from the start. The hub keeps one copy of each, so sales made since the snapshot come back from the devices that made them, and every device ends up with all of them. What a snapshot can't bring back: back office accounts and settings changed after it, and devices paired after it. Those devices say they were removed; pair them again from **Settings → Hub**, and their sales follow. A `pre-migration` copy is a plain SQLite file: put it back at `tenants//shop.sqlite` with the older version of OpenKedai. --- # HTTPS and remote access On the shop's network, the hub speaks plain HTTP on port 8080. That's normal for equipment on a private network. Anything reached over the internet should use HTTPS. Pick one of these: ## Caddy in front of the hub (a server with a domain name) [Caddy](https://caddyserver.com/) gets and renews certificates by itself. Point a domain (e.g. `pos.example.my`) at the server, install Caddy, and put this in `/etc/caddy/Caddyfile`: ```text pos.example.my { reverse_proxy 127.0.0.1:8080 } ``` Then `sudo systemctl reload caddy`, and tell the hub its address so pairing codes use it: ```sh OPENKEDAI_PUBLIC_URL=https://pos.example.my ``` Keep port 8080 closed to the internet (only 80 and 443 open), e.g. with `ufw allow 80,443/tcp` and without allowing 8080, or set `OPENKEDAI_HOST=127.0.0.1`. ## Cloudflare Tunnel (no open ports) A tunnel connects the hub to Cloudflare from the inside, so nothing on the router or server is opened. Install `cloudflared`, create a tunnel for a hostname in your Cloudflare account, and point it at `http://localhost:8080`. Then set `OPENKEDAI_PUBLIC_URL` to `https://`. ## Tailscale (private access for the owner) [Tailscale](https://tailscale.com/) puts the hub, the owner's laptop and phone on a private network. Install it on the hub and on the owner's devices; the back office is then at `http://:8080` from anywhere, and nothing is exposed to the internet. To give it HTTPS too, use `tailscale serve`. ## Devices and HTTPS - POS devices connect to whatever address the pairing code carries. With HTTPS in front, set `OPENKEDAI_PUBLIC_URL` before pairing devices, or use **Settings → Hub → Change hub address** afterwards. - Devices don't look for an `https://` hub on the shop's network: such an address doesn't move around. --- # Configuration Every setting is an environment variable with a safe default; the hub needs none of them to run. Set them in `compose.yaml` (Docker), `/etc/openkedai/openkedai.env` (Linux installer), or the platform's settings page, then restart the hub. ## The basics | Variable | Default | What it does | | --- | --- | --- | | `PORT` | `8080` | The one port for the back office, the API and live updates | | `OPENKEDAI_HOST` | `0.0.0.0` | Interface to listen on; `127.0.0.1` behind a reverse proxy on the same machine | | `OPENKEDAI_DATA_DIR` | `./data` (`/data` in the image) | The one data folder: shops, plugins, backups | | `OPENKEDAI_HUB_NAME` | the machine's name | The name devices show for this hub | | `OPENKEDAI_PUBLIC_URL` | this machine's network addresses | Address(es) devices should use, comma-separated, e.g. `https://pos.example.my`. On Render, Railway and Fly it comes from the platform | | `OPENKEDAI_SETUP_CODE` | made on first start, kept, and printed in the log | The hub owner's key for adding shops. Set it only to a long random value | | `OPENKEDAI_MDNS` | `on` (`off` on Render, Railway and Fly) | Announce the hub on the shop's network so devices find it | | `OPENKEDAI_LOG_LEVEL` | `info` | `debug`, `info`, `warn` or `error`; logs are JSON lines on stdout | ## Backups See [Backups](https://docs.openkedai.com/self-hosting/backups/). | Variable | Default | | --- | --- | | `OPENKEDAI_BACKUP_INTERVAL_HOURS` | `24`; `0` turns snapshots off | | `OPENKEDAI_BACKUP_KEEP` | `7` | | `OPENKEDAI_BACKUP_S3_URL` | not set: no copies leave the hub | | `OPENKEDAI_BACKUP_S3_ACCESS_KEY_ID`, `OPENKEDAI_BACKUP_S3_SECRET_ACCESS_KEY` | needed with the URL | | `OPENKEDAI_BACKUP_S3_REGION` | from the URL for AWS and Backblaze, else `us-east-1` | | `OPENKEDAI_BACKUP_S3_PREFIX` | `openkedai/` | ## Plugins See [Plugins](https://docs.openkedai.com/plugins/). | Variable | Default | | --- | --- | | `OPENKEDAI_PLUGINS_DIR` | `/plugins` | | `OPENKEDAI_PLUGINS` | none: installed plugin packages or paths, comma-separated | | `OPENKEDAI_PLUGINS_DISABLED` | none: plugin ids not to start, comma-separated | ## Rarely needed | Variable | Default | | --- | --- | | `OPENKEDAI_WEB_DIR` | the built back office next to the server | | `NODE_OPTIONS=--enable-source-maps` | off: readable stack traces, at about 75 MB more memory | --- # Plugins Plugins add features to an OpenKedai server without changing OpenKedai itself. Examples: syncing sales to an accounting package, awarding loyalty points, printing a feedback QR code on receipts, or asking for a car plate at a drive-through. A plugin runs inside the server, reads the shop's sales as they happen, and can add its own web routes, settings, data and payment types. Plugins run on the server (the hub). The POS app never runs plugin code, so a sale never waits on a plugin, and plugins can't change prices, tax, discounts or rounding. Those come from the shop's settings, which every device applies the same way, even offline. > Status: the plugin interface is new (`PLUGIN_API_VERSION` 1) and may still change before the > first release. ## Installing a plugin A plugin is a JavaScript module. To install one, put it in the server's plugins folder, `/plugins/` (`/data/plugins/` in Docker), and restart the server. It can be: - a single `.js` or `.mjs` file, or - a folder with a `package.json` whose `exports` or `main` points at the plugin, or with an `index.js`. You can also install plugins from npm next to the server and list them in `OPENKEDAI_PLUGINS`. That suits a server run from a clone of the repository. With the Docker image or the Linux installer, use the plugins folder: it is in the data folder, so plugins stay through updates. | Environment variable | What it does | Default | | --- | --- | --- | | `OPENKEDAI_PLUGINS_DIR` | Folder to load plugins from | `/plugins` | | `OPENKEDAI_PLUGINS` | Extra plugins: package names or file paths, comma-separated | none | | `OPENKEDAI_PLUGINS_DISABLED` | Plugin ids not to start, comma-separated | none | | `OPENKEDAI_PLUGIN__` | A plugin setting, e.g. `OPENKEDAI_PLUGIN_DAILY_TALLY_FEEDBACK_URL` | none | A plugin that fails to load is logged and skipped. The server still starts. Plugins run with the server's full permissions, so only install plugins you trust. ## Writing a plugin Install the SDK, `@openkedai/plugin-sdk`. It is MIT licensed and has no dependencies. Export a plugin definition as the module's default export: ```ts import { definePlugin, PLUGIN_API_VERSION } from "@openkedai/plugin-sdk"; export default definePlugin({ id: "hello", // 2–40 lowercase letters, digits and single hyphens name: "Hello", // or { en: "Hello", ms: "Helo" } version: "1.0.0", apiVersion: PLUGIN_API_VERSION, setup(api) { api.onEvent("order.closed", (event, ctx) => { ctx.log.info("Order closed", { orderId: event.orderId }); }); }, }); ``` Bundle it into one file (for example with `esbuild --bundle --format=esm --platform=node`) and drop it in the plugins folder. [`examples/plugins/daily-tally`](https://github.com/OpenKedai/openkedai/tree/main/examples/plugins/daily-tally/) is a complete example to copy. ### Handling events `api.onEvent(patterns, handler)` receives events after they are stored. A pattern is an exact type (`order.closed`), a prefix ending in `.*` (`order.*`, `ext.loyalty.*`), or `*` for everything. - Events arrive **in the order they were stored**, one at a time. - Each event is delivered **at least once**. The server remembers how far each plugin got, so after a restart it carries on from there. After a crash it may deliver the last few events again, so make handlers safe to repeat: remember what you have done, keyed by event id, payment id or order id. - If a handler throws, the server retries it after 1 s, 2 s, 4 s… (up to every 5 minutes). Later events wait, so the plugin never sees them out of order. Sales are never held up. The owner can see the error and, as a last resort, skip the failing event. - Money is in **sen** (integer cents): `1290` is RM12.90. Core event types: `order.opened`, `order.item_added`, `order.item_changed`, `order.item_voided`, `order.discount_applied`, `order.service_charge_set`, `order.sent_to_kitchen`, `order.fields_set`, `order.payment_recorded`, `order.closed`, `order.refunded`, `shift.opened`, `shift.cash_movement`, `shift.closed`. Their payloads are defined in `packages/core` and validated by `packages/events`. ### What a handler can use The `ctx` passed to handlers, routes and jobs is scoped to one shop (tenant): | | | | --- | --- | | `ctx.settings` | This shop's settings for the plugin, with defaults filled in | | `ctx.storage` | The plugin's own SQLite database for this shop: `exec`, `run`, `all`, `get`, `transaction`, and a small `kv` store. It is a separate file, so a plugin can't damage the shop's data | | `ctx.emit(name, payload)` | Stores one of the plugin's own events, `ext..`, declared first with `api.defineEvent(name, schema?)` | | `ctx.events.get(id)`, `ctx.events.forOrder(orderId)` | Read the shop's event log | | `ctx.log` | Structured logs, tagged with the plugin and shop | Plugin events are part of the shop's permanent log. They never belong to an order: refer to orders in the payload (`{ orderId: "…" }`). A payload must be a JSON object of at most 16 KB. `api.onTenantStart(ctx => …)` runs once per shop before any handler, for example to create tables. Make it safe to run again. ### Routes ```ts api.route({ method: "GET", path: "/orders/:orderId", // served at /api/ext//orders/:orderId access: "owner", // public | device | staff | owner (default) body: schema, // optional, for POST/PUT/PATCH: any Standard Schema (Zod, Valibot…) handler: async (request, ctx) => ({ orderId: request.params.orderId }), }); ``` Return a value to send it as JSON, `respond(status, body, headers)` for anything else, or nothing for `204 No Content`. Throw `new PluginHttpError(404, "No such order")` to send an error. Any other error becomes a plain `500`, and its details go to the log only. Paired POS devices can call `device` routes with their token. `owner` routes answer an owner or manager signed in to the back office, and `staff` routes either of those or a paired device (see [ADR 0020](https://docs.openkedai.com/adr/0020-back-office-sign-in/)). Treat `public` routes as reachable by anyone who can reach the server. On a hub with several shops, a `public` route picks the shop with `?shop=`; for the others, the device or the session picks it. ### Settings Declare settings as fields, and the back office draws the form: ```ts settings: [ { key: "api_key", type: "text", label: "API key", secret: true }, { key: "every", type: "number", label: "Every", default: 100, min: 1, integer: true }, { key: "enabled", type: "boolean", label: "Enabled", default: true }, { key: "mode", type: "select", label: "Mode", options: [{ value: "a", label: "A" }] }, ], ``` A value comes from what the owner saved, then from `OPENKEDAI_PLUGIN__`, then from the default. Secret values are never sent back to a browser. ### Scheduled jobs ```ts api.job({ id: "nightly-export", schedule: { dailyAt: "03:30" }, run: async (ctx) => { … } }); api.job({ id: "sync", schedule: { everyMinutes: 15 }, run: async (ctx) => { … } }); ``` Jobs run per shop, on the server's local time. A run that is still going when the next is due is skipped. ### Items for the POS app A plugin can add items that the POS app shows and caches, so they work offline: ```ts contributes: (settings) => ({ tenders: [{ id: "grabpay", name: "GrabPay", method: "ewallet", reference: "optional" }], receiptBlocks: [{ id: "survey", text: "Rate us", qr: "https://example.my/r/{receiptNumber}" }], orderFields: [ { id: "plate", label: "Car plate", type: "text", channels: ["takeaway"], print: { receipt: true } }, ], }), ``` - **Tenders** are extra payment types. Each is counted as a built-in method (`card`, `qr`, `ewallet`, `other`), so reports and the cash count keep working. The receipt prints its name. - **Receipt blocks** print text and/or a QR code under the shop's footer. Placeholders: `{receiptNumber}`, `{outletName}`, `{total}` (e.g. `RM12.90`), `{totalSen}` (e.g. `1290`), `{date}` (YYYY-MM-DD). - **Order fields** are extra values taken with an order: `text`, `number` or `phone`, optionally required, and optionally printed on the receipt or kitchen ticket. Ids are unique within the plugin. The server prefixes them with the plugin id, e.g. `my-plugin:grabpay`. Devices fetch the merged list from `GET /api/plugins/extras`. ## Server endpoints | Endpoint | Access | | | --- | --- | --- | | `GET /api/plugins` | owner | Plugins, their state per shop, failing events, jobs, and plugins that failed to load | | `GET /api/plugins/extras` | device | The POS items of every running plugin | | `GET /api/plugins/:id/settings` | owner | Setting fields and values (secrets shown as set or not) | | `PUT /api/plugins/:id/settings` | owner | Save settings; `null` clears a value | | `POST /api/plugins/:id/skip-failed-event` | owner | Skip the event a plugin keeps failing on | ## Limits - Plugins run in the server's process. A plugin that loops forever or uses a lot of memory slows the whole server. The resource budget is measured without plugins. - A plugin can't change totals, edit or delete events, or run on the POS app. - The plugin interface follows `PLUGIN_API_VERSION`. A server only loads plugins built for its version. ## Licensing OpenKedai is AGPL-3.0. The `LICENSE` file adds a plugin exception: a plugin that talks to OpenKedai only through `@openkedai/plugin-sdk` may use any license, including a closed-source one. The SDK itself is MIT. (The exception's wording is still to be reviewed by a lawyer.) --- # Architecture decision records Decisions that are hard to reverse. Each record says what was decided and why. To change one, write a new record that supersedes it. | # | Decision | Status | | --- | --- | --- | | 0001 | [License: AGPL-3.0-only](https://docs.openkedai.com/adr/0001-license-agpl/) | Accepted | | 0002 | [Offline-first](https://docs.openkedai.com/adr/0002-offline-first/) | Accepted | | 0003 | [Money is integer sen](https://docs.openkedai.com/adr/0003-money-integer-sen/) | Accepted | | 0004 | [The event log is the source of truth](https://docs.openkedai.com/adr/0004-event-log-source-of-truth/) | Accepted | | 0005 | [Three ways to run, one codebase](https://docs.openkedai.com/adr/0005-three-ways-to-run/) | Accepted | | 0006 | [Country rules are data](https://docs.openkedai.com/adr/0006-country-rules-are-data/) | Accepted | | 0007 | [Who is authoritative](https://docs.openkedai.com/adr/0007-authority/) | Accepted | | 0008 | [Telemetry is opt-in](https://docs.openkedai.com/adr/0008-telemetry-opt-in/) | Accepted | | 0009 | [Two apps sharing one core](https://docs.openkedai.com/adr/0009-two-apps-shared-core/) | Accepted | | 0010 | [App-store distribution and the CLA](https://docs.openkedai.com/adr/0010-app-store-distribution-and-cla/) | Accepted | | 0011 | [Self-hosting is a product feature](https://docs.openkedai.com/adr/0011-self-hosting-is-a-feature/) | Accepted | | 0012 | [Cheap by design](https://docs.openkedai.com/adr/0012-cheap-by-design/) | Accepted | | 0013 | [This is the public open-source repository](https://docs.openkedai.com/adr/0013-public-repository/) | Accepted | | 0014 | [Business rules are configurable](https://docs.openkedai.com/adr/0014-configurable-business-rules/) | Accepted | | 0015 | [Event ordering and conflicts](https://docs.openkedai.com/adr/0015-event-ordering-and-conflicts/) | Accepted | | 0016 | [Printing](https://docs.openkedai.com/adr/0016-printing/) | Accepted | | 0017 | [Shifts and Z-reports](https://docs.openkedai.com/adr/0017-shifts/) | Accepted | | 0018 | [Plugins and extension points](https://docs.openkedai.com/adr/0018-plugins/) | Accepted | | 0019 | [Hub sync and pairing](https://docs.openkedai.com/adr/0019-hub-sync-and-pairing/) | Accepted | | 0020 | [Back office sign-in](https://docs.openkedai.com/adr/0020-back-office-sign-in/) | Accepted | | 0021 | [Languages and Chinese printing](https://docs.openkedai.com/adr/0021-languages-and-chinese-printing/) | Accepted | | 0022 | [Finding the hub on the network](https://docs.openkedai.com/adr/0022-finding-the-hub-on-the-network/) | Accepted | | 0023 | [Shipping the server, and backups](https://docs.openkedai.com/adr/0023-shipping-the-server-and-backups/) | Accepted | | 0024 | [The website and the docs site](https://docs.openkedai.com/adr/0024-website-and-docs-site/) | Accepted | New records use the next number and the same sections: Status, Date, Context, Decision, Consequences. --- # 0001. License: AGPL-3.0-only - Status: Accepted - Date: 2026-10-27 ## Context OpenKedai is free software that shops can run themselves. We want anyone to be able to self-host it, and we want improvements made by people who run modified copies as a service to come back to the community. ## Decision Everything in this repository is licensed under the GNU Affero General Public License, version 3 only (`AGPL-3.0-only`), plus the app-store permission described in [ADR 0010](https://docs.openkedai.com/adr/0010-app-store-distribution-and-cla/). ## Consequences - Shops, integrators and hobbyists can run, study, modify and share OpenKedai freely. - Anyone who offers a modified OpenKedai to users over a network must offer those users the modified source. - Third-party code must have an AGPL-compatible license. MIT-licensed code may be copied in only with its copyright notice kept. --- # 0002. Offline-first - Status: Accepted - Date: 2026-10-27 ## Context Many Malaysian shops have unreliable internet. A point of sale that stalls at the counter when the connection drops loses sales and trust. ## Decision Taking an order, totalling, paying, printing and closing a shift never wait on the internet or on any remote call. Every device keeps what it needs locally (catalog, staff, its own event log) and syncs in the background when a link is available. ## Consequences - No network call (hub, cloud, telemetry, update check) may sit on the path of taking, paying or printing an order. - Devices need local storage and their own copy of the domain logic (see [ADR 0009](https://docs.openkedai.com/adr/0009-two-apps-shared-core/)). - Sync must tolerate long offline periods and resent data (see [ADR 0004](https://docs.openkedai.com/adr/0004-event-log-source-of-truth/)). --- # 0003. Money is integer sen - Status: Accepted - Date: 2026-10-27 ## Context Floating-point arithmetic cannot represent most decimal amounts exactly, which causes rounding errors in totals, tax and reports. ## Decision All money is stored, computed and transmitted as integer sen (RM12.90 is `1290`). No floating point anywhere money is stored, computed or sent. Formatting money for display happens only in the money module of `packages/core`. ## Consequences - Rates (tax, service charge) are applied with integer arithmetic and an explicit rounding rule (half-up to the sen unless a rule says otherwise). - API payloads and database columns carry integers, never decimal strings or floats. - Code review rejects `number` arithmetic on money outside the money module. --- # 0004. The event log is the source of truth - Status: Accepted - Date: 2026-10-27 ## Context Orders change many times between opening and closing, often on devices that are offline. We need a full audit trail, safe retries and a simple way to merge work from several devices. ## Decision Order and shift changes are recorded as immutable, append-only events with a ULID id generated on the device. Current state (an open order, a shift's totals) is a projection computed by replaying events with a pure reducer. The catalog (products, modifiers, categories, tax codes) is ordinary versioned CRUD data, not event-sourced. ## Consequences - Stored events are never edited or deleted; corrections are new events (void, refund). - Inserting by event id with `INSERT OR IGNORE` makes resending always safe. - Replaying the same events always gives the same state, on any device or server. - The envelope carries `deviceSeq` for gap detection and is designed so terminal-to-terminal or hub-to-hub sync needs no schema change later. --- # 0005. Three ways to run, one codebase - Status: Accepted - Date: 2026-10-27 ## Context A small café with one tablet needs no server. A busier restaurant wants several devices sharing orders through a hub on its own network. Others prefer a server on a VPS. Maintaining separate products for each would split the project. ## Decision One codebase supports three setups: a single POS device on its own (single-device mode, the default for a new install), devices plus a hub on shop hardware, and devices plus a self-hosted server anywhere. Every event and row carries `tenantId` and `outletId` from day one, even though the MVP has one of each. ## Consequences - A single-device shop can grow into hub mode by uploading its existing log to a new hub, with no data loss. - Code must not assume a single tenant or outlet, which keeps multi-outlet features and a future hosted fork possible. --- # 0006. Country rules are data - Status: Accepted - Date: 2026-10-27 ## Context Tax rates, rounding rules and receipt requirements differ by country and change over time. Hard-coding Malaysian rules would make every other country a fork. ## Decision Country-specific rules (tax codes and rates, cash rounding, required receipt fields) live as data in a country pack, validated by a JSON schema. Malaysia (`packages/country-my`) is the first pack. `packages/core` implements the generic calculations and reads rates and codes from the pack. ## Consequences - No Malaysian rate or code is hard-coded outside `packages/country-my`. - Each pack ships test vectors (`fixtures/vectors.json`) that any implementation can check itself against. - Supporting another country is a contribution of a new pack, not a fork. --- # 0007. Who is authoritative - Status: Accepted - Date: 2026-10-27 ## Context When several devices and a hub compute totals, they could disagree, through bugs, stale catalog data or tampering. ## Decision In single-device mode, the device is authoritative. With a hub, the hub recomputes order totals using the same `packages/core` library, and its result wins; devices re-render with the hub's totals. ## Consequences - There is one rule set everywhere, because devices and the server share `core`. - Devices still compute totals locally so they keep working offline; the hub corrects them when they sync. --- # 0008. Telemetry is opt-in - Status: Accepted - Date: 2026-10-27 ## Context Usage data could help improve OpenKedai, but shops handle customer and sales data covered by Malaysia's Personal Data Protection Act (PDPA), and unexpected data collection destroys trust in open-source software. ## Decision Telemetry is off by default and only ever enabled by the owner. No data leaves a device or server unless the owner has switched that feature on. ## Consequences - Features that send data elsewhere (telemetry, update checks, cloud backups) each need an explicit, owner-controlled setting. - None of them may sit on the order path (see [ADR 0002](https://docs.openkedai.com/adr/0002-offline-first/)). --- # 0009. Two apps sharing one core - Status: Accepted - Date: 2026-10-27 ## Context Counter staff need a fast, native-feeling app with on-device storage that works offline, on the counter and on the floor. Managers need full-size screens for menus, staff and reports. ## Decision There are two apps. The POS is an Expo (React Native) app for tablets and phones: iPad, iPhone, Android tablets and Android phones. Its layout adapts from a counter tablet in landscape to a phone held in one hand, so staff can take orders on the move (at the table, in a queue, at a pop-up stall). The back office is a React web app for managers on laptops, built to static files and served by the server. Both share the TypeScript `packages/core`. ## Consequences - Domain logic is written once, in TypeScript, and runs on devices, the server and in the browser. - POS screens are designed for both tablet and phone sizes from the start. - Native code is added only through Expo modules and config plugins. - iOS devices (iPad and iPhone) never act as a hub, because iOS suspends background apps. --- # 0010. App-store distribution and the CLA - Status: Accepted - Date: 2026-10-27 ## Context Apple's App Store terms conflict with GPL-family licenses, which would stop us distributing the POS app through the stores shops actually use. We also want to keep a hosted version or future relicensing possible without asking every past contributor. ## Decision `LICENSE` adds an additional permission under AGPL section 7 that allows distribution through the Apple App Store, Google Play and similar stores, as long as the Corresponding Source stays available under the AGPL. Outside contributions require a licence-grant Contributor License Agreement in the style of the Apache ICLA; contributors keep their copyright. The CLA is collected by the CLA Assistant bot once the repository is public. The README promises that OpenKedai will always be available under the AGPL-3.0. ## Consequences - The POS app can be published in the App Store and Google Play. - Maintainers can offer a hosted version or relicense in future; the AGPL promise keeps community trust. - A lawyer must review the app-store permission and the CLA text before the repository is published or the app is first released to a store. --- # 0011. Self-hosting is a product feature - Status: Accepted - Date: 2026-10-27 ## Context The more places OpenKedai can be deployed in a few minutes, the more people will try it. Complicated deployments put off the small shops and helpers we are building for. ## Decision The server is one container, one process and one port (default `8080`) serving the API, WebSocket and back office, with one data folder (`/data`). It needs zero configuration: every setting has a safe default and an environment variable, and a first-run wizard in the browser does the rest. Images are built for `linux/amd64` and `linux/arm64`. ## Consequences - Migrations run automatically on start, after an automatic backup; the server refuses to start against a newer database. - Each deploy channel (Docker, install script, one-click PaaS, and later NAS and panel app stores) gets a short guide in `docs/self-hosting/`. --- # 0012. Cheap by design - Status: Accepted - Date: 2026-10-27 ## Context Small shops cannot pay for large servers or managed databases. A hosted fork would also need to fit many shops onto small machines. ## Decision Each tenant gets one SQLite file (`better-sqlite3`, WAL mode). The server has no required external services: no Postgres, Redis, queue or object storage. Backups can optionally stream to any S3-compatible storage. A resource budget (image size, memory, idle CPU, cold start, bytes per order) is enforced in CI by `scripts/budget.ts`. ## Consequences - A 512 MB / 1 vCPU VPS, a Raspberry Pi or a NAS can run a shop. - Tenants are easy to back up, restore and move individually, and cannot leak into each other through a missing filter. Rows still carry `tenantId` for exports and safety. - Changes that need more memory or image size must justify it in their pull request. --- # 0013. This is the public open-source repository - Status: Accepted - Date: 2026-10-27 ## Context OpenKedai will be developed in the open. A hosted version, if one is offered, needs operational features that do not belong in the community project. ## Decision This repository is the public open-source project (private until the maintainers publish it). Internal working documents live in `docs/internal/` until the maintainers clean them up. A hosted version, if any, is a separate private fork that adds multi-tenant management, billing and operations; the landing page lives in a separate repository. ## Consequences - Everything outside `docs/internal/` is written as if already public. - Tenant provisioning, billing and other hosted-only features are not built here. - The code favours extension points and adapters over hard-coded single-tenant assumptions, so a fork can rebase with few conflicts. --- # 0014. Business rules are configurable - Status: Accepted - Date: 2026-10-27 ## Context Shops differ in how they price and charge, even within one country: whether modifiers are charged per item or once per order line, whether service charge is calculated before or after tax, whether menu prices include tax. Hard-coding one practice forces every other shop to work around OpenKedai or fork it. ## Decision Where a business rule has more than one reasonable practice, OpenKedai makes it a setting instead of hard-coding one. Each setting has a sensible default: the most common practice, taken from the country pack where it is country-specific (see [ADR 0006](https://docs.openkedai.com/adr/0006-country-rules-are-data/)), and can be overridden per outlet, sales channel or item as fits the rule. Legal requirements are not settings: where the law fixes a rule (for example, service charge is not subject to SST in Malaysia), the rule is enforced, and the country pack only describes it. ## Consequences - `packages/core` takes options (for example `serviceChargeBase`, modifier `per`) rather than assuming one behaviour; defaults keep simple setups simple. - Every option needs tests for each of its values, and the back office needs a place to set it. - More options mean more combinations to test. Add an option when a real practice needs it, and keep the default path the well-trodden one. --- # 0015. Event ordering and conflicts - Status: Accepted - Date: 2026-10-27 ## Context Events for one order can come from several devices, arrive late after time offline, arrive twice, or contradict each other (a waiter adds an item on a phone just as the cashier closes the order). Every device and the hub must still reach the same state from the same events, and no event may be lost. ## Decision - An order's state is rebuilt by `replayOrder`: drop repeated ids, apply `order.opened` first, then merge the devices' events. Each device's events keep that device's own order (`deviceSeq`), and devices are interleaved by event id: at each step, the device whose next event has the smallest id goes next. Ids are ULIDs, so this approximates creation time across devices, while a device whose clock jumps backwards still replays in its own order. - The reducer is pure and never throws on a valid event. An event that cannot apply (a closed order, an unknown or voided line, a void without a reason, a repeated payment id) is kept in the order's `conflicts` list with its reason, instead of being dropped or failing the replay. - A closed order accepts only refunds. - Stored events are append-only: the SQLite schema has triggers that reject `UPDATE` and `DELETE` on the events table. ## Consequences - Replaying any set of events, in any order and with duplicates, gives identical state. - Conflicts are visible for review in the back office and in reports instead of disappearing. - A device's own events always replay in the order it recorded them. Device clocks only affect how different devices' events interleave; the hub records `receivedAt` so badly wrong clocks can be detected later. - Fixed-amount discounts cap at the amount they discount, so a replay never fails because voids made a voucher larger than the bill. --- # 0016. Printing - Status: Accepted - Date: 2026-10-27 ## Context Shops use many brands of thermal printer, mostly cheap ESC/POS models from China plus Epson, and Sunmi Android terminals with built-in printers. Printer vendors ship SDKs, but several are closed binaries or carry "all rights reserved" licences that conflict with the AGPL. Printers also disagree on less common commands, and they go offline, run out of paper or get unplugged during service. ## Decision - OpenKedai builds ESC/POS bytes itself (`packages/escpos`) from a small command subset that common printers share. Each job starts by resetting the printer and leaving Chinese double-byte mode (`ESC @`, `FS .`); text is ASCII, with accents and symbols transliterated. - Network printers get raw bytes over TCP port 9100 from our own native module (Network.framework on iOS, `java.net.Socket` on Android). Bytes go in 4 KB chunks with short pauses, and every connect and send has a timeout. - Sunmi built-in printers go through Sunmi's SDKs (both Apache-2.0): `com.sunmi:printerlibrary` for the classic print service, with `com.sunmi:printerx` as the fallback for newer devices such as the P-series. No other vendor SDK is used: the Epson ePOS SDK and closed binaries such as `libPrinterSDK.a` are not AGPL-compatible, and raw TCP works on those printers anyway. - QR codes print as images (`GS v 0`) by default, because not every printer has a QR command; the printer's own QR command is an option. - Every printer's profile (characters per line, cutter, QR mode, drawer) is a setting with defaults by paper width (ADR 0014). - All printing goes through a stored queue that sends one job at a time per printer, in order, and retries with growing delays. Checkout and kitchen actions only add jobs to the queue, so a printer can never block a sale (ADR 0002). ## Consequences - No printer app or driver to install, and nothing proprietary in the build except Sunmi's Apache-2.0 libraries (listed in `THIRD_PARTY_NOTICES.md`). - Chinese text needs a raster fallback or a printer font (M8); until then it prints as "?". - Printer status queries (`DLE EOT`) aren't used yet. A failed connection is the offline signal. - Before targeting Android 17 (SDK 37), the app must request `ACCESS_LOCAL_NETWORK`. Without it, network printing fails with timeouts that look like an offline printer. --- # 0017. Shifts and Z-reports - Status: Accepted - Date: 2026-10-27 ## Context At the end of the day a shop counts its cash drawer and prints a Z-report: what was sold, what tax was charged, how customers paid, and whether the drawer is over or short. A shop may have several devices, and a table opened before a shift change may be paid after it. Each sale has to be counted exactly once, without coordination between devices and without a server. ## Decision - **A shift belongs to one device and its cash drawer.** It opens with a float, records cash paid in and out with a reason, and closes with the cash counted. Each device numbers its own shifts 1, 2, 3…; the number is the Z-report number. - **Shifts are events** (`shift.opened`, `shift.cash_movement`, `shift.closed`), projected like orders: pure, idempotent, and an event that cannot apply is kept as a conflict. - **Money is reported where it moved; a sale is reported where it closed.** Payments, closes and refunds carry the `shiftId` of the device's open shift. A Z-report counts: - **sales** (bill totals, SST per code and rate, service charge, discounts, voided lines): from the orders that **closed** in the shift; - **payments, refunds and cash rounding:** from those taken **in** the shift. An order opened in one shift and paid in the next is reported once, in the second. - **The report is computed, never stored.** `shiftReport()` in `packages/core` rebuilds it from the events, so a reprint, the hub and the back office all get the same numbers. - **Expected cash** is the opening float plus cash payments (after rounding and change), minus cash refunds, plus cash in, minus cash out. The variance is counted minus expected. - **Settings (ADR 0014):** - each device can require an open shift to take payments (on by default, so every sale lands in a Z-report; order-taking phones can turn it off); - each outlet can choose a blind close (off by default), which hides the expected cash while counting and on X-reports, until the Z-report prints. ## Consequences - Payments taken with shifts switched off belong to no shift. They still count in daily sales reports (M7), but in no Z-report. - Open orders do not block closing a shift; the close screen warns about them, and they count in whichever shift they are paid in. - `shiftId` was added to three existing payloads as an optional field without a schema version bump, which was safe before any release. Later payload changes need a new schema version. - The event store copies `payload.shiftId` into an indexed column, so a report reads one shift's events without scanning the log. --- # 0018. Plugins and extension points - Status: Accepted - Date: 2026-10-27 ## Context Shops and integrators want OpenKedai to do things the core project will never ship: sync to an accounting package, award loyalty points, print a survey QR on receipts, ask for a car plate at a drive-through. If the only way to add these is to change the core codebase, every shop with a need forks, and forks drift. The human asked for the server to be as flexible and extensible as possible, with plugins that add features without touching the core (in the spirit of [ADR 0014](https://docs.openkedai.com/adr/0014-configurable-business-rules/)). A future hosted fork ([ADR 0013](https://docs.openkedai.com/adr/0013-public-repository/)) needs the same seams to stay thin. Three constraints shape what a plugin may do: - **Offline-first** ([ADR 0002](https://docs.openkedai.com/adr/0002-offline-first/)): taking, paying and printing an order never wait on the network, so nothing on the server can sit on that path. - **Devices compute totals** ([ADR 0007](https://docs.openkedai.com/adr/0007-authority/)): a device calculates totals with `packages/core` while offline. A server-side plugin that changed a price, tax, discount or rounding would disagree with what the till already printed. - **App stores** do not allow an app to download and run new code, and the POS must work offline, so plugin code cannot run on devices. ## Decision **Two layers, one mechanism.** 1. **In-process server plugins** for self-hosters. The operator installs a plugin by putting it in the plugins folder (or naming a package in an environment variable). Through `@openkedai/plugin-sdk` a plugin can: - handle events after they are stored, including its own; - add HTTP routes under `/api/ext//`, each with an access level; - declare settings as a list of simple fields, so any front end can render the form; - keep data in its own SQLite file per tenant; - define its own event types, `ext..`, validated by a schema it supplies; - run scheduled jobs; - contribute **declarative items** that the POS app shows (below). Plugins are trusted code running with the server's permissions, like WordPress or Strapi plugins. The operator decides what to install; a hosted version only allows plugins it has vetted. 2. **Outgoing webhooks and a token-scoped API** for everyone else: integrations in any language, hosted or self-hosted. These are designed for now and built in a later milestone. They are off by default and only send data when the owner switches them on. **Event-log consumers.** Each plugin's event handlers, and later each webhook, read the tenant's event log from a saved position (cursor). A handler that throws is retried with backoff and never blocks storing or syncing sales; the plugin is marked as failing until it recovers. After a restart or a fixed bug, the plugin catches up from where it stopped. Delivery is at least once, in storage order, so handlers must tolerate seeing an event twice. Delivery is woken by new events, never by polling. **What plugins cannot do.** They cannot change prices, tax, discounts or rounding, or edit or delete events. Anything that changes money goes through settings and rule data ([ADR 0006](https://docs.openkedai.com/adr/0006-country-rules-are-data/), [ADR 0014](https://docs.openkedai.com/adr/0014-configurable-business-rules/)), which every device applies the same way offline. Plugin events never belong to an order (`orderId` stays empty; they refer to orders in their payload), so order and shift projections never see them. **Declarative items for the POS app.** A plugin can contribute data the app knows how to use, and the app caches it so it works offline: - **tenders**: extra payment types (for example "GrabPay", "Touch 'n Go"), each mapped to a built-in method, optionally asking for a reference; - **receipt blocks**: text or a QR code at the foot of the receipt, with a few placeholders such as the receipt number; - **order fields**: extra fields taken with an order (for example a car plate or a pager number), optionally printed on the receipt or kitchen ticket. The same shapes can later be defined by the owner directly, without a plugin. **The hub's own features use the same API.** Sync, pairing and discovery (M6b), and later backup, e-invoicing and payment providers, are built as modules on the plugin kernel where practical, so the API stays honest and a hosted fork becomes the core plus private plugins. **Licensing.** `@openkedai/plugin-sdk` is MIT, with no runtime dependencies. `LICENSE` adds a section 7 additional permission (a plugin exception) so plugins that use only the plugin interface may be distributed under any license. Like the CLA, the exception's wording must be reviewed by a lawyer before the repository is published. **Compatibility.** The SDK exports `PLUGIN_API_VERSION`. A plugin declares the version it was built for. The server loads a plugin only when the major versions match, and reports any plugin it skips. ## Consequences - The server grows a plugin kernel: a loader, consumers, a route mount, settings, storage, jobs, and the contributions endpoint. M6 is split: M6a builds the kernel, M6b builds the hub on it. - The event store accepts extension events alongside the core events. The core order and shift types stay closed, so exhaustive checks still catch missing core cases. - A broken plugin can still harm the server: it can use memory, block the event loop or read files. That is the price of in-process plugins. The resource budget is measured without plugins. - The plugin API is a public contract. Breaking it needs a new major `PLUGIN_API_VERSION` and a note in the changelog. - Payment payloads, orders and receipts grow optional fields for tenders, order fields and receipt blocks. Older events stay valid. --- # 0019. Hub sync and pairing - Status: Accepted - Date: 2026-10-27 ## Context In hub mode several POS devices share one shop: a waiter's phone opens a table that the counter tablet charges, and every device sees held orders as they change. The hub keeps the canonical log ([ADR 0004](https://docs.openkedai.com/adr/0004-event-log-source-of-truth/)), but devices must keep selling when it is unreachable ([ADR 0002](https://docs.openkedai.com/adr/0002-offline-first/)). A shop usually starts on one device in single-device mode and adds a hub later, with no data loss. The hub may sit on a shop LAN with no TLS, and before M7 there is no back office to show a pairing QR or to sign in to. ## Decision **Adding a shop to a hub.** One hub can serve several separate shops (tenants), each in its own SQLite file; a device's token ties it to one shop, so shops never see each other. The hub prints a **setup code**, the hub owner's code for adding shops, in its log on every start (`OPENKEDAI_SETUP_CODE` sets one). A shop's first device adds the shop by entering the hub's address and the setup code ("Move this shop to a hub"). It uploads its outlet, catalog, staff and devices, then its whole event log through the normal outbox. The hub's copy of the shop takes the device's `tenantId` and `outletId`, so no event is rewritten. Several outlets of one business in one shop come later; every row and event already carries its outlet. **Pairing more devices.** An owner or manager on a paired device taps **Pair a device**. The hub issues a one-time pairing code (8 characters, single use, valid for 10 minutes), shown as a QR code with the hub's addresses and as text to type. The new device chooses **Join a hub** during setup, scans or types the code, and gets a **device token** (32 random bytes; the hub stores only its hash). A device that already has its own sales can't join another shop's hub: events can't be rewritten into another tenant, and nothing is deleted. **Device trust.** Every API call and the live connection carry the device token. A paired device is trusted to act for its shop; the app decides which staff may do what, as in single-device mode. Owner sign-in in a browser comes with the back office (M7). **Events.** - Devices push their own events from an outbox (events this device wrote after its push cursor) in gzip-compressed batches to `POST /api/events`. The hub validates them, rejects events for another tenant or written by another device, and stores them by id (`INSERT … ON CONFLICT DO NOTHING`), so a resent batch never duplicates. - Devices pull every event of the shop after their hub cursor with `GET /api/events` and store them locally the same way. Each device replays orders and shifts with the same `packages/core` code, so all devices converge on the hub's state ([ADR 0007](https://docs.openkedai.com/adr/0007-authority/), [ADR 0015](https://docs.openkedai.com/adr/0015-event-ordering-and-conflicts/)). - A WebSocket (`/api/live`) only says that something changed (new events up to a cursor, or a new catalog revision); devices then pull. Missed messages cost nothing: devices also pull on reconnect and after every push. **Catalog, staff and settings** stay versioned rows, not events. - A device pushes the rows it changed since its last push. - The hub keeps an incoming row only if its `version` is higher than the hub's, and bumps the shop's catalog revision. - Devices fetch the whole snapshot when the revision changes and keep a local row only while it is newer than the hub's, i.e. not yet pushed. - When two devices change the same row offline, the first to reach the hub wins. - Device rows sync only their code and name; receipt counters stay on the device. **Discovery.** The hub advertises itself over mDNS as `_openkedai._tcp` (on by default, `OPENKEDAI_MDNS=off` turns it off). The pairing QR carries every LAN address of the hub, so devices don't need discovery to pair. (Since M8.5, devices also use it to follow a hub whose address changed; see ADR 0022.) **Versions.** `GET /api/hub` returns the API version. The app refuses to sync with a hub whose major version differs and says which side to update. ## Consequences - The hub keeps a small registry database (`/hub.sqlite`): shops, device token hashes, pairing codes and the setup code. - Anyone with the setup code can add a shop to the hub, so it is for the person who runs the server, not for shop staff. A public plugin route on a hub with several shops names the shop with `?shop=`. - Plain HTTP on a LAN means anyone on the network can watch traffic. The guidance for remote access (HTTPS through Caddy, Cloudflare Tunnel or Tailscale) stays as in the brief. - Catalog edits made offline on two devices can overwrite each other; sales never do. - A stolen paired device can act for the shop until its token is revoked; revoking devices comes with the back office. --- # 0020. Back office sign-in - Status: Accepted - Date: 2026-10-28 ## Context The back office (M7) is a web app served by the hub, for owners and managers on a laptop or phone. It edits the menu, staff and settings that every device sells with, removes devices and shows reports, so it needs real sign-in. Until now a hub only knew devices (ADR 0019): tokens issued by pairing, and staff identified by short PINs on the device itself. A 4–8 digit PIN is fine behind a device's own lock screen and lockout, but too weak for a page that may be reachable over the internet. A shop reaches the back office in one of two ways: set up from scratch in the browser, or moved to the hub from its first POS device (ADR 0019), with an owner who has a PIN but no password yet. One hub may serve several shops, and one person may run more than one. ## Decision **Accounts.** A back office account is an email and a password, kept in the hub's registry (`hub.sqlite`) next to device tokens. Each account is linked, per shop, to one staff member of that shop. The staff member's role decides what the account may do: owners and managers may sign in; cashiers can't. Archiving a staff member or making them a cashier ends their sign-in at once. One account can manage several shops on the same hub and switch between them. Passwords are hashed with scrypt (N = 2^15, r = 8, p = 1, parameters stored with the hash) and are at least 8 characters. **Sessions.** Signing in sets an `HttpOnly`, `SameSite=Lax` cookie (`Secure` behind HTTPS) holding a random token; the hub stores only its hash. Sessions last 30 days from last use. Every request that changes something must carry the `x-openkedai-api` header, which a page on another site can't send without the hub's permission (CORS is never granted), so the cookie can't be used by a forged form. Wrong passwords and codes are limited per address and per email. **Ways in.** - **New shop in the browser.** The setup wizard asks for the hub's setup code (ADR 0019), then the shop's details, the owner's name and POS PIN, and an email and password. A person who is already signed in adds the shop to their account. - **A shop moved from a POS device.** An owner or manager on a paired device opens Settings → Hub → Back office and gets a one-time **sign-in code** (8 characters, 30 minutes) with the back office's address. In the browser, "I have a code from the POS" asks for the code, an email and a password. The device asks for the code on behalf of the staff member using it, trusted as devices are (ADR 0019). - **Forgotten password.** A new sign-in code from a POS device resets the password of the staff member's account and signs it out everywhere. - **Managers.** An owner can give a manager (or another owner) an email and password from the Staff page. **Rules the hub enforces** (devices still enforce roles on themselves): only owners manage owners or give back office access; a shop always keeps at least one owner; nobody changes their own role or removes themselves. **Plugin routes** (ADR 0018) get real meaning: `owner` routes answer a signed-in owner or manager of the shop, `staff` routes a signed-in owner or manager or a paired device, `device` routes a paired device. **Removing devices.** Owners and managers remove a device in the back office or from another device (a device can't remove itself). Its tokens stop working at once, its live connection is told and closed, and it stops syncing with a reason. Its row and sales stay. A pairing code can bring it back with its own id and device code (`POST /api/devices/rejoin`), so its sales waiting in its outbox still reach the hub. ## Consequences - Owners and managers can run the shop from a browser without a terminal; the only secret they may need from the server is the setup code, which a hosted or PaaS deploy can set with `OPENKEDAI_SETUP_CODE`. - Accounts live in the hub's registry rather than in a shop's file, because one person can run several shops. Moving a shop's file to another hub doesn't move its accounts; its owner signs in again with a code from a POS device. - No email is ever sent: there is no mail server to configure, and the POS sign-in code covers password resets. Email-based resets and two-factor sign-in can come later. - Sign-in on a shop LAN without HTTPS sends the password in the clear on that network, as device tokens are (ADR 0019). The hub guide recommends HTTPS for any hub reachable from the internet. --- # 0021. Languages and Chinese printing - Status: Accepted - Date: 2026-10-28 ## Context Malaysian shops work in English, Malay and Chinese, often all three in one shop: a cashier reads Malay, the kitchen reads Chinese, and customers want receipts they can read. M8 adds Malay (`ms`) and Simplified Chinese (`zh-Hans`) to the POS app, the back office and the printouts. Each reader needs their own language, and switching must not need a restart. Thermal printers mostly print only Latin text in their built-in fonts. Models made for the Chinese market also have a Chinese font (GB2312/GBK, read in their "Chinese mode"), but many printers sold in Malaysia don't. Printing Chinese must work on both, without native code that can only be checked on a device. ## Decision **Who chooses which language.** - **Device screens:** each POS device chooses its own screen language. It is stored on the device, offered on the setup screen, the PIN pad and in Settings → Language, and starts from the system language. - **Back office:** each browser chooses its own language. It is stored in the browser and starts from the browser's language. - **Shop settings:** the shop sets a receipt language, an optional second receipt language, and a kitchen ticket language. The kitchen ticket language defaults to the receipt language. These settings sync like the other shop settings. - **Z-reports and test pages:** these are for staff, so they print in the device's screen language. **Switching without a restart.** - **POS app:** the provider holds the translator, so every screen re-renders when it changes. - **Back office:** the router remounts with the new language (the data cache stays), and `` follows. - **Hub errors:** the hub answers in the language a request asks for (`Accept-Language`). The back office and the app send theirs, so errors appear in the reader's language. Handlers send catalog keys (`fail("server.…")`), and one hook turns them into words. **Two languages on a receipt.** With a second language, labels print in both ("Total / Jumlah"). A label followed by one value prints in both, with the value once ("Receipt / Resit K1-0042", "Served by / Dilayan oleh Aminah"; changed 2026-10-28, after the third Mac pass found those lines in one language). Messages with several values (tax lines) print in the first language only, so numbers aren't printed twice. Lines that grow too long wrap. Item names are the shop's own data and print as entered. **Catalogs.** - **Layout:** every language has the same keys, split into the POS app, printing, the back office and the hub's messages. - **Tests:** they check that each language has every key with the same placeholders. - **No hard-coded text:** a test scans the POS app's and back office's components for text written into JSX or into props people read (`label`, `title`, `placeholder`, `aria-label`…). **Chinese printing.** Each printer chooses one of two modes: - **Image (the default):** works on every printer that prints images (`GS v 0`), which is all the printers we target. A line containing Chinese is drawn as a 1-bit image at the printer's own cell size (12 × 24 dots, wide characters 24 × 24) and sent like a QR image, so columns line up with lines the printer draws itself. The glyphs are GNU Unifont's 16-dot bitmaps (SIL OFL 1.1), scaled 1.5× with smoothing. - **Printer's own font:** for printers with a Chinese font. The line is sent in Chinese mode (`FS &`) with GBK bytes, then the job leaves Chinese mode again (`FS .`). **What the font covers.** GBK: simplified and traditional Chinese, which includes GB2312 and is read by GB18030 printers. GB2312 alone would miss characters common on Malaysian menus, such as 粿 in 炒粿条 and 叻 in 叻沙. The glyphs and the GBK table are packed into one generated module of about 580 KB (deflated, base64). It is unpacked into flat arrays the first time Chinese is printed. ## Consequences - **Printing:** - Every printer can print Chinese out of the box, with no native code and no new permissions. - Image lines print a little slower than text and look pixelated next to the printer's own font. Shops with a Chinese printer can switch that printer to its own font. - Characters outside GBK print as "?". Emoji are among them. - Unifont's Latin glyphs, used in image lines, don't match the printer's own font exactly. - **Size:** - The POS app's JavaScript grows by about 580 KB for the font. - The back office grows by about 70 KB for the extra languages, now one 510 KB file (150 KB gzipped). - **Translations:** - Plugin labels already carry `en`, `ms` and `zh-Hans` (ADR 0018). They now follow the screen's language on screens and the receipt language on receipts. - Sample menu items, staff names and other shop data are not translated. - Adding a language means a catalog, a tag in `LOCALES`, and a review of receipt widths (labels must fit a 32-column line). - **Hub API:** catalog keys for errors are part of how the hub talks. Plugins still send their own words. --- # 0022. Finding the hub on the network - Status: Accepted - Date: 2026-10-28 ## Context A hub on a shop's LAN usually gets its IP address from the router. After a power cut or a router change, the address can change, and every paired device loses the hub until someone types the new address into each one (**Change hub address**, M7). The hub already announces itself over mDNS as `_openkedai._tcp` (ADR 0019), but nothing listened. Following a hub automatically has a risk that typing an address doesn't: a device follows whatever answers on the network. Anyone on the shop's Wi-Fi can announce a service with any name. A device that sent its token to such a service would hand over the shop's access. ## Decision **The hub's identity.** - Each hub makes a random **hub id** once and keeps it in its registry (`hub_state.hub_id`). - The id is in the hub's mDNS TXT record (`id`, next to `api`), in `GET /api/hub`, in the credentials from claiming, pairing and rejoining, and in `GET /api/devices/me`. - Devices store it with the hub's address (`hub.id`). It is optional in the protocol, so devices paired before M8.5 learn it the first time they follow the hub. **Proving the hub is the device's own, before the token is sent.** - The hub stores only the SHA-256 of each device token. A device that finds a candidate hub sends `POST /api/hub/prove` with its device id and a fresh random nonce (no token). - The hub answers with `HMAC-SHA256(key = token hash, message = nonce)` for each working token of that device. The device computes the same from its own token and compares. - Only a hub that holds the token's hash can answer. A new nonce each time means an answer can't be replayed. A removed device has no working token, so its old hub can't prove anything. - Only after the proof does the device send its token, to `GET /api/devices/me`. It follows the hub if the answer names the same shop and device. This is the same check as **Change hub address**. **When a device looks.** - The app feeds the sync engine's status to a `HubFollower`. It starts looking when the hub stops answering, or when the hub at the old address doesn't know the device (another hub took its address). - It looks 5 seconds after the hub is lost, then every 20 seconds while it stays lost. Each look browses for 4 seconds. A device follows a restarted hub within about 30 seconds of it answering again. - It considers only hubs advertising its hub id. A device without a stored id considers every hub that has one, and the proof filters the rest. - When the hub is still at its old address, the device just syncs again at once. - It doesn't look for a hub whose address is `https://`. Such a hub is on the internet or behind a proxy with a fixed name, so it doesn't move around a LAN. **Native code.** A new Expo module, `openkedai-discovery`, has one call: `browse(type, timeoutMs)`. It lists the services found, each with its IPv4 addresses, port and TXT record. - **iOS:** Network.framework's `NWBrowser`, resolving each service by connecting to it over IPv4. The app declares `NSBonjourServices: ["_openkedai._tcp"]` for iOS's local network permission. - **Android:** `NsdManager`, resolving one service at a time (older Android allows only one at a time). - **Web preview and Expo Go:** browsing isn't available, and the app simply doesn't look. **Joining.** The join screen and **Change hub address** list the hubs found on the network. Tapping one fills in its address; the pairing code is still needed. ## Consequences - **Hub IP changes:** devices reconnect on their own, with no one touching them. A fixed address in the router is no longer needed, though it is still a good idea. - **Spoofed hubs:** something announcing the shop's hub id gets a nonce, never a token. - A copy of the hub's registry (`hub.sqlite`) could pass the proof. Whoever has that file has the hub's data anyway. - Plain HTTP on the LAN can still be watched, as ADR 0019 says. - **Network limits:** mDNS doesn't cross routers or VLANs, and some guest Wi-Fi networks block it. There, devices keep retrying the old address, and **Change hub address** remains the way to move them. - **Android emulator:** the emulator's own network usually doesn't receive the Mac's mDNS, so checking discovery on Android may need a real device on the same Wi-Fi. - **Old hubs:** devices can't follow a hub older than M8.5. Its info has no hub id, and it has no prove route. - **Hub port:** the hub now advertises the port it actually listens on, not the configured one. They differ only when the configured port is 0. --- # 0023. Shipping the server, and backups - Status: Accepted - Date: 2026-10-28 ## Context M9 ships the hub to people who are not developers, on whatever they already have: - a Raspberry Pi or mini PC at the shop; - a $5 VPS; - Docker; - a hosting platform. ADR 0011 fixes the shape (one container, one process, one port, one data folder, amd64 and arm64), and ADR 0012 fixes the cost (SQLite per shop, no required services, a resource budget, backups to any S3-compatible storage). What remained was how to package, release and back up. ## Decision **What runs.** - The server is one esbuild bundle. `scripts/package-server.ts` puts it in a folder with: - the built back office; - better-sqlite3's JavaScript and its prebuilt binary for the platform, and nothing else from `node_modules`. - The Docker image and the release tarballs are that folder. - better-sqlite3 13 ships prebuilt binaries for Linux, macOS and Windows on x64 and arm64, so its install script is not allowed to run. No compiler is needed anywhere. - The server starts without `--enable-source-maps`. Loading the map costs about 75 MB, half the idle budget. `NODE_OPTIONS=--enable-source-maps` turns it on for debugging. **The image.** - **Base:** `node:22-bookworm-slim`. It is glibc, like the hosts the release tarball targets. Alpine would save a few MB but makes the image and the tarball differ. - **User:** it runs as `node`. - **Data:** in `/data`. - **Size:** about 78 MB compressed, of which about 3 MB is OpenKedai. - **Health check:** none in the image, so idle stays idle. The Compose file adds one each minute. **Releases.** A `v*` tag runs `release.yml`: - Each architecture is built on a native runner (no QEMU), checked against the resource budget, and pushed by digest. - A stress test runs, compressed to 6 minutes. - The digests are merged into one multi-arch image: - on GitHub Container Registry, and on Docker Hub when it is configured; - tagged with the version, the minor version and `latest`. Pre-releases move neither. - The installer's tarballs are copied out of the same images and attached to a GitHub release with `SHA256SUMS`. - Runners come from repository variables (`LINUX_RUNNER`, `ARM_RUNNER`), so moving to another runner provider is a settings change. **The Linux installer** (`deploy/install.sh`): - **Node.js:** it downloads its own Node.js 22 from nodejs.org, rather than using the distribution's, which varies from release to release. - **Checks:** both downloads are checked against their published checksums. - **Layout:** the hub goes in `/opt/openkedai/releases/