Backend deploy target is self-hosted Docker (VPS / Kubernetes / Docker Engine), not Cloud Run. Add a multi-stage Dockerfile (Node 20, bcrypt compiled in build stage, non-root runtime), .dockerignore, a staging docker-compose, and DEPLOY.md covering install, build, migrate, run, and log mapping/rotation. Pin engines.node>=20. Update deployment.md runbook and backend/CLAUDE.md infra line off Cloud Run. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7.1 KiB
Halo Bestie — Backend
Fastify.js REST API serving both mobile apps and the internal control center.
See root
CLAUDE.mdfor full project context and architectural decisions.
Stack
- Runtime: Node.js + Fastify.js
- Database: PostgreSQL via GCP Cloud SQL
- Auth: Self-managed JWT (HS256 access, 1h) + opaque refresh token (30d, rotated, bcrypt-hashed in
auth_sessions). Firebase Auth removed in Phase 3.4 (commitf860ab6).firebase-adminis kept but only for FCM messaging. - Payment: Xendit
- Infra: Self-hosted Docker (VPS / Kubernetes / Docker Engine) — not Cloud Run. Multi-stage Dockerfile; deploy + log runbook in DEPLOY.md. DB is PostgreSQL (managed or self-hosted).
Two Listeners
Public (0.0.0.0:3000) → client_app + mitra_app routes
Internal (private IP:3001) → control_center routes only
Internal listener must never be exposed to the public internet.
Route Namespacing
/api/client/... → client app routes
/api/mitra/... → mitra app routes
/api/shared/... → shared routes (e.g. auth, refresh, logout, anonymous)
/internal/... → control center routes (internal listener only)
Auth Flow
- Mobile (client/mitra):
Authorization: Bearer <access_token>header. Access token is our own JWT (HS256,AUTH_JWT_SECRET), with claims{ sub, user_type, session_id }. Refresh viaPOST /api/shared/auth/refreshwith the opaque refresh token in the body. - Control center: Access token in
Authorization: Bearer(kept in memory by the SPA). Refresh token lives in anhttpOnlySecure cookie; refresh callsPOST /internal/auth/refreshwithcredentials: 'include'. - Entry points:
- Anonymous customer:
POST /api/shared/auth/anonymous - Phone OTP (customer/mitra):
/api/{client,mitra}/auth/otp/{request,verify}— Fazpass is stubbed inotp.service.js; code is logged to the backend console ([OTP STUB] phone=… code=…) until real API docs arrive. - Google/Apple:
/api/client/auth/{google,apple}(client_app only — creds pending) - CC login:
POST /internal/auth/login(email + bcrypt password)
- Anonymous customer:
- Middleware:
authenticateplugin verifies the JWT and attachesrequest.auth = { userType, userId, sessionId }. WebSocket handshake uses the same verification. No DB lookup on every request — the user ID is encoded in the token.
Key Conventions
- All routes must be authenticated unless explicitly marked public (auth + anonymous routes are the exceptions)
- Internal routes additionally require
request.auth.userType === 'cc_user' - Use Fastify plugins for shared middleware (auth, error handling, logging)
- Business logic lives in
services/— never directly in route handlers - Never reintroduce Firebase Auth.
firebase-adminis FCM-only; do not import.auth()from it.
Config-Source Convention
Two distinct knob-types exist; do not conflate them:
- DB-stored (
app_configtable, mutable via CC SettingsPage at runtime): used for operator-tunable values that may change between deploys without a code roll —mitra_stale_after_seconds,extension_timeout_seconds,pricing_tiers,support_handles_json,max_customers_per_mitra, etc. Read via getters inservices/config.service.js. Cache invalidation goes throughvalkeypub/sub when needed. - Env-driven (
process.env, set per deployment via.envor Cloud Run env vars): used for deploy-fixed values that should never differ between operator actions —MITRA_HEARTBEAT_CADENCE_SECONDS,FIREBASE_SERVICE_ACCOUNT_PATH,AUTH_JWT_SECRET,DATABASE_URL. Always expose via a getter helper with a sane default + numeric parsing (seegetMitraHeartbeatCadenceSecondsin config.service.js for the pattern).
When a new value needs to flow from CC → app, prefer DB. When it's a deploy-fixed contract (e.g. heartbeat cadence the apps must honor, Xendit credentials, callback tokens), prefer env. CC inputs that depend on env values (e.g. min/max validation) read the env-derived value via the same config endpoint that surfaces the DB value, and the PATCH route validates against it.
Timezone
The backend is UTC end-to-end, and that is independent of the server/OS timezone.
- All timestamp columns are
TIMESTAMPTZ, which stores an absolute UTC instant (no per-row zone). Storage does NOT depend on the session/server timezone. - All timestamp writes use server-computed instants (
NOW(),NOW() + interval), never app-supplied wall-clock. There is no session-tz-dependent SQL (date_trunc/::date/CURRENT_DATE/AT TIME ZONE) anywhere today, so correctness does not rely on the timezone setting. - The
postgresdriver returns JSDate(an absolute instant); Fastify serializes it via.toISOString(), so the API always emits ISO-8601 with aZ. Flutter parses that to a UTCDateTimeand.toLocal()s only at display time. Rule for the apps: store/transport UTC, convert to local only when rendering a wall-clock.
SERVER_TZ (env, default UTC) is belt-and-suspenders, not a fix for any live bug: db/client.js pins the DB session timezone and server.js pins process.env.TZ to it, then asserts at boot that the DB session matches (logs [tz] … / a loud warning otherwise). This keeps any future date_trunc/::date-style reporting deterministic and surfaces a misconfigured server early. Getter: getServerTimezone() in config.service.js (db/client.js reads the env directly to avoid an import cycle — keep the UTC default in sync). The thing that genuinely matters operationally is NTP clock sync, not the timezone — a wrong wall-clock breaks NOW() and timers; a wrong timezone does not.
FCM Channel Convention
Single channel halobestie_chat_v2 is shared by both apps and ships the branded halobestie_notif.ogg sound. Backend FCM payloads should always target this channel ID via android.notification.channelId:
android: { priority: 'high', notification: { channelId: 'halobestie_chat_v2' } }
Channel must be created from native Android (Kotlin) code, not from Dart via flutter_local_notifications. The plugin's AndroidNotificationChannel sets AudioAttributes with CONTENT_TYPE_UNKNOWN; on Android 13+ (API 33) this causes notifications to post and be tagged isNoisy=true, but systemui never requests audio focus and the sound is silently dropped. The native channel must use setContentType(CONTENT_TYPE_SONIFICATION) alongside USAGE_NOTIFICATION. See MainActivity.kt in both client_app and mitra_app. The Dart-side AndroidNotificationChannel definition stays in notification_service.dart so flutter_local_notifications.show() resolves the channel id, but its createNotificationChannel call is a no-op since the native channel already exists (channels are immutable on API 26+).
Do not introduce per-recipient or per-feature channels lightly. If a new sound is required (e.g. payment alert), bump the channel ID (halobestie_chat_v3) and update both apps' native MainActivity + Dart definition + backend simultaneously — Android binds channel sound at create-time on API 26+, so mutating the existing channel doesn't pick up the new sound for installed users.