Boskaf
A café system where the rules live in the database: QR ordering at the table, a live kitchen pass, and the admin behind both.
- Category
- Café Ordering & Operations
- Year
- 2026
- Status
- In Development
- Technologies
- Next.js, React, TypeScript, Supabase, PostgreSQL, Tailwind CSS, Vitest, Playwright
- Index
- 05 / 05

01Overview
Boskaf is the software for a café in Semarang: a public site, ordering from a QR code on the table, and the admin and kitchen screens the staff work from. It is one Next.js application and one Postgres database, split into three surfaces by hostname. The design question that shaped it was where the rules should live — and the answer here is the database. Prices, tax, discounts, status transitions and stock movements are all computed and enforced in Postgres, so the screens are a way to ask for something rather than the thing deciding what is allowed.
02Problem
A café system has three audiences with incompatible needs — a customer at a table who will not install anything, a barista who needs one screen that is never wrong, and an owner who needs to change prices without a deploy. The usual shortcut is to let the client compute the order and have the server store what it is told, which makes every screen a place where money can be wrong. The harder constraint is that the people using it are busy: a wrong total or a stale menu is not an edge case to triage later, it is an argument at the counter.
03Approach
Treat the browser as untrusted and the database as the product. The ordering request carries product ids, option ids, quantities, a note, an idempotency key and optionally a promo code — there is no price or discount parameter anywhere in the server action or the database function, so a tampered payload has nothing to tamper with. Everything that decides money or state is a `SECURITY DEFINER` function, and the tables behind them have no write policies at all. The UI is then free to be optimistic, because it cannot be authoritative.
04System
One application, three surfaces, separated by origin. `src/proxy.ts` maps hostnames onto path prefixes from environment variables, so no domain is hardcoded and the public host serves 404 for the staff paths — the admin and kitchen surfaces exist at exactly one origin each. Beneath that sit three Supabase clients with three different jobs: a cookie-free anonymous client for public catalog reads, which is what lets the storefront stay statically cached; a session client for staff pages and actions; and a service-role client, marked `server-only`, for QR resolution and guest orders where there is no user to act as. The database is reached through all three, and trusts none of them: 41 tables, 134 functions, and row-level security underneath a deliberately narrow set of table, column, function and sequence privileges.
- 01
Surface
- StorefrontHome, menu, coffee beans, about and visit. Rendered from the catalog through the cookie-free client so the pages stay statically cached. The bean pages are a catalogue only — there is no cart and no checkout.
- QR ordering/order/t/[token] — the menu for one table, product pages with modifiers, the cart, and a receipt that tracks status. No customer account exists anywhere in the system.
- Admin · KitchenOrders, the till, products, categories, tables and QR codes, staff, promotions, settings and inventory — plus the kitchen pass on its own origin, so a tablet in the kitchen holds no admin session.
- 02
Edge
- proxy · host routingsrc/proxy.ts maps admin and kitchen hostnames onto path prefixes from environment variables, refreshes the Supabase session, and redirects anonymous staff requests before a page renders. The public host 404s the staff paths.
- Server ActionsEvery write goes through a Server Action that authorises first — `requireRole` / `requirePermission` for pages, `authorize()` for actions — reading role and active state from the database on every request, so a deactivation takes effect immediately.
- 03
Access
- anon clientNo cookies, so storefront responses stay cacheable. Holds SELECT on the ten tables the storefront renders and no write of any kind — and since the privilege pass, no function in the schema is executable by anon at all.
- staff sessionThe signed-in staff member's own identity, so `auth.uid()` resolves and every audited write records who did it. Used by admin and kitchen pages and actions.
- service roleserver-only. Used where there is no user to act as: resolving a QR token to a table and creating a guest order. Ownership is structural — the guest wrapper takes a session token and never an order id, so it can only touch the order it just created.
- 04
Data
- Postgres · RLS41 tables across catalog, tables and sessions, orders, promotions, inventory and recipes, staff profiles and settings. Orders, payments, sessions and inventory carry no write policy at all.
- SECURITY DEFINER134 functions. create_dine_in_order() prices from catalog rows and has no amount parameter; evaluate_promotion() computes a discount from the order's own stored lines; advance_dine_in_order() holds a row lock and a legal-move table. A balance is never writable — stock moves only through a function that writes its ledger row in the same transaction.
- RealtimeA signal table the kitchen subscribes to, mirroring only what the pass needs — it carries no money and no customer data. New tickets appear without polling and without a refresh.
- NEWPlaced and on the pass, not yet acknowledged.
- CONFIRMEDThe bar has taken it.
- PREPARINGBeing made.
- READYOn the counter. Going cold from here.
- SERVEDTaken to the table. Dine-in only.
- COMPLETEDterminalClosed.
- CANCELLEDterminalStopped before it was handed over.
Transitions
- NEWCONFIRMED
Confirm - CONFIRMEDPREPARING
Start preparing - PREPARINGREADY
Mark ready - READYSERVED
Mark served — dine-in - SERVEDCOMPLETED
Completed - READYCOMPLETED
Handed over — takeaway - NEWCANCELLED
Cancel — OWNER · MANAGER · CASHIER - PREPARINGCANCELLED
Cancel
The counter lifecycle, as the database enforces it. Dine-in and takeaway are identical up to READY and part there. Cancelling needs OWNER, MANAGER or CASHIER; every other move, SERVED included, is open to any staff role.
05Interface
A QR code on the table is the whole entry point: the token in the URL is the durable credential, and a guest session is derived from it into an httpOnly cookie scoped to the ordering routes. Reading the menu does not create one — a passer-by looking at prices from the pavement must not occupy a table — so the session begins when the first item is added. The cart lives in sessionStorage keyed by the table's token, which means it cannot follow a customer to another table, and it is reconciled against the catalog when reopened: a tab left open across a price change is told which line moved before the customer reaches the button, rather than being refused at checkout. On the staff side each ticket offers exactly one next action, because the screen is generated from the same legal-move table the database enforces.




