# blinds_express — Architecture & Design Record ## What this is Node.js/Express API for the BlindMaster smart blind controller. Manages users, devices, peripherals, groups, scheduled jobs, email auth, and real-time WebSocket events. Served at `blindmaster.wahwa.com` via Cloudflare Tunnel. --- ## Infrastructure overview ``` Cloudflare Tunnel ──► 127.0.0.1:3002 (backend container) │ ┌─────────────┴──────────────┐ ▼ ▼ postgres:5432 (bridge) mongo:27017 (bridge) postgres_data volume mongo_data volume ``` All three containers live on a Docker bridge network created by Compose. Nothing is exposed on a host port except the backend's `127.0.0.1:3002` loopback bind. --- ## Key design decisions ### Cloudflare Tunnel replaces nginx Express serves the static `public/` directory via `express.static`, handles all API routes, and upgrades WebSocket connections. A Cloudflare Tunnel pointing directly at `http://127.0.0.1:3002` is sufficient — nginx is not needed. WebSocket proxying must be enabled in the Cloudflare dashboard for the tunnel. ### Dedicated postgres container (not host postgres) The host runs a native `postgresql@16-main.service` on `127.0.0.1:5432` used by other services. LabWise also runs its own `labwise-postgres-1` container, but that container does **not** expose a host port — it lives on LabWise's bridge network only. A third, isolated postgres container for blinds_express costs ~150 MB RAM but gives clean isolation, easy `pg_dump` migration, and full parity with LabWise's container model. To migrate existing data from the host postgres: ```bash pg_dump -U blinds_db | \ docker compose exec -T postgres psql -U blinds blinds_db ``` ### Idempotent schema migration `db/schema.sql` uses `CREATE TABLE IF NOT EXISTS` for every table and a tail block of `ALTER TABLE … ADD COLUMN IF NOT EXISTS` for columns added after initial creation. `db/migrate.js` runs this file on every container start via the Dockerfile CMD: ``` CMD ["sh", "-c", "node db/migrate.js && node index.js"] ``` If the build fails, `docker compose build` exits non-zero and `docker compose up` is never reached — existing containers keep running. This matches LabWise's migration pattern exactly. ### APNs push notifications (no third-party library) Replaced Firebase Admin (`firebase-admin`) with hand-rolled APNs over HTTP/2 using only Node built-ins (`http2`, `crypto`). The reference implementation was `~/LockInBroAPI/app/services/push.py`, which was known-working. Critical detail: Node's `crypto.sign` defaults to DER encoding for ECDSA. APNs requires **P1363 (IEEE) format** (raw r‖s concatenation). Fixed with: ```js crypto.sign('sha256', Buffer.from(signingInput), { key: keyObj, dsaEncoding: 'ieee-p1363', }); ``` JWT is cached 55 minutes (APNs invalidates tokens older than 60 min). HTTP/2 session is opened per request (connection reuse is a future optimisation). A `410 Unregistered` response means the device token is dead — the handler nulls it in the database immediately: ```js if (result.status === 410 && pool) { await pool.query("UPDATE users SET apns_token=NULL WHERE apns_token=$1", [token]); } ``` Env vars (matching LockInBroAPI naming): - `APNS_KEY_ID` — the 10-char key ID from the `.p8` filename - `APNS_TEAM_ID` — Apple Developer team ID - `APNS_P8_PATH` — container-internal path to the key file (e.g. `/app/AuthKey.p8`) - `APPLE_BUNDLE_ID` — app bundle identifier - `APNS_SANDBOX` — `true` for development, omit or `false` for production ### APNs key file mounting The `.p8` file is never committed (`.gitignore` includes `*.p8`). It is mounted read-only into the container via a Compose volume: ```yaml - ${APNS_P8_HOST_PATH:-./AuthKey.p8}:${APNS_P8_PATH:-/app/AuthKey.p8}:ro ``` Set both in `.env`: - `APNS_P8_HOST_PATH=./AuthKey_A7ASKB9B7V.p8` (actual filename on the host) - `APNS_P8_PATH=/app/AuthKey.p8` (path inside the container, read by `push.js`) ### Mailgun replaces AWS SES Removed `@aws-sdk/client-sesv2` and `nodemailer`; added `mailgun.js` + `form-data`. `mailer.js` uses `mg.messages.create()` matching LabWise's `email.ts` pattern. Verification emails link to `blindmaster.wahwa.com`. ### NODE_VERSION build arg `.node-version` contains the bare version (`22.11.0`). The Gitea Actions workflow appends `-alpine` and writes it into `.env` before `docker compose build`: ```yaml NODE_VER=$(cat .node-version) sed -i '/^NODE_VERSION=/d' .env || true echo "NODE_VERSION=${NODE_VER}-alpine" >> .env docker compose build ``` `Dockerfile.backend` consumes it as a build arg: ```dockerfile ARG NODE_VERSION=22-alpine FROM node:${NODE_VERSION} ``` ### CI/CD (Gitea Actions) `.gitea/workflows/deploy.yaml` mirrors LabWise's workflow exactly: 1. Alpine runner container with `openssh-client`, `iproute2`, `git` 2. SSH key from `secrets.DEPLOY_SSH_KEY` → `~/.ssh/id_rsa` 3. Host IP discovered from the Docker bridge gateway (`ip route | awk '/default/'`) 4. SSH into host, `git pull`, patch `NODE_VERSION` in `.env`, `docker compose build`, `docker compose up -d`, `docker image prune -f` `set -e` in the remote SSH block means a failed build aborts before `up -d`, leaving existing containers untouched. --- ## Dependencies removed | Package | Reason | |---|---| | `firebase-admin` | Replaced by hand-rolled APNs in `push.js` | | `@aws-sdk/client-sesv2` | Replaced by Mailgun | | `nodemailer` | No longer needed | ## Dependencies added | Package | Reason | |---|---| | `mailgun.js` | Mailgun API client | | `form-data` | Required peer dep of `mailgun.js` | | `cron-parser` | Already used internally, made explicit | --- ## Files added / changed | File | Status | Notes | |---|---|---| | `Dockerfile.backend` | New | Alpine Node image, production deps only | | `docker-compose.yml` | New | postgres + mongo + backend | | `.gitea/workflows/deploy.yaml` | New | Replaces `.github/workflows/deploy.yml` | | `.github/workflows/deploy.yml` | Deleted | Replaced by Gitea Actions | | `db/schema.sql` | New | Full idempotent schema | | `db/migrate.js` | New | Reads schema.sql, runs via pg Pool | | `mailer.js` | Rewritten | SES/nodemailer → Mailgun | | `push.js` | New | Hand-rolled APNs, no library | | `index.js` | Modified | FCM → APNs, port from env, listen on 0.0.0.0 | | `db.js` | Modified | Mongo URI from `MONGO_URI` env var | | `package.json` | Modified | Deps swapped, `"start"` script added | | `.env.example` | New | Documents all required env vars | | `.dockerignore` | New | Excludes node_modules, .env, .git, etc. | | `.node-version` | New | `22.11.0` — consumed by deploy workflow | --- ## Port reference | Service | Host binding | Notes | |---|---|---| | Backend API | `127.0.0.1:3002` | Cloudflare Tunnel → here | | postgres | none | Bridge-only | | mongo | none | Bridge-only | | Gitea | `:3000` | Separate service, not part of this compose | --- ## First-time setup checklist 1. Copy `.env.example` → `.env` and fill in all values 2. Set `APNS_P8_HOST_PATH` to the actual `.p8` filename (e.g. `./AuthKey_A7ASKB9B7V.p8`) 3. Place the `.p8` file in the project root (it is gitignored) 4. Add `DEPLOY_SSH_KEY` (private key for `adipu@host`) to Gitea repository secrets 5. Point the Cloudflare Tunnel for `blindmaster.wahwa.com` → `http://127.0.0.1:3002` 6. Enable WebSocket proxying in Cloudflare dashboard for that tunnel 7. Push to `main` — the Gitea workflow builds, migrates, and starts everything