No description
  • Python 37.8%
  • JavaScript 35.5%
  • Svelte 22.8%
  • CSS 3.2%
  • Dockerfile 0.4%
  • Other 0.3%
Find a file
maks 959f1f485a
All checks were successful
CI / backend (push) Successful in 2m34s
CI / frontend (push) Successful in 1m16s
CI / pre-commit (push) Successful in 1m20s
Add Lidl Plus retailer integration with in-app linking
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>
2026-05-17 09:56:09 +02:00
.forgejo/workflows Harden production deploy: healthchecks, rate limits, SHA256, backups 2026-04-22 17:11:27 +02:00
backend Add Lidl Plus retailer integration with in-app linking 2026-05-17 09:56:09 +02:00
frontend Add Lidl Plus retailer integration with in-app linking 2026-05-17 09:56:09 +02:00
.env.example Add Lidl Plus retailer integration with in-app linking 2026-05-17 09:56:09 +02:00
.gitignore Add Playwright E2E harness for auth, cards, admin, sharing 2026-04-22 23:29:31 +02:00
.pre-commit-config.yaml Harden production deploy: healthchecks, rate limits, SHA256, backups 2026-04-22 17:11:27 +02:00
.secrets.baseline Satisfy pre-commit on Tuš coupon fixture 2026-04-25 11:45:28 +02:00
CLAUDE.md Add Lidl Plus retailer integration with in-app linking 2026-05-17 09:56:09 +02:00
docker-compose.yml Add Lidl Plus retailer integration with in-app linking 2026-05-17 09:56:09 +02:00
README.md Add Lidl Plus retailer integration with in-app linking 2026-05-17 09:56:09 +02:00

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: 25 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 via idb. Barcode rendering with jsbarcode (1D) and qrcode (QR). Scanner uses the native BarcodeDetector where available, falling back to @zxing/browser.
  • Deploy — Docker Compose. Frontend container is nginx serving the built SPA + proxying /api to 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 /apihttp://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.png
  • frontend/public/icons/icon-512.png
  • frontend/public/icons/icon-maskable.png
  • frontend/public/icons/apple-touch-icon.png
  • frontend/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 .env contains a real secret. Run openssl rand -hex 32 and 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 migrationsbackend/app/main.py::_migrate_schema runs ad-hoc ALTER TABLE statements. Move to Alembic before the next schema change.
  • passlib is effectively unmaintained (last release 2020). Swap for direct bcrypt or argon2-cffi.
  • JWT in localStorage — XSS-accessible. Strict CSP (no 'unsafe-eval', no inline scripts) keeps this contained for v1; migrate to HttpOnly cookies + 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.