Files
blinds_express/ARCHITECTURE.md
Aditya Pulipaka 7da8bda5eb
All checks were successful
Deploy to Server / deploy (push) Successful in 18s
end-to-end CI to match containerization on rest of adipu_server
2026-05-05 00:10:39 +00:00

209 lines
7.5 KiB
Markdown

# 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 <host_user> 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