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:
58
docs/architecture/auth-flow.md
Normal file
58
docs/architecture/auth-flow.md
Normal 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)
|
||||
109
docs/architecture/deployment.md
Normal file
109
docs/architecture/deployment.md
Normal 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)
|
||||
5
docs/architecture/index.md
Normal file
5
docs/architecture/index.md
Normal 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
|
||||
51
docs/architecture/overview.md
Normal file
51
docs/architecture/overview.md
Normal 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)
|
||||
Reference in New Issue
Block a user