209 lines
7.5 KiB
Markdown
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
|