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

7.5 KiB

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:

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:

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:

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_SANDBOXtrue 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:

- ${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:

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:

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.comhttp://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