Initial commit: EatMe attendance tracker

Google-SSO PWA for bistro employee clock-in/out, admin employee
management, and stats with CSV export. Express + SQLite backend,
React + Zustand frontend in the light-mono-tui design language.
Multi-stage Dockerfile, compose.yaml for image-based deploys, nginx
reverse-proxy template, and an OKF documentation bundle in docs/.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Michal Pemcak
2026-08-12 11:57:55 +02:00
commit fffcb73ea4
90 changed files with 3411 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
---
type: Architecture Overview
title: Authentication & authorization
description: Google ID-token verification, the session cookie, and the admin/employee role split.
tags: [architecture, auth, security]
timestamp: 2026-08-12T00:00:00Z
---
# Authentication & authorization
## Sign-in
1. The frontend loads the Google Identity Services script
(`https://accounts.google.com/gsi/client`, in `frontend/index.html`) and
renders the button in `frontend/src/auth/GoogleButton.tsx`.
2. Google returns a signed ID token to the browser (`credential`). The
frontend does **not** trust it — it POSTs it to `POST /api/auth/google`.
3. The backend verifies the token server-side with `google-auth-library`
(`backend/src/auth/google.ts`, `verifyGoogleIdToken`), checking the
audience against `GOOGLE_CLIENT_ID` and requiring `email_verified`.
## Role resolution
Two independent checks decide who gets in and as what role
(`backend/src/routes/auth.ts`):
- **Admin**: the verified email is in the `ADMIN_EMAILS` env var
(comma-separated, lowercased — `backend/src/env.ts`). Admins do **not**
need a row in the `employees` table.
- **Employee**: the verified email matches an **active** row in
[employees](/docs/data-model/employees.md).
If neither matches, login is rejected with 403 ("This account is not
registered as an EatMe employee"). This is how the employee whitelist is
enforced — there's no self-service signup.
## Session
On success the backend signs its own JWT (`backend/src/auth/session.ts`,
`issueSession`) containing `{ email, name, role, employeeId }`, and sets it
as an httpOnly, `SameSite=Lax` cookie (`eatme_session`, 12h TTL). All
subsequent `/api/*` requests carry that cookie
(`frontend/src/api/client.ts` sends `credentials: 'include'`); the backend
never re-derives role from the DB on every request — it trusts the signed
cookie until it expires.
`backend/src/auth/middleware.ts` provides three guards used by the routers:
- `requireAuth` — valid session cookie present
- `requireAdmin``role === 'admin'`
- `requireEmployee``employeeId != null` (blocks admins with no employee
profile from hitting `/api/attendance/*`)
## Related
- [System overview](./overview.md)
- [Auth API](/docs/api/auth-routes.md)
- [Employees table](/docs/data-model/employees.md)

View File

@@ -0,0 +1,109 @@
---
type: Architecture Overview
title: Build & deployment
description: esbuild backend bundle, Vite frontend build, the multi-stage Docker image, and how it ships to a host.
tags: [architecture, deployment, docker]
timestamp: 2026-08-12T12:00:00Z
---
# Build & deployment
## Backend build
`backend/package.json`'s `build` script runs `tsc --noEmit` (typecheck only)
then `node esbuild.config.js`, which bundles `src/index.ts` into a single
minified `dist/index.js` (~1.4MB, with sourcemap). `better-sqlite3` is
marked `external` in `esbuild.config.js` because it ships a native `.node`
binding that can't be bundled — it stays a real `node_modules` dependency at
runtime.
## Frontend build
Standard Vite build (`frontend/package.json``tsc -b && vite build`),
producing an optimized static `dist/` with a service worker
(`vite-plugin-pwa`, `frontend/vite.config.ts`). `VITE_*` env vars (just
`VITE_GOOGLE_CLIENT_ID`) are inlined at build time — they cannot be changed
at container runtime, only at image-build time.
## Docker image
The root `Dockerfile` is a three-stage build:
1. `frontend-build``npm ci` + `npm run build` in `frontend/`. Takes
`VITE_GOOGLE_CLIENT_ID` as a build arg.
2. `backend-build``npm ci` + `npm run build` in `backend/`, producing the
esbuild bundle.
3. `runtime` — fresh `npm ci --omit=dev` for `backend/package.json` on the
**same base image** as `backend-build` (so `better-sqlite3`'s native
binding is compiled for the environment it'll actually run in, rather
than copied from a different stage). Then copies in the backend bundle
(`dist/index.js`) and the frontend build output as `dist/public/`.
At runtime, `backend/src/index.ts` checks whether `dist/public` exists next
to itself and, if so, serves it with `express.static` plus a SPA fallback
route (`app.get(/^(?!\/api\/).*/, ...)`) — that's what makes the single
image serve both API and UI on one origin. In local dev this directory
doesn't exist, so the branch is skipped and the frontend's own Vite dev
server is used instead.
```bash
docker build --build-arg VITE_GOOGLE_CLIENT_ID=<id> -t eatme .
docker run -d -p 4000:4000 -v eatme-data:/app/data \
-e GOOGLE_CLIENT_ID=<id> -e ADMIN_EMAILS=<emails> \
-e JWT_SECRET=<secret> -e CORS_ORIGIN=<origin> -e COOKIE_SECURE=true \
eatme
```
The SQLite file lives at `DB_PATH` (default `/app/data/eatme.db` in the
image) — mount `/app/data` as a volume or it's lost on container recreate.
## Shipping to a host
The image is always built where its target platform matches the deploy
host, and shipped as a built artifact — never by copying the repo to the
server and building there (keeps source off prod hosts, no build toolchain
needed remotely, and the exact artifact tested locally is what runs). If
building on an Apple Silicon Mac for an x86_64 server, `--platform
linux/amd64` is required or the image won't run there at all.
```bash
docker build --platform linux/amd64 \
--build-arg VITE_GOOGLE_CLIENT_ID=<id> -t eatme:latest .
docker save eatme:latest | gzip | ssh <host> "gunzip | docker load"
```
Only `compose.yaml` (root of the repo) and a `.env` next to it (see
`.env.example`) then need to reach the host — `compose.yaml` references
`image: eatme:latest` (not `build:`), so `docker compose up -d` just starts
the already-loaded image. Redeploying a new version is the same
build → save → load → `docker compose up -d` sequence again.
```bash
scp compose.yaml <host>:~/eatme/compose.yaml
ssh <host> "cd ~/eatme && docker compose up -d"
```
TLS termination is handled by an nginx reverse proxy in front of the
container (not part of this repo) — a template server block lives at
`deploy/<domain>` for whichever host runs nginx, proxying
`https://<domain>` to `http://<deploy-host>:<published-port>`. It's a plain
file to be moved into `/etc/nginx/sites-available/` (and symlinked into
`sites-enabled/`) by whoever has sudo on that host — nothing here writes to
system nginx config directly.
## Environment variables
| Var | App | Required | Notes |
|---|---|---|---|
| `GOOGLE_CLIENT_ID` | backend | yes | must match frontend's `VITE_GOOGLE_CLIENT_ID` |
| `ADMIN_EMAILS` | backend | yes | comma-separated |
| `JWT_SECRET` | backend | yes | session signing key |
| `DB_PATH` | backend | no | default `./data/eatme.db` |
| `CORS_ORIGIN` | backend | no | default `http://localhost:5173` |
| `COOKIE_SECURE` | backend | no | set `true` behind HTTPS |
| `PORT` | backend | no | default `4000` |
| `VITE_GOOGLE_CLIENT_ID` | frontend | yes | build-time only |
## Related
- [System overview](./overview.md)

View File

@@ -0,0 +1,5 @@
# Architecture
- [System overview](./overview.md) - how the backend, frontend, and database fit together
- [Authentication & authorization](./auth-flow.md) - Google ID-token verification, session cookie, admin/employee roles
- [Build & deployment](./deployment.md) - esbuild bundle, Vite build, multi-stage Dockerfile, env vars

View File

@@ -0,0 +1,51 @@
---
type: Architecture Overview
title: System overview
description: How the EatMe frontend, backend, and database fit together.
tags: [architecture, backend, frontend]
timestamp: 2026-08-12T00:00:00Z
---
# System overview
EatMe is a small monorepo with two independently-runnable apps that ship as
one Docker image in production.
- **`backend/`** — Express + TypeScript API, SQLite via `better-sqlite3`
(entry point `backend/src/index.ts`). Owns all state: employee whitelist,
attendance events, sessions.
- **`frontend/`** — Vite + React + TypeScript PWA (entry point
`frontend/src/App.tsx`). Talks to the backend only over `/api/*`; in dev,
Vite proxies that path to `http://localhost:4000` (see
`frontend/vite.config.ts`).
## Request flow
1. Employee/admin signs in with Google Identity Services in the browser
(ID-token flow, no server-side OAuth redirect) — see
[Auth flow](./auth-flow.md).
2. The frontend POSTs the Google ID token to `POST /api/auth/google`; the
backend verifies it, issues its own JWT session cookie, and from then on
every `/api/*` call rides on that cookie (`credentials: 'include'` in
`frontend/src/api/client.ts`).
3. All app logic (fetching, polling, derived state) lives in Zustand stores
under `frontend/src/store/` — see [Frontend](/docs/frontend/index.md).
Components are thin consumers of store selectors/actions.
4. In production the backend also serves the built frontend as static files
(`dist/public/`, see [Deployment](./deployment.md)) — one process, one
container, one origin.
## Design language
The UI follows the "light monochrome TUI" style defined at
`~/doc/concepts/ui/light-mono-tui.md`: grey page background, black type and
1px borders, mono font, inverted fills for active/hover state, no accent
colors, no border-radius. Implemented as plain CSS tokens in
`frontend/src/styles/tui.css`.
## Related
- [Auth flow](./auth-flow.md)
- [Deployment](./deployment.md)
- [Data model](/docs/data-model/index.md)
- [API](/docs/api/index.md)