- Python 37.8%
- JavaScript 35.5%
- Svelte 22.8%
- CSS 3.2%
- Dockerfile 0.4%
- Other 0.3%
Introduces an opt-in retailer-integrations framework (gated by
CREDENTIALS_ENCRYPTION_KEY) with Lidl Plus as the first adapter. Users
link from the app — a background thread drives Selenium through Lidl's
current OAuth flow including SMS 2FA, and persists only the refresh
token, Fernet-encrypted at rest. Opening a Lidl Plus card fires an
auto-activate-coupons call on the owner's behalf; if it fails the QR
still renders ("scan anyway"), so the till workflow never breaks.
Login flow rewritten for today's Vue SPA at accounts.lidl.com: two-step
identifier→password submission with the new data-testid/id selectors,
accepts either an email or a phone, plus a stealth Chromium bring-up
(undetected-chromedriver + CDP shims) for reCAPTCHA Enterprise.
Known limitation: upstream lidl-plus 0.3.5 hard-codes runtime API hosts
that have since moved. The profile-API path prefix was fixed in this
commit (drop /profile/), so verify() / test endpoint works. The coupons
host still 404s on every known path — needs traffic capture from the
mobile app to refresh, tracked as follow-up. Debug dumps (screenshot +
HTML on failure) scrub password values before writing to disk.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|---|---|---|
| .forgejo/workflows | ||
| backend | ||
| frontend | ||
| .env.example | ||
| .gitignore | ||
| .pre-commit-config.yaml | ||
| .secrets.baseline | ||
| CLAUDE.md | ||
| docker-compose.yml | ||
| README.md | ||
Beeparr
Loyalty cards, fast at the register.
A small, self-hosted PWA for storing your retail loyalty-card barcodes and displaying them full-screen at checkout. Built for Slovenian chains (Mercator Pika, Spar, Hofer, Tuš, Lidl Plus, DM, Müller, Petrol Klub, …), but works with anything.
- Fast: tap a tile → barcode on screen in under a second, screen stays bright, works offline.
- Self-hosted: one
docker compose up -d, SQLite on a local volume. - Family-scale: 2–5 accounts, created by an admin. No public registration.
- Privacy: fonts are bundled, nothing hits third-party CDNs at runtime.
Quick start
cp .env.example .env
# pick a real JWT secret — the app refuses to start with the example value
sed -i "s/change-me-use-openssl-rand-hex-32/$(openssl rand -hex 32)/" .env
docker compose up -d --build
docker compose exec backend python create_user.py alice --admin
# open http://localhost:8080
Sign in as alice. Add a card: pick a preset (e.g. Mercator Pika) → scan or type the barcode → Save. Tap the tile to show the barcode.
Create additional family accounts from Settings → Users while logged in as an admin, or from the CLI:
docker compose exec backend python create_user.py bob
HTTPS is required in the real world
PWA install, Wake Lock, and camera access all need HTTPS (or localhost). Run Beeparr behind a reverse proxy that terminates TLS. A minimal Caddy config:
beeparr.example.com {
reverse_proxy localhost:8080
}
or Traefik, nginx, Cloudflare Tunnel — whatever you already run.
Set HSTS at the reverse proxy: Strict-Transport-Security: max-age=31536000; includeSubDomains. Caddy sets it automatically once the cert is issued; nginx/Traefik need an explicit add_header / middleware. The in-container nginx deliberately does not set HSTS — it listens on plain HTTP inside the network, and HSTS on a non-TLS response is a footgun.
Environment
| Var | Purpose | Default |
|---|---|---|
JWT_SECRET |
HS256 signing key — required, must not equal the example | — |
JWT_EXPIRE_DAYS |
token lifetime | 365 |
APP_PORT |
host port for the frontend container | 8080 |
CORS_ORIGINS |
comma-separated extra origins for dev with split hosts | empty |
CREDENTIALS_ENCRYPTION_KEY |
Fernet key for retailer-integration tokens (opt-in) | empty |
Secrets fail-fast: the backend refuses to start if JWT_SECRET is missing, blank, or equals the example value.
CREDENTIALS_ENCRYPTION_KEY is optional. When it's empty, retailer loyalty-app integrations (e.g. Lidl Plus coupon auto-activation) stay disabled and the /api/retailers endpoints return 503. To enable, generate a fresh Fernet key and add it to .env:
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
Losing the key makes stored retailer tokens unrecoverable — users just re-link via the CLI.
Linking a retailer loyalty app (Lidl Plus)
From the app (recommended). After adding a Lidl Plus card (or from Settings → Loyalty app integrations), click Link account and enter the Lidl Plus phone and password. The backend drives a headless browser through Lidl's OAuth flow, prompts you for the SMS code, and stores only the refresh token. From then on, opening the Lidl card in Beeparr fires an activate-all-coupons call against Lidl's API in the background, so the cashier scan picks up every eligible discount.
CLI fallback (headless deployments, no browser UI available):
docker compose exec backend python scripts/link_retailer.py --user alice --retailer lidl_plus
The link worker runs Selenium + Chromium in the backend container, so mem_limit is set to 1g in docker-compose.yml. Idle usage is well below that — only the brief link-flow spikes.
Stack
- Backend — FastAPI (Python 3.12), SQLAlchemy 2, Pydantic v2, bcrypt via passlib, JWT via pyjwt, SQLite in WAL mode.
- Frontend — Svelte 5 + Vite,
svelte-spa-router,vite-plugin-pwa, IndexedDB viaidb. Barcode rendering withjsbarcode(1D) andqrcode(QR). Scanner uses the nativeBarcodeDetectorwhere available, falling back to@zxing/browser. - Deploy — Docker Compose. Frontend container is nginx serving the built SPA + proxying
/apito the backend.
Data lives in ./data/loyalty.db. See Backups below for the WAL-safe way to copy it.
Backups
SQLite runs in WAL mode (journal_mode=WAL), so a plain cp loyalty.db while the backend is running can miss uncheckpointed pages in loyalty.db-wal. Use the bundled script, which goes through SQLite's online .backup() API:
mkdir -p ./data/backups
docker compose exec backend python backup.py /data/backups/loyalty-$(date +%F).db
A minimal daily cron on the host:
0 3 * * * cd /srv/beeparr && docker compose exec -T backend python backup.py /data/backups/loyalty-$(date +\%F).db
Restore is a plain copy while the backend is stopped:
docker compose stop backend
cp ./data/backups/loyalty-2026-04-22.db ./data/loyalty.db
rm -f ./data/loyalty.db-wal ./data/loyalty.db-shm
docker compose start backend
Rotating the JWT secret
sed -i "s/^JWT_SECRET=.*/JWT_SECRET=$(openssl rand -hex 32)/" .env
docker compose restart backend
All existing sessions are invalidated; users log in once again.
Development
Backend, outside Docker:
cd backend
python -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt
export JWT_SECRET=$(openssl rand -hex 32)
export DATABASE_URL=sqlite:///./dev.db
uvicorn app.main:app --reload
Frontend, outside Docker (expects backend on :8000):
cd frontend
npm install
npm run dev
Vite dev proxies /api → http://localhost:8000.
Testing
Unit tests:
# backend (inside backend/.venv, with requirements-dev.txt installed)
pytest
# frontend
cd frontend && npm run test
End-to-end tests run against a real backend + built frontend under Playwright. They spin up uvicorn and vite on dedicated ports (8100 / 5200) against a throwaway SQLite DB in frontend/e2e/.tmp/, so they don't collide with your dev stack.
cd frontend
npx playwright install --with-deps chromium # once
npm run test:e2e # headless
npm run test:e2e:ui # Playwright UI
The e2e harness sets DISABLE_RATE_LIMITS=1 on the backend so the /auth/login 10/min limiter doesn't 429 repeated logins across tests. Never set this in production.
What's in v1 (and what isn't)
In:
- Auth (admin creates users, no public signup).
- Per-user cards with presets, colour, notes.
- Full-screen barcode view with Wake Lock and pure-white backdrop.
- Camera scanner for 1D and QR.
- Offline browsing and optimistic offline mutations: tile grid + CardView work with the network off, and add/edit/delete queue to an IndexedDB outbox and flush on reconnect.
- Reorder via long-press + drag.
- Admin user management UI.
- PWA install (iOS Add-to-Home-Screen, Android install prompt).
Not yet:
- Public registration / password reset — by design.
- Custom per-card logo uploads — tiles use brand colour plus initial letter.
- Automatic HTTPS — use a reverse proxy.
- Native apps — PWA only.
Placeholder icons
The app ships with generated placeholder icons (a minimal barcode-mark on black). They're fine for v1 but not distinctive; replace them before going public:
frontend/public/icons/icon-192.pngfrontend/public/icons/icon-512.pngfrontend/public/icons/icon-maskable.pngfrontend/public/icons/apple-touch-icon.pngfrontend/public/icons/favicon.svg
Keep the same filenames and sizes and the manifest picks them up automatically.
Upgrading
The backend container now runs as UID 1000 (app) instead of root. If your existing ./data dir is owned by root from an older build, chown it once on the host before the next docker compose up:
sudo chown -R 1000:1000 ./data
If you prefer to keep root, override the container user in docker-compose.yml with user: "0:0".
Troubleshooting
- "JWT_SECRET must be set to a real secret" — the backend container refuses to start until
.envcontains a real secret. Runopenssl rand -hex 32and paste that. - Wake Lock not honored on iOS — the browser silently drops the lock on tab switches. We re-acquire on
visibilitychange, but if the screen still dims, check that iOS Low Power Mode is off. - Lidl Plus QR doesn't scan at the register — the official app uses a dynamic QR that changes per session; a static snapshot from your physical card won't always work. Use the in-store paper receipt scanner or the official app for Lidl until/unless they publish a static identifier.
- EAN-13 validation error — enter 12 digits and Beeparr computes the 13th; enter 13 and we validate the checksum.
Known debt
Tracked but deliberately unfixed in v1; revisit as the product grows:
- Schema migrations —
backend/app/main.py::_migrate_schemaruns ad-hocALTER TABLEstatements. Move to Alembic before the next schema change. passlibis effectively unmaintained (last release 2020). Swap for directbcryptorargon2-cffi.- JWT in
localStorage— XSS-accessible. Strict CSP (no'unsafe-eval', no inline scripts) keeps this contained for v1; migrate toHttpOnlycookies + CSRF tokens when the app grows beyond family scope. - CSP
'wasm-unsafe-eval'— only present to unblock Tesseract.js OCR. Remove if OCR is dropped. - Placeholder icons under
frontend/public/icons/— see note above.
License
Private / personal use. Ship what you want on your own infra.