06Build
- The order request carries no moneyProduct ids, option ids, quantities, a note, an idempotency key and optionally a promo code. That is the entire payload. Prices, option deltas, tax, service charge and discounts are computed inside the transaction from catalog rows, and there is no parameter anywhere for a client-supplied amount — so there is no code path where a tampered request can be wrong rather than refused.
- A replay returns the original order, not a second oneIdempotency is a unique index, not a disabled button. The same key hands back the order it already created. Applying a promo code is folded into the same transaction as creating the order, with its own guard: replaying a key that carried no code, now carrying one, used to rewrite the money columns of a committed transaction — and the guest guard only answers for a key owned by the caller's own session, because answering for a foreign key would have leaked another table's order number and total.
- Authorisation in three independent placesThe proxy turns anonymous staff requests away; `requireRole` and `authorize()` read role and active state from the database on every request, so a deactivation takes effect immediately rather than at the next login; and RLS plus `SECURITY DEFINER` refuse the same operations again underneath. Remove any one layer and two remain. The first account to sign up becomes an active OWNER and every later one starts inactive with least privilege — a claim serialised by a singleton primary key, because an existence check let two simultaneous sign-ups both become OWNER.
- Privileges are the layer under RLS, and they were wrong`TRUNCATE` is not subject to row-level security, and `ALTER DEFAULT PRIVILEGES` had been granting the full set on every new table — so a kitchen tablet's token could have erased the orders table. Fifteen functions were anonymously executable at `/rest/v1/rpc/<name>` without anyone intending it, two of which changed state. Both were closed across four migrations, and the defaults with them, so a new table now arrives shut rather than open. A test sweeps effective privileges across the whole schema, with negative controls that reintroduce a forbidden grant and require the sweep to fail.
- A switch that is off in the database, not in the UIAutomatic stock deduction is built and wired into the confirm edge, and it is off — `inventory_auto_deduct_enabled` is false and a CHECK constraint makes it unstorable as anything else. Feature switches are checked inside the writing functions rather than in a screen, so a switched-off module refuses a stale tab, a replayed request and a direct RPC alike. Switching one off deletes nothing: the items, balances, ledger rows and recipes all stay, and stay readable.
07Current Status
Running end to end on a local stack: 67 migrations, 41 tables and 134 functions, with the storefront, QR ordering, admin and kitchen all working against one database. The full path is exercised — scan a table's QR code, choose a drink and its modifiers, place the order, watch it arrive on the kitchen pass over Realtime, advance it through the legal moves, and see it in the admin orders list. Tests: 803 unit tests across 31 files pass; alongside them the repository carries 38 SQL suites, 12 race-condition and guard scripts, and 21 browser-driven end-to-end scripts. What is not done is the money. Payments are not implemented — Midtrans exists only as commented-out environment variables, nothing writes the payments table, and there is consequently no refund workflow. The coffee-bean pages are a catalogue with no cart and no checkout, so the online store cannot take an order or a promo code. There is no CMS and no reporting, inventory deduction is deliberately switched off, and the catalog is seeded sample data with placeholder photography. It is not deployed: the documented hosts are local, and the owner-facing checklist of what is needed before launch is still open.
08Links
- Source is a private repository