Prepare QF100 pilot and Debian app deploy
This commit is contained in:
@ -20,7 +20,7 @@ TRACE_HEADER=x-request-id
|
||||
IDEMPOTENCY_TTL_MS=300000
|
||||
INTEGRATION_WEBHOOK_SECRET=dev-callback-secret
|
||||
MQTT_PUBLISH_MODE=simulator
|
||||
MQTT_BROKER_URL=mqtts://mqtt.iptek.co:8883
|
||||
MQTT_BROKER_URL=mqtts://broker.bizone.id:8883
|
||||
MQTT_USERNAME=qris-backend
|
||||
MQTT_PASSWORD=change-me
|
||||
MQTT_CLIENT_ID=qris-platform-backend
|
||||
@ -30,6 +30,11 @@ MQTT_SUBSCRIBE_TOPICS=devices/+/uplink/#
|
||||
MQTT_PUBLISH_FORCE_FAIL_ALL=false
|
||||
MQTT_PUBLISH_FORCE_FAIL_DEVICE_IDS=
|
||||
MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS=15000
|
||||
QF100_MQTT_BROKER_HOST=
|
||||
QF100_MQTT_BROKER_PORT=0
|
||||
QF100_MQTT_USERNAME=
|
||||
QF100_MQTT_PASSWORD=
|
||||
QF100_MQTT_KEEP_ALIVE_SECONDS=60
|
||||
DYNAMIC_QR_EXPIRY_SCHEDULER_ENABLED=true
|
||||
DYNAMIC_QR_EXPIRY_SWEEP_INTERVAL_MS=60000
|
||||
DYNAMIC_QR_EXPIRY_SWEEP_LIMIT=100
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,3 +2,4 @@ node_modules/
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
.env
|
||||
QF100-60s-l511-SecondApp-260107/
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# MQTT Broker Mosquitto on Debian 13
|
||||
|
||||
Panduan operasional untuk menyiapkan broker MQTT awal platform QRIS Soundbox di Debian 13 dengan subdomain `mqtt.iptek.co`.
|
||||
Panduan operasional untuk menyiapkan broker MQTT awal platform QRIS Soundbox di Debian 13 dengan subdomain `broker.bizone.id`.
|
||||
|
||||
Keputusan arsitektur terkait:
|
||||
- `D-026`: broker MQTT sungguhan ditunda sampai infrastruktur siap; simulator/outbox tetap dipakai selama transisi.
|
||||
@ -9,7 +9,7 @@ Keputusan arsitektur terkait:
|
||||
## Target Setup
|
||||
|
||||
- Broker: Eclipse Mosquitto.
|
||||
- Domain: `mqtt.iptek.co`.
|
||||
- Domain: `broker.bizone.id`.
|
||||
- MQTT TLS publik: `8883/tcp`.
|
||||
- MQTT local-only: `1883/tcp` pada `127.0.0.1`.
|
||||
- TLS: Let's Encrypt.
|
||||
@ -19,10 +19,10 @@ Keputusan arsitektur terkait:
|
||||
|
||||
## DNS dan Paket
|
||||
|
||||
Pastikan DNS `mqtt.iptek.co` sudah mengarah ke public IP server.
|
||||
Pastikan DNS `broker.bizone.id` sudah mengarah ke public IP server.
|
||||
|
||||
```bash
|
||||
dig +short mqtt.iptek.co
|
||||
dig +short broker.bizone.id
|
||||
curl -4 ifconfig.me
|
||||
```
|
||||
|
||||
@ -55,7 +55,7 @@ Jangan buka `1883/tcp` ke internet. Listener `1883` hanya untuk localhost/intern
|
||||
Ambil sertifikat Let's Encrypt:
|
||||
|
||||
```bash
|
||||
sudo certbot certonly --standalone -d mqtt.iptek.co
|
||||
sudo certbot certonly --standalone -d broker.bizone.id
|
||||
```
|
||||
|
||||
Copy sertifikat ke lokasi yang bisa dibaca Mosquitto:
|
||||
@ -64,11 +64,11 @@ Copy sertifikat ke lokasi yang bisa dibaca Mosquitto:
|
||||
sudo install -d -o root -g mosquitto -m 750 /etc/mosquitto/certs
|
||||
|
||||
sudo install -o root -g mosquitto -m 640 \
|
||||
/etc/letsencrypt/live/mqtt.iptek.co/fullchain.pem \
|
||||
/etc/letsencrypt/live/broker.bizone.id/fullchain.pem \
|
||||
/etc/mosquitto/certs/fullchain.pem
|
||||
|
||||
sudo install -o root -g mosquitto -m 640 \
|
||||
/etc/letsencrypt/live/mqtt.iptek.co/privkey.pem \
|
||||
/etc/letsencrypt/live/broker.bizone.id/privkey.pem \
|
||||
/etc/mosquitto/certs/privkey.pem
|
||||
```
|
||||
|
||||
@ -84,7 +84,7 @@ Isi:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
DOMAIN="mqtt.iptek.co"
|
||||
DOMAIN="broker.bizone.id"
|
||||
|
||||
install -o root -g mosquitto -m 640 \
|
||||
"/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" \
|
||||
@ -206,7 +206,7 @@ Terminal 1, subscribe sebagai backend:
|
||||
|
||||
```bash
|
||||
mosquitto_sub \
|
||||
-h mqtt.iptek.co \
|
||||
-h broker.bizone.id \
|
||||
-p 8883 \
|
||||
-u qris-backend \
|
||||
-P 'PASSWORD_BACKEND' \
|
||||
@ -218,7 +218,7 @@ Terminal 2, publish sebagai device:
|
||||
|
||||
```bash
|
||||
mosquitto_pub \
|
||||
-h mqtt.iptek.co \
|
||||
-h broker.bizone.id \
|
||||
-p 8883 \
|
||||
-u DEVICE_UUID_FROM_PLATFORM \
|
||||
-P 'PASSWORD_DEVICE' \
|
||||
@ -230,7 +230,7 @@ Test ACL negatif:
|
||||
|
||||
```bash
|
||||
mosquitto_pub \
|
||||
-h mqtt.iptek.co \
|
||||
-h broker.bizone.id \
|
||||
-p 8883 \
|
||||
-u DEVICE_UUID_FROM_PLATFORM \
|
||||
-P 'PASSWORD_DEVICE' \
|
||||
@ -260,7 +260,7 @@ Saat adapter broker sungguhan dipasang ke platform:
|
||||
|
||||
```env
|
||||
MQTT_PUBLISH_MODE=broker
|
||||
MQTT_BROKER_URL=mqtts://mqtt.iptek.co:8883
|
||||
MQTT_BROKER_URL=mqtts://broker.bizone.id:8883
|
||||
MQTT_USERNAME=qris-backend
|
||||
MQTT_PASSWORD=...
|
||||
MQTT_CLIENT_ID=qris-platform-backend
|
||||
|
||||
459
CODEX_HANDOFF.md
459
CODEX_HANDOFF.md
@ -1,251 +1,316 @@
|
||||
# Codex Handoff - QRIS Soundbox Platform
|
||||
|
||||
Tanggal update: 2026-05-29, Asia/Jakarta.
|
||||
Tanggal update: 2026-06-03, Asia/Jakarta.
|
||||
|
||||
Dokumen ini adalah snapshot kerja terakhir untuk melanjutkan project tanpa perlu membaca ulang seluruh chat.
|
||||
|
||||
## Status Terakhir
|
||||
|
||||
- Estimasi MVP / early pilot: 92-94%.
|
||||
- Estimasi production-ready penuh: 82-85%.
|
||||
- Platform sudah bukan prototype docs-only. Backend, UI operasional, migration, smoke test, rate limiting, audit logging, async export, runbook, dan script deployment sudah tersedia.
|
||||
- Fokus terakhir yang selesai: rate limiting + security polish, login audit, admin audit UI real-data, placeholder nav cleanup, dan runbook/checklist produksi.
|
||||
- Worktree kemungkinan masih dirty karena banyak perubahan aktif. Jangan revert perubahan yang tidak eksplisit diminta.
|
||||
- Fokus hari ini bergeser dari production readiness umum ke integrasi device soundbox QF100.
|
||||
- Folder SDK lokal `QF100-60s-l511-SecondApp-260107/` ditemukan dan dianalisis. Folder ini sengaja tidak dimasukkan git.
|
||||
- Backend sekarang sudah punya adapter awal untuk firmware QF100 sample:
|
||||
- config server vendor-compatible;
|
||||
- payload MQTT payment success format QF100;
|
||||
- smoke script backend untuk static + dynamic QF100.
|
||||
- Target milestone berikutnya: dua soundbox real, satu static dan satu dynamic, bisa ambil config dari backend, connect MQTT, lalu static payment test bunyi dan tercatat di dashboard.
|
||||
- Worktree aktif/dirty karena perubahan backend QF100 dan update handoff. Jangan revert perubahan yang tidak eksplisit diminta.
|
||||
|
||||
## Verifikasi Terakhir
|
||||
|
||||
- `npm run typecheck`: pass.
|
||||
- `npm run db:migrate`: pass dan idempotent sampai migration `003_export_job_storage.sql`.
|
||||
- `npm audit --json`: pass, 0 vulnerability.
|
||||
- `npm run ui:qa`: pass setelah cleanup placeholder navigation.
|
||||
- `npm run smoke:e2e`: pass setelah rate limiting dan login audit.
|
||||
- Quick rate limit test: pass. Login admin salah pertama menghasilkan `401` dengan `RateLimit-Remaining: 0`, request berikutnya menghasilkan `429 RATE_LIMITED`.
|
||||
- Quick login audit test: pass. Event `admin.login.success`, `admin.login.failed`, `merchant.login.success`, dan `merchant.login.failed` tercatat.
|
||||
- Quick audit UI API test: pass. `GET /admin/audit-logs?action_contains=.login.&limit=10` mengembalikan event login.
|
||||
- Production-like env check dummy: pass via `npm run deploy:check-env`, hanya warning opsional untuk `MQTT_SUBSCRIBE`.
|
||||
- Staging/load/MQTT real sebelumnya sudah pernah diverifikasi: load level 2 1610 requests 0 errors, MQTT broker `mqtts://mqtt.iptek.co:8883` publish/subscribe OK.
|
||||
- `npm run typecheck`: pass setelah perubahan QF100.
|
||||
- `npm install`: sudah dijalankan untuk memasang dependency lokal; perubahan lockfile accidental sudah dibalik.
|
||||
- `npm run smoke:qf100`: script sudah dibuat, belum dijalankan end-to-end di DB/server lokal pada turn ini.
|
||||
- Verifikasi lama yang masih relevan:
|
||||
- `npm run db:migrate`: sebelumnya pass dan idempotent sampai migration `003_export_job_storage.sql`.
|
||||
- `npm audit --json`: sebelumnya pass, 0 vulnerability.
|
||||
- `npm run ui:qa`: sebelumnya pass.
|
||||
- `npm run smoke:e2e`: sebelumnya pass.
|
||||
- Real MQTT smoke sebelumnya pernah pass terhadap `mqtts://broker.bizone.id:8883`.
|
||||
|
||||
## Implementasi Selesai
|
||||
## SDK QF100 Yang Ditemukan
|
||||
|
||||
### 1. Auth, RBAC, dan Security
|
||||
- Folder lokal: `QF100-60s-l511-SecondApp-260107/`.
|
||||
- Sudah ditambahkan ke `.gitignore`:
|
||||
- `QF100-60s-l511-SecondApp-260107/`
|
||||
- Struktur penting:
|
||||
- `docs/Config Server.docx`: kontrak config server vendor.
|
||||
- `docs/Cloud Speaker API Spec V2.8.7.pdf`: API spec cloud speaker.
|
||||
- `app/source/MainApp/globalDefine.h`: device model, demo mode, `CONFIG_ADDR`.
|
||||
- `app/source/MainApp/demo.c`: alur config server, MQTT connect/subscribe, parse payment payload.
|
||||
- `app/source/MainApp/demo.h`: MQTT TLS/QoS/clean session/cert config.
|
||||
- `app/source/MainApp/main.c`: boot flow dan monitor network/MQTT.
|
||||
- `app/inc/MercuryMqtt.h`: header MQTT SDK.
|
||||
- `app/release/user_app.bin`: firmware build existing.
|
||||
|
||||
- Admin session login tersedia lewat `/admin/login`, `/admin/logout`, `/admin/me`.
|
||||
- Merchant session login tersedia lewat `/merchant/login`, `/merchant/logout`, `/merchant/me`.
|
||||
- Legacy dev auth bisa dimatikan via env dan production check memblokir konfigurasi yang tidak aman.
|
||||
- Admin dan merchant bootstrap script tersedia:
|
||||
- `scripts/create-admin-user.mjs`
|
||||
- `scripts/create-merchant-user.mjs`
|
||||
- Password policy bootstrap diperketat:
|
||||
- minimal 14 karakter;
|
||||
- wajib lowercase, uppercase, angka, dan simbol;
|
||||
- menolak kata mudah ditebak seperti product/default/password/admin/merchant/qris/soundbox.
|
||||
- Rate limiting middleware baru:
|
||||
- [src/shared/middleware/rateLimit.ts](/home/wira/work/codex/qris-soundbox-platform/src/shared/middleware/rateLimit.ts)
|
||||
- dipasang ke `/admin/login`, `/merchant/login`, admin write routes, `/device`, dan `/integrations`.
|
||||
- Env security baru:
|
||||
- `TRUST_PROXY`
|
||||
- `JSON_BODY_LIMIT`
|
||||
- `RATE_LIMIT_ENABLED`
|
||||
- `RATE_LIMIT_AUTH_WINDOW_MS`
|
||||
- `RATE_LIMIT_AUTH_MAX`
|
||||
- `RATE_LIMIT_ADMIN_WRITE_WINDOW_MS`
|
||||
- `RATE_LIMIT_ADMIN_WRITE_MAX`
|
||||
- `RATE_LIMIT_WRITE_WINDOW_MS`
|
||||
- `RATE_LIMIT_WRITE_MAX`
|
||||
- Error code baru `RATE_LIMITED` di [src/shared/errors/index.ts](/home/wira/work/codex/qris-soundbox-platform/src/shared/errors/index.ts).
|
||||
## Kesimpulan SDK QF100
|
||||
|
||||
### 2. Audit, Monitoring, dan Logging
|
||||
- SDK ini memungkinkan develop/patch aplikasi firmware sendiri, tetapi bentuknya embedded C firmware app, bukan SDK backend.
|
||||
- Strategi dipilih: firmware hanya hardcode URL config server backend kita, bukan hardcode broker MQTT.
|
||||
- Firmware sample boot lalu call `CONFIG_ADDR` ke endpoint vendor `/speaker/dev-config`.
|
||||
- Request config berisi field seperti:
|
||||
- `dev-model`
|
||||
- `item-number`
|
||||
- `dev-sn`
|
||||
- `fw-version`
|
||||
- `fw-build`
|
||||
- `app-config-version`
|
||||
- `imei`
|
||||
- `imsi`
|
||||
- `iccid`
|
||||
- Response yang firmware cari memiliki blok:
|
||||
- `mqtt.broker-ip`
|
||||
- `mqtt.broker-port`
|
||||
- `mqtt.client-id`
|
||||
- `mqtt.user-name`
|
||||
- `mqtt.password`
|
||||
- `mqtt.subscribe-topic`
|
||||
- `mqtt.keep-alive`
|
||||
- Payment success payload yang firmware sample bisa bunyikan:
|
||||
```json
|
||||
{
|
||||
"header": {
|
||||
"category": 1
|
||||
},
|
||||
"data": {
|
||||
"pay-amount": 15000
|
||||
}
|
||||
}
|
||||
```
|
||||
- `MQTT_TLS_ENABLE` di SDK sample masih `0`. Jika broker production memakai `mqtts://...:8883`, firmware perlu patch TLS atau pilot perlu listener non-TLS terbatas.
|
||||
|
||||
- Audit logging login admin:
|
||||
- `admin.login.success`
|
||||
- `admin.login.failed`
|
||||
- Audit logging login merchant:
|
||||
- `merchant.login.success`
|
||||
- `merchant.login.failed`
|
||||
- `auditLogStore` mendukung `actor_type: merchant`.
|
||||
- Filter audit baru `action_contains` tersedia di:
|
||||
- [src/shared/store/auditLogStore.ts](/home/wira/work/codex/qris-soundbox-platform/src/shared/store/auditLogStore.ts)
|
||||
- [src/routes/admin.ts](/home/wira/work/codex/qris-soundbox-platform/src/routes/admin.ts)
|
||||
- Admin audit UI sudah memakai real API, bukan mock:
|
||||
- [ui/admin-system-audit-logs/index.html](/home/wira/work/codex/qris-soundbox-platform/ui/admin-system-audit-logs/index.html)
|
||||
- Audit UI memiliki filter action/entity/date/search, preset login events, KPI count, dan drawer JSON detail.
|
||||
- Observability/health sebelumnya sudah tersedia:
|
||||
- `/health`
|
||||
- `/health/ready`
|
||||
- `/admin/observability/summary`
|
||||
- `/admin/observability/dead-letter-replays`
|
||||
- `/admin/observability/mqtt-status`
|
||||
## Implementasi QF100 Backend Hari Ini
|
||||
|
||||
### 3. MQTT dan Device Operations
|
||||
### 1. Config Server Vendor-Compatible
|
||||
|
||||
- MQTT worker dan policy production sudah diperketat.
|
||||
- Wildcard subscribe default production dicegah oleh env check.
|
||||
- MQTT ACL tooling tersedia:
|
||||
- `scripts/check-mqtt-acl.mjs`
|
||||
- `scripts/smoke-mqtt-acl.mjs`
|
||||
- `scripts/provision-mqtt-device.mjs`
|
||||
- Package scripts:
|
||||
- `npm run mqtt:provision-device`
|
||||
- `npm run mqtt:check-acl`
|
||||
- `npm run smoke:mqtt-acl`
|
||||
- `npm run smoke:mqtt-real`
|
||||
- Real MQTT smoke pernah pass dengan broker `mqtts://mqtt.iptek.co:8883`.
|
||||
- Route baru:
|
||||
- `GET /speaker/dev-config`
|
||||
- File:
|
||||
- `src/routes/speaker.ts`
|
||||
- mounted di `src/app.ts`
|
||||
- Perilaku:
|
||||
- menerima query/body vendor;
|
||||
- lookup device memakai `dev-sn` -> `devices.serial_number`;
|
||||
- jika device aktif, update:
|
||||
- `vendor`
|
||||
- `model`
|
||||
- `communication_mode`
|
||||
- `last_seen_at`
|
||||
- `firmware_version`
|
||||
- mencatat heartbeat `state: config_pull`;
|
||||
- balas JSON vendor top-level, bukan wrapper internal `successResponse`.
|
||||
- Error vendor style:
|
||||
- `1001`: `dev-sn` kosong;
|
||||
- `1002`: device tidak terdaftar atau inactive;
|
||||
- `1003`: broker MQTT belum dikonfigurasi.
|
||||
|
||||
### 4. Settlement, Reconciliation, dan Finance Ops
|
||||
### 2. Env QF100
|
||||
|
||||
- Settlement batch, merchant settlement history, reconciliation management, adjustment approval, dan device technical detail UI sudah tersedia.
|
||||
- Admin reconciliation UI sudah menggunakan async export flow dan export history.
|
||||
- Key UI pages:
|
||||
- [ui/admin-reconciliation-management/index.html](/home/wira/work/codex/qris-soundbox-platform/ui/admin-reconciliation-management/index.html)
|
||||
- [ui/settlement-batch-management/index.html](/home/wira/work/codex/qris-soundbox-platform/ui/settlement-batch-management/index.html)
|
||||
- [ui/merchant-settlement-history/index.html](/home/wira/work/codex/qris-soundbox-platform/ui/merchant-settlement-history/index.html)
|
||||
- [ui/device-technical-detail/index.html](/home/wira/work/codex/qris-soundbox-platform/ui/device-technical-detail/index.html)
|
||||
- Placeholder `href="#"` sudah dibersihkan dari UI yang masuk QA.
|
||||
- Env baru di `src/config/env.ts` dan `.env.example`:
|
||||
- `QF100_MQTT_BROKER_HOST`
|
||||
- `QF100_MQTT_BROKER_PORT`
|
||||
- `QF100_MQTT_USERNAME`
|
||||
- `QF100_MQTT_PASSWORD`
|
||||
- `QF100_MQTT_KEEP_ALIVE_SECONDS`
|
||||
- Jika host/port QF100 kosong, endpoint mencoba parse dari `MQTT_BROKER_URL`.
|
||||
- Catatan penting: password MQTT yang dikirim ke QF100 diambil dari `QF100_MQTT_PASSWORD` atau fallback `MQTT_PASSWORD`. Sistem saat ini menyimpan credential device sebagai fingerprint, jadi plaintext per-device tidak bisa dibaca ulang dari DB.
|
||||
|
||||
### 5. Async Export dan Storage
|
||||
### 3. Payload MQTT QF100
|
||||
|
||||
- Async export job sudah tersedia untuk settlement adjustment export.
|
||||
- Export job worker:
|
||||
- [src/shared/services/exportJobWorker.ts](/home/wira/work/codex/qris-soundbox-platform/src/shared/services/exportJobWorker.ts)
|
||||
- Export job store:
|
||||
- [src/shared/store/exportJobStore.ts](/home/wira/work/codex/qris-soundbox-platform/src/shared/store/exportJobStore.ts)
|
||||
- Migration:
|
||||
- [migrations/002_export_jobs.sql](/home/wira/work/codex/qris-soundbox-platform/migrations/002_export_jobs.sql)
|
||||
- [migrations/003_export_job_storage.sql](/home/wira/work/codex/qris-soundbox-platform/migrations/003_export_job_storage.sql)
|
||||
- Export file storage memakai `EXPORT_STORAGE_DIR`, dengan metadata path/size/expiry.
|
||||
- Export retention cleanup tersedia via worker.
|
||||
- Admin endpoints:
|
||||
- `POST /admin/exports/settlement-adjustments`
|
||||
- `GET /admin/exports`
|
||||
- `GET /admin/exports/:jobId`
|
||||
- `GET /admin/exports/:jobId/download`
|
||||
- File:
|
||||
- `src/shared/services/mqttPublisher.ts`
|
||||
- `src/shared/orchestrators/notificationOrchestrator.ts`
|
||||
- Topic QF100:
|
||||
- `devices/{deviceId}/downlink/qf100`
|
||||
- Adapter memilih format QF100 jika:
|
||||
- `device.model` mengandung `QF100`; atau
|
||||
- `capability_profile_json.mqtt_payload_profile`, `protocol_profile`, atau `vendor_protocol` bernilai `qf100`.
|
||||
- Downlink payment sekarang juga dicatat ke `mqtt_messages`, sehingga dashboard device detail bisa melihat topic/payload yang dikirim.
|
||||
|
||||
### 6. Deployment, Backup, Restore, dan Runbook
|
||||
### 4. Device Store dan Schema
|
||||
|
||||
- Production env checker diperketat:
|
||||
- [scripts/check-production-env.mjs](/home/wira/work/codex/qris-soundbox-platform/scripts/check-production-env.mjs)
|
||||
- Backup/restore tooling:
|
||||
- [scripts/backup-production.mjs](/home/wira/work/codex/qris-soundbox-platform/scripts/backup-production.mjs)
|
||||
- [scripts/restore-plan.mjs](/home/wira/work/codex/qris-soundbox-platform/scripts/restore-plan.mjs)
|
||||
- [scripts/restore-drill-validate.mjs](/home/wira/work/codex/qris-soundbox-platform/scripts/restore-drill-validate.mjs)
|
||||
- Load testing/report tooling:
|
||||
- [scripts/load-test.mjs](/home/wira/work/codex/qris-soundbox-platform/scripts/load-test.mjs)
|
||||
- [scripts/run-staging-load-report.mjs](/home/wira/work/codex/qris-soundbox-platform/scripts/run-staging-load-report.mjs)
|
||||
- Operational docs baru:
|
||||
- [OPERATIONAL_RUNBOOK.md](/home/wira/work/codex/qris-soundbox-platform/OPERATIONAL_RUNBOOK.md)
|
||||
- [PILOT_EXECUTION_CHECKLIST.md](/home/wira/work/codex/qris-soundbox-platform/PILOT_EXECUTION_CHECKLIST.md)
|
||||
- [EXPORT_STORAGE_READINESS.md](/home/wira/work/codex/qris-soundbox-platform/EXPORT_STORAGE_READINESS.md)
|
||||
- README dan deployment readiness docs sudah direferensikan ke runbook/checklist tersebut.
|
||||
- Lookup baru:
|
||||
- `getDeviceBySerialNumber(serialNumber)` di `src/shared/store/deviceStore.ts`.
|
||||
- Schema bootstrap menambahkan index:
|
||||
- `idx_devices_serial_number`.
|
||||
|
||||
## Endpoint Penting
|
||||
### 5. Smoke QF100
|
||||
|
||||
- Script baru:
|
||||
- `scripts/smoke-qf100-adapter.mjs`
|
||||
- Package script baru:
|
||||
- `npm run smoke:qf100`
|
||||
- Script ini:
|
||||
- create merchant/outlet/terminal/device static;
|
||||
- create merchant/outlet/terminal/device dynamic MQTT;
|
||||
- set `model: QF100` dan `capability_profile_json.mqtt_payload_profile: qf100`;
|
||||
- hit `/speaker/dev-config` untuk dua SN;
|
||||
- validasi MQTT config vendor;
|
||||
- trigger static QRIS paid callback;
|
||||
- validasi `mqtt_messages` downlink QF100 payload `category: 1`;
|
||||
- trigger backend dynamic QR MQTT flow untuk device dynamic.
|
||||
- Untuk memakai SN real:
|
||||
```bash
|
||||
QF100_STATIC_SN=<sn-static> \
|
||||
QF100_DYNAMIC_SN=<sn-dynamic> \
|
||||
BASE_URL=http://127.0.0.1:3000 \
|
||||
npm run smoke:qf100
|
||||
```
|
||||
|
||||
## Device Flow Yang Disepakati
|
||||
|
||||
### Static Soundbox
|
||||
|
||||
1. Device boot.
|
||||
2. Firmware call backend `/speaker/dev-config`.
|
||||
3. Backend balas MQTT config.
|
||||
4. Device connect MQTT dan subscribe `devices/{deviceId}/downlink/qf100`.
|
||||
5. QRIS callback paid masuk backend.
|
||||
6. Backend publish payload QF100 `category: 1`.
|
||||
7. Device bunyi nominal.
|
||||
8. Dashboard melihat transaction, notification, mqtt message, last seen/config pull.
|
||||
|
||||
### Dynamic Soundbox
|
||||
|
||||
- Prinsip: trigger tetap via MQTT, bukan polling terus-menerus.
|
||||
- HTTP polling dynamic QR dianggap tidak cocok untuk skala 20 ribu device karena `gap=30s` berarti sekitar 40 ribu request/menit.
|
||||
- Desain lanjutan:
|
||||
1. Backend/admin/merchant membuat dynamic QR request.
|
||||
2. Backend publish MQTT command ke device.
|
||||
3. Device menampilkan QR langsung atau HTTP fetch QR detail dari URL.
|
||||
4. Payment callback tetap jadi source of truth.
|
||||
5. Payment success tetap via MQTT `category: 1`.
|
||||
- Firmware dynamic handler belum dipatch. Perlu tambah handler misalnya `category == 10` di `demo.c`.
|
||||
|
||||
## Dashboard Yang Perlu Disiapkan Berikutnya
|
||||
|
||||
Prioritas UI/dashboard untuk test dua soundbox real:
|
||||
|
||||
1. Device detail QF100 panel:
|
||||
- SN;
|
||||
- model/profile;
|
||||
- config pull terakhir;
|
||||
- broker host/port yang dikirim;
|
||||
- client-id;
|
||||
- subscribe-topic;
|
||||
- keepalive.
|
||||
2. Test payment button:
|
||||
- nominal quick actions, misalnya Rp1.000 dan Rp15.000;
|
||||
- create dummy tx/callback internal;
|
||||
- tampilkan notification dan MQTT payload result.
|
||||
3. MQTT timeline:
|
||||
- direction;
|
||||
- topic;
|
||||
- message_type;
|
||||
- payload JSON;
|
||||
- publish_status/reason;
|
||||
- timestamp.
|
||||
4. Dynamic QR panel:
|
||||
- input nominal;
|
||||
- create dynamic QR;
|
||||
- tampilkan QR payload/status;
|
||||
- nanti `Send to Soundbox` via MQTT command setelah firmware handler siap.
|
||||
5. Ops summary:
|
||||
- total soundbox;
|
||||
- online/stale/offline;
|
||||
- config pull terbaru;
|
||||
- payment notification sent/failed;
|
||||
- dynamic QR active/paid/expired.
|
||||
|
||||
## Endpoint Penting Saat Ini
|
||||
|
||||
- Health:
|
||||
- `GET /health`
|
||||
- `GET /health/ready`
|
||||
- `GET /health/deep`
|
||||
- QF100 vendor config:
|
||||
- `GET /speaker/dev-config`
|
||||
- Admin auth/session:
|
||||
- `POST /admin/login`
|
||||
- `POST /admin/logout`
|
||||
- `GET /admin/me`
|
||||
- Admin audit/observability:
|
||||
- `GET /admin/audit-logs`
|
||||
- Admin device:
|
||||
- `POST /admin/devices`
|
||||
- `GET /admin/devices`
|
||||
- `GET /admin/devices/{id}`
|
||||
- `POST /admin/devices/{id}/credentials/rotate`
|
||||
- `POST /admin/devices/{id}/bind`
|
||||
- `POST /admin/devices/{id}/unbind`
|
||||
- `GET /admin/devices/{id}/mqtt-messages`
|
||||
- `GET /admin/devices/{id}/notifications`
|
||||
- Device API:
|
||||
- `POST /device/heartbeat`
|
||||
- `POST /device/transactions/dynamic-qr`
|
||||
- `POST /device/mqtt/uplink/dynamic-qr/request`
|
||||
- `GET /device/config`
|
||||
- `POST /device/config/ack`
|
||||
- Integrations:
|
||||
- `POST /integrations/qris/callback`
|
||||
- Observability:
|
||||
- `GET /admin/observability/summary`
|
||||
- `GET /admin/observability/dead-letter-replays`
|
||||
- `GET /admin/observability/mqtt-status`
|
||||
- Admin export:
|
||||
- `POST /admin/exports/settlement-adjustments`
|
||||
- `GET /admin/exports`
|
||||
- `GET /admin/exports/:jobId`
|
||||
- `GET /admin/exports/:jobId/download`
|
||||
- Merchant auth/session:
|
||||
- `POST /merchant/login`
|
||||
- `POST /merchant/logout`
|
||||
- `GET /merchant/me`
|
||||
- Device and integration routes remain rate-limited for write-heavy paths:
|
||||
- `/device`
|
||||
- `/integrations`
|
||||
|
||||
## Package Scripts Penting
|
||||
|
||||
- `npm run typecheck`
|
||||
- `npm run db:migrate`
|
||||
- `npm run smoke:qf100`
|
||||
- `npm run smoke:e2e`
|
||||
- `npm run smoke:mqtt-real`
|
||||
- `npm run smoke:mqtt-acl`
|
||||
- `npm run ui:qa`
|
||||
- `npm run deploy:check-env`
|
||||
- `npm run load:test`
|
||||
- `npm run load:test:staging`
|
||||
- `npm run backup:production`
|
||||
- `npm run restore:plan`
|
||||
- `npm run restore:validate`
|
||||
- `npm run admin:create-user`
|
||||
- `npm run merchant:create-user`
|
||||
- `npm run mqtt:provision-device`
|
||||
- `npm run mqtt:check-acl`
|
||||
- `npm run smoke:mqtt-acl`
|
||||
- `npm run smoke:mqtt-real`
|
||||
- `npm run admin:create-user`
|
||||
- `npm run merchant:create-user`
|
||||
|
||||
## File Kunci yang Sering Disentuh
|
||||
## File Kunci Yang Sering Disentuh
|
||||
|
||||
- App bootstrap: [src/app.ts](/home/wira/work/codex/qris-soundbox-platform/src/app.ts)
|
||||
- Env config: [src/config/env.ts](/home/wira/work/codex/qris-soundbox-platform/src/config/env.ts)
|
||||
- Admin routes: [src/routes/admin.ts](/home/wira/work/codex/qris-soundbox-platform/src/routes/admin.ts)
|
||||
- Merchant routes: [src/routes/merchant.ts](/home/wira/work/codex/qris-soundbox-platform/src/routes/merchant.ts)
|
||||
- Audit store: [src/shared/store/auditLogStore.ts](/home/wira/work/codex/qris-soundbox-platform/src/shared/store/auditLogStore.ts)
|
||||
- Export worker: [src/shared/services/exportJobWorker.ts](/home/wira/work/codex/qris-soundbox-platform/src/shared/services/exportJobWorker.ts)
|
||||
- Export store: [src/shared/store/exportJobStore.ts](/home/wira/work/codex/qris-soundbox-platform/src/shared/store/exportJobStore.ts)
|
||||
- Rate limit middleware: [src/shared/middleware/rateLimit.ts](/home/wira/work/codex/qris-soundbox-platform/src/shared/middleware/rateLimit.ts)
|
||||
- UI QA script: [scripts/ui-qa-check.mjs](/home/wira/work/codex/qris-soundbox-platform/scripts/ui-qa-check.mjs)
|
||||
- Admin audit UI: [ui/admin-system-audit-logs/index.html](/home/wira/work/codex/qris-soundbox-platform/ui/admin-system-audit-logs/index.html)
|
||||
- Env sample: [.env.example](/home/wira/work/codex/qris-soundbox-platform/.env.example)
|
||||
|
||||
## Decision Log Ringkas
|
||||
|
||||
- D-026 sampai D-049: dasar auth, merchant/admin flows, migration, UI awal, dan smoke testing.
|
||||
- D-050 sampai D-059: production hardening awal, MQTT policy, finance/reconciliation UI, settlement flows.
|
||||
- D-060 sampai D-069: merchant auth productionization, DB migration idempotent, monitoring/logging, load test, async export.
|
||||
- D-070 sampai D-074: export storage/history, MQTT ACL, backup/restore, staging load report.
|
||||
- D-075 sampai D-080: rate limiting/security polish, login audit, audit UI real data, UI QA cleanup, runbook/checklist produksi.
|
||||
|
||||
Rujukan utama: [DECISIONS_LOG.md](/home/wira/work/codex/qris-soundbox-platform/DECISIONS_LOG.md).
|
||||
- App bootstrap: `src/app.ts`
|
||||
- Env config: `src/config/env.ts`
|
||||
- QF100 config route: `src/routes/speaker.ts`
|
||||
- Device route: `src/routes/device.ts`
|
||||
- Admin route: `src/routes/admin.ts`
|
||||
- MQTT publisher: `src/shared/services/mqttPublisher.ts`
|
||||
- MQTT subscriber: `src/shared/services/mqttSubscriber.ts`
|
||||
- Notification orchestrator: `src/shared/orchestrators/notificationOrchestrator.ts`
|
||||
- Device store: `src/shared/store/deviceStore.ts`
|
||||
- MQTT message store: `src/shared/store/mqttMessageStore.ts`
|
||||
- Heartbeat store: `src/shared/store/heartbeatStore.ts`
|
||||
- Schema bootstrap: `src/shared/db/pool.ts`
|
||||
- QF100 smoke: `scripts/smoke-qf100-adapter.mjs`
|
||||
- Env sample: `.env.example`
|
||||
|
||||
## Sisa Gap Utama
|
||||
|
||||
1. Eksekusi staging nyata dari checklist:
|
||||
- deploy dengan env final;
|
||||
- jalankan `deploy:check-env`, migration, smoke, UI QA, load report;
|
||||
- simpan artefak hasil staging.
|
||||
2. Pilot real device:
|
||||
- provisioning device real;
|
||||
- validasi MQTT ACL per device;
|
||||
- transaksi QRIS test end-to-end;
|
||||
- validasi soundbox delivery dan dead-letter handling.
|
||||
3. Restore drill nyata:
|
||||
- backup production/staging;
|
||||
- restore ke database disposable;
|
||||
- jalankan `restore:validate`;
|
||||
- dokumentasikan RTO/RPO aktual.
|
||||
4. Export storage production topology:
|
||||
- pastikan `EXPORT_STORAGE_DIR` durable, absolute, writable, dan di-backup;
|
||||
- jika multi-node, perlu shared filesystem/object storage strategy.
|
||||
5. Manual visual QA:
|
||||
- buka halaman admin utama di browser;
|
||||
- cek layout mobile/desktop;
|
||||
- cek login/session expiry state;
|
||||
- cek empty/error/loading state.
|
||||
6. Operational readiness:
|
||||
- isi PIC, escalation contact, broker credential, backup location, dan pilot merchant list di runbook/checklist.
|
||||
|
||||
## Prioritas Lanjutan Disarankan
|
||||
|
||||
1. Jalankan full staging rehearsal dari `PILOT_EXECUTION_CHECKLIST.md`.
|
||||
2. Lakukan manual visual QA admin UI dengan browser.
|
||||
3. Jalankan restore drill sungguhan pada database disposable.
|
||||
4. Finalisasi export storage production strategy.
|
||||
5. Siapkan pilot real merchant/device dan rekam hasilnya di runbook.
|
||||
1. Jalankan `npm run smoke:qf100` terhadap backend + DB lokal/staging.
|
||||
2. Register dua device real:
|
||||
- static SN -> device static;
|
||||
- dynamic SN -> device dynamic.
|
||||
3. Patch firmware QF100:
|
||||
- `CONFIG_ADDR` ke backend kita;
|
||||
- TLS setting sesuai broker.
|
||||
4. Test static device real:
|
||||
- config pull;
|
||||
- MQTT connect;
|
||||
- payment success bunyi;
|
||||
- dashboard melihat downlink.
|
||||
5. Patch firmware dynamic:
|
||||
- tambah handler command dynamic QR, kemungkinan `category == 10`;
|
||||
- pilih direct QR payload vs HTTP fetch detail.
|
||||
6. Siapkan dashboard QF100 detail/test panel.
|
||||
7. Putuskan credential strategy production:
|
||||
- shared pilot password saat ini cukup untuk lab;
|
||||
- production sebaiknya credential per-device yang bisa diprovisioning aman.
|
||||
8. Tetap perlu staging rehearsal, restore drill, export storage strategy, dan manual visual QA dari handoff lama.
|
||||
|
||||
## Catatan Penting
|
||||
|
||||
- Jangan hidupkan legacy auth di production.
|
||||
- Jangan gunakan wildcard MQTT subscribe di production kecuali sedang maintenance terkontrol.
|
||||
- `EXPORT_STORAGE_DIR` harus absolute path dan durable untuk production.
|
||||
- Rate limiting sekarang aktif secara default jika `RATE_LIMIT_ENABLED=true`; hati-hati saat smoke test berulang pada login endpoint.
|
||||
- Folder SDK QF100 adalah artefak lokal dan jangan dimasukkan git.
|
||||
- Jangan hardcode broker credential production di firmware.
|
||||
- Firmware cukup hardcode config server URL; broker/topic/credential dikirim dari backend.
|
||||
- Jangan gunakan wildcard MQTT subscribe di production kecuali maintenance terkontrol.
|
||||
- Jika memakai MQTT TLS (`mqtts://...:8883`), firmware QF100 perlu `MQTT_TLS_ENABLE=1` dan sertifikat yang sesuai.
|
||||
- Rate limiting aktif default; `/speaker` memakai limiter device.
|
||||
- `CODEX_HANDOFF.md` ini adalah snapshot operasional terbaru; untuk detail historis keputusan, baca `DECISIONS_LOG.md`.
|
||||
|
||||
372
DEBIAN12_APP_SERVER_SETUP.md
Normal file
372
DEBIAN12_APP_SERVER_SETUP.md
Normal file
@ -0,0 +1,372 @@
|
||||
# Debian 12 App Server Setup
|
||||
|
||||
Panduan ini untuk menyiapkan server kosong Debian 12 sebagai app server QRIS Soundbox Platform.
|
||||
|
||||
- App domain: `sms.bizone.id`
|
||||
- App user: `qrisapp`
|
||||
- App directory: `/opt/qris-soundbox`
|
||||
- Env file: `/etc/qris-soundbox/qris-soundbox.env`
|
||||
- Local app port: `3000`
|
||||
- MQTT broker: `broker.bizone.id`
|
||||
|
||||
Broker MQTT boleh berada di server lain. Dokumen ini fokus ke server aplikasi.
|
||||
|
||||
## 1. DNS
|
||||
|
||||
Pastikan `sms.bizone.id` sudah mengarah ke public IP server app.
|
||||
|
||||
```bash
|
||||
dig +short sms.bizone.id
|
||||
curl -4 ifconfig.me
|
||||
```
|
||||
|
||||
## 2. Base Packages
|
||||
|
||||
Jalankan sebagai root atau user sudo.
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt upgrade -y
|
||||
sudo apt install -y \
|
||||
ca-certificates \
|
||||
curl \
|
||||
gnupg \
|
||||
git \
|
||||
build-essential \
|
||||
nginx \
|
||||
certbot \
|
||||
python3-certbot-nginx \
|
||||
postgresql \
|
||||
postgresql-contrib \
|
||||
ufw
|
||||
```
|
||||
|
||||
## 3. Node.js
|
||||
|
||||
Gunakan Node.js 22 untuk runtime production.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo apt install -y nodejs
|
||||
node -v
|
||||
npm -v
|
||||
```
|
||||
|
||||
## 4. Firewall
|
||||
|
||||
```bash
|
||||
sudo ufw allow OpenSSH
|
||||
sudo ufw allow 'Nginx Full'
|
||||
sudo ufw enable
|
||||
sudo ufw status
|
||||
```
|
||||
|
||||
Jangan buka port `3000` ke internet. Traffic publik masuk lewat Nginx.
|
||||
|
||||
## 5. App User
|
||||
|
||||
Buat user khusus untuk menjalankan service.
|
||||
|
||||
```bash
|
||||
sudo adduser --system --group --home /opt/qris-soundbox qrisapp
|
||||
sudo install -d -o qrisapp -g qrisapp -m 750 /opt/qris-soundbox
|
||||
sudo install -d -o root -g qrisapp -m 750 /etc/qris-soundbox
|
||||
sudo install -d -o qrisapp -g qrisapp -m 750 /var/lib/qris-soundbox/exports
|
||||
```
|
||||
|
||||
Untuk deploy via git/rsync, operator boleh masuk dengan user sudo biasa, lalu copy release ke `/opt/qris-soundbox` dan set ownership ke `qrisapp:qrisapp`.
|
||||
|
||||
## 6. PostgreSQL
|
||||
|
||||
Buat database dan user production.
|
||||
|
||||
```bash
|
||||
sudo -u postgres psql
|
||||
```
|
||||
|
||||
Di prompt `psql`:
|
||||
|
||||
```sql
|
||||
CREATE USER qris_app WITH PASSWORD '5174e2c2fb3f8424806d1e5a4ca873a3dd33aace06a7b99c49f1bed9d1f42c4a';
|
||||
CREATE DATABASE qris_soundbox_platform OWNER qris_app;
|
||||
GRANT ALL PRIVILEGES ON DATABASE qris_soundbox_platform TO qris_app;
|
||||
\q
|
||||
```
|
||||
|
||||
## 7. Deploy Code
|
||||
|
||||
Contoh deploy awal via git:
|
||||
|
||||
```bash
|
||||
sudo -u qrisapp git clone <repo-url> /opt/qris-soundbox
|
||||
cd /opt/qris-soundbox
|
||||
sudo -u qrisapp npm ci
|
||||
sudo -u qrisapp npm run typecheck
|
||||
sudo -u qrisapp npm run build
|
||||
```
|
||||
|
||||
Jika deploy dari artifact, extract/copy artifact ke `/opt/qris-soundbox`, lalu:
|
||||
|
||||
```bash
|
||||
sudo chown -R qrisapp:qrisapp /opt/qris-soundbox
|
||||
cd /opt/qris-soundbox
|
||||
sudo -u qrisapp npm ci
|
||||
sudo -u qrisapp npm run build
|
||||
```
|
||||
|
||||
Catatan: command `npm run typecheck`, `npm run db:migrate`, dan sebagian smoke script membutuhkan dev dependency. Untuk server staging/pilot awal, gunakan `npm ci` penuh agar semua command operasional tersedia. Service production tetap menjalankan hasil build lewat `npm run start:dist`.
|
||||
|
||||
## 8. Environment
|
||||
|
||||
Buat env production:
|
||||
|
||||
```bash
|
||||
sudo nano /etc/qris-soundbox/qris-soundbox.env
|
||||
```
|
||||
|
||||
Template awal:
|
||||
|
||||
```env
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
TRUST_PROXY=true
|
||||
JSON_BODY_LIMIT=1mb
|
||||
LOG_FORMAT=json
|
||||
LOG_LEVEL=info
|
||||
|
||||
ADMIN_AUTH_ALLOW_LEGACY_TOKEN=false
|
||||
ADMIN_DEV_LOGIN_ENABLED=false
|
||||
ADMIN_SESSION_SECRET=CHANGE_ME_LONG_RANDOM_ADMIN_SESSION_SECRET
|
||||
ADMIN_SESSION_TTL_SECONDS=28800
|
||||
|
||||
MERCHANT_AUTH_ALLOW_LEGACY_TOKEN=false
|
||||
MERCHANT_DEV_LOGIN_ENABLED=false
|
||||
MERCHANT_SESSION_SECRET=CHANGE_ME_LONG_RANDOM_MERCHANT_SESSION_SECRET
|
||||
MERCHANT_SESSION_TTL_SECONDS=28800
|
||||
|
||||
DEVICE_AUTH_ALLOW_LEGACY_TOKEN=false
|
||||
TRACE_HEADER=x-request-id
|
||||
IDEMPOTENCY_TTL_MS=300000
|
||||
INTEGRATION_WEBHOOK_SECRET=CHANGE_ME_LONG_RANDOM_WEBHOOK_SECRET
|
||||
|
||||
MQTT_PUBLISH_MODE=broker
|
||||
MQTT_BROKER_URL=mqtts://broker.bizone.id:8883
|
||||
MQTT_USERNAME=qris-backend
|
||||
MQTT_PASSWORD=CHANGE_ME_MQTT_BACKEND_PASSWORD
|
||||
MQTT_CLIENT_ID=qris-platform-backend-prod
|
||||
MQTT_CONNECT_TIMEOUT_MS=5000
|
||||
MQTT_SUBSCRIBE_ENABLED=true
|
||||
MQTT_SUBSCRIBE_TOPICS=devices/+/uplink/#
|
||||
MQTT_PUBLISH_FORCE_FAIL_ALL=false
|
||||
MQTT_PUBLISH_FORCE_FAIL_DEVICE_IDS=
|
||||
MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS=15000
|
||||
|
||||
QF100_MQTT_BROKER_HOST=broker.bizone.id
|
||||
QF100_MQTT_BROKER_PORT=8883
|
||||
QF100_MQTT_USERNAME=qris-backend
|
||||
QF100_MQTT_PASSWORD=CHANGE_ME_MQTT_BACKEND_PASSWORD
|
||||
QF100_MQTT_KEEP_ALIVE_SECONDS=60
|
||||
|
||||
DYNAMIC_QR_EXPIRY_SCHEDULER_ENABLED=true
|
||||
DYNAMIC_QR_EXPIRY_SWEEP_INTERVAL_MS=60000
|
||||
DYNAMIC_QR_EXPIRY_SWEEP_LIMIT=100
|
||||
|
||||
EXPORT_WORKER_ENABLED=true
|
||||
EXPORT_WORKER_INTERVAL_MS=2000
|
||||
EXPORT_WORKER_BATCH_SIZE=2
|
||||
EXPORT_JOB_STALE_RUNNING_MS=900000
|
||||
EXPORT_SETTLEMENT_ADJUSTMENT_MAX_ROWS=5000
|
||||
EXPORT_STORAGE_DIR=/var/lib/qris-soundbox/exports
|
||||
EXPORT_RETENTION_DAYS=30
|
||||
|
||||
RATE_LIMIT_ENABLED=true
|
||||
RATE_LIMIT_LOGIN_WINDOW_MS=60000
|
||||
RATE_LIMIT_LOGIN_MAX=20
|
||||
RATE_LIMIT_DEVICE_WINDOW_MS=60000
|
||||
RATE_LIMIT_DEVICE_MAX=600
|
||||
RATE_LIMIT_ADMIN_WRITE_WINDOW_MS=60000
|
||||
RATE_LIMIT_ADMIN_WRITE_MAX=300
|
||||
|
||||
FINANCE_PLATFORM_FEE_BPS=70
|
||||
SETTLEMENT_ADJUSTMENT_REQUIRE_APPROVAL=true
|
||||
|
||||
PGHOST=127.0.0.1
|
||||
PGPORT=5432
|
||||
PGUSER=qris_app
|
||||
PGPASSWORD=CHANGE_ME_STRONG_DB_PASSWORD
|
||||
PGDATABASE=qris_soundbox_platform
|
||||
```
|
||||
|
||||
Lock permission:
|
||||
|
||||
```bash
|
||||
sudo chown root:qrisapp /etc/qris-soundbox/qris-soundbox.env
|
||||
sudo chmod 640 /etc/qris-soundbox/qris-soundbox.env
|
||||
```
|
||||
|
||||
## 9. Database Migration
|
||||
|
||||
```bash
|
||||
cd /opt/qris-soundbox
|
||||
sudo -u qrisapp env $(sudo cat /etc/qris-soundbox/qris-soundbox.env | xargs) npm run db:migrate
|
||||
```
|
||||
|
||||
Jika env berisi karakter spesial yang membuat `xargs` bermasalah, gunakan systemd service sementara atau jalankan lewat shell yang melakukan `set -a; source ...; set +a`.
|
||||
|
||||
```bash
|
||||
cd /opt/qris-soundbox
|
||||
sudo -u qrisapp bash -lc 'set -a; source /etc/qris-soundbox/qris-soundbox.env; set +a; npm run db:migrate'
|
||||
```
|
||||
|
||||
## 10. Systemd Service
|
||||
|
||||
Buat service:
|
||||
|
||||
```bash
|
||||
sudo nano /etc/systemd/system/qris-soundbox.service
|
||||
```
|
||||
|
||||
Isi:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=QRIS Soundbox Platform
|
||||
After=network-online.target postgresql.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=qrisapp
|
||||
Group=qrisapp
|
||||
WorkingDirectory=/opt/qris-soundbox
|
||||
EnvironmentFile=/etc/qris-soundbox/qris-soundbox.env
|
||||
ExecStart=/usr/bin/npm run start:dist
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ReadWritePaths=/opt/qris-soundbox /var/lib/qris-soundbox
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Start service:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable qris-soundbox
|
||||
sudo systemctl start qris-soundbox
|
||||
sudo systemctl status qris-soundbox --no-pager
|
||||
journalctl -u qris-soundbox -f
|
||||
```
|
||||
|
||||
## 11. Nginx
|
||||
|
||||
Buat config:
|
||||
|
||||
```bash
|
||||
sudo nano /etc/nginx/sites-available/sms.bizone.id
|
||||
```
|
||||
|
||||
Isi:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name sms.bizone.id;
|
||||
|
||||
client_max_body_size 1m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Enable:
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/sms.bizone.id /etc/nginx/sites-enabled/sms.bizone.id
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
## 12. TLS Certificate
|
||||
|
||||
```bash
|
||||
sudo certbot --nginx -d sms.bizone.id
|
||||
sudo certbot renew --dry-run
|
||||
```
|
||||
|
||||
## 13. Create Production Users
|
||||
|
||||
```bash
|
||||
cd /opt/qris-soundbox
|
||||
sudo -u qrisapp bash -lc 'set -a; source /etc/qris-soundbox/qris-soundbox.env; set +a; npm run admin:create-user -- --email <email> --name <name> --role admin --password <strong-password>'
|
||||
sudo -u qrisapp bash -lc 'set -a; source /etc/qris-soundbox/qris-soundbox.env; set +a; npm run merchant:create-user -- --merchant <merchant-id-or-code> --email <email> --name <name> --role owner --password <strong-password>'
|
||||
```
|
||||
|
||||
## 14. Verification
|
||||
|
||||
```bash
|
||||
curl -fsS https://sms.bizone.id/health
|
||||
curl -fsS https://sms.bizone.id/health/deep
|
||||
curl -I https://sms.bizone.id/ui
|
||||
```
|
||||
|
||||
Run production preflight from server:
|
||||
|
||||
```bash
|
||||
cd /opt/qris-soundbox
|
||||
sudo -u qrisapp bash -lc 'set -a; source /etc/qris-soundbox/qris-soundbox.env; set +a; npm run deploy:check-env'
|
||||
sudo -u qrisapp bash -lc 'set -a; source /etc/qris-soundbox/qris-soundbox.env; set +a; npm run smoke:mqtt-real'
|
||||
```
|
||||
|
||||
Run full smoke only on staging/controlled environment because it creates and cleans test data:
|
||||
|
||||
```bash
|
||||
sudo -u qrisapp bash -lc 'set -a; source /etc/qris-soundbox/qris-soundbox.env; set +a; npm run smoke:e2e'
|
||||
sudo -u qrisapp bash -lc 'set -a; source /etc/qris-soundbox/qris-soundbox.env; set +a; npm run ui:qa'
|
||||
```
|
||||
|
||||
## 15. QF100 Notes
|
||||
|
||||
For QF100 devices, app config server URL should point to:
|
||||
|
||||
```text
|
||||
https://sms.bizone.id/speaker/dev-config
|
||||
```
|
||||
|
||||
The config response will point the device to MQTT broker:
|
||||
|
||||
```text
|
||||
broker.bizone.id:8883
|
||||
```
|
||||
|
||||
If the firmware still has MQTT TLS disabled, either patch firmware TLS or prepare a restricted non-TLS pilot listener. Do not expose unrestricted non-TLS MQTT to the internet.
|
||||
|
||||
## 16. Routine Ops
|
||||
|
||||
Useful commands:
|
||||
|
||||
```bash
|
||||
sudo systemctl status qris-soundbox --no-pager
|
||||
journalctl -u qris-soundbox -n 200 --no-pager
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
Backup:
|
||||
|
||||
```bash
|
||||
cd /opt/qris-soundbox
|
||||
sudo -u qrisapp bash -lc 'set -a; source /etc/qris-soundbox/qris-soundbox.env; set +a; npm run backup:production -- --out /var/backups/qris --include-mosquitto'
|
||||
```
|
||||
@ -829,7 +829,7 @@ Log keputusan arsitektur dan implementasi yang harus dijadikan acuan eksekusi.
|
||||
## D-062 — Real MQTT Broker Smoke Validation
|
||||
- Tanggal: 2026-05-29
|
||||
- Keputusan:
|
||||
- `npm run smoke:mqtt-real` dijalankan terhadap broker `mqtts://mqtt.iptek.co:8883`.
|
||||
- `npm run smoke:mqtt-real` dijalankan terhadap broker `mqtts://broker.bizone.id:8883`.
|
||||
- Test memverifikasi broker connect, subscribe `devices/+/downlink/#`, payment success downlink, config push, dan dynamic QR response.
|
||||
- Alasan:
|
||||
- MQTT broker production-like harus divalidasi terpisah dari simulator sebelum pilot device real.
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
## Production Preflight
|
||||
|
||||
Debian 12 app server setup untuk domain `sms.bizone.id` tersedia di `DEBIAN12_APP_SERVER_SETUP.md`.
|
||||
|
||||
Run this before deploying a production candidate:
|
||||
|
||||
```bash
|
||||
|
||||
@ -14,6 +14,7 @@ Paket ini berisi blueprint final v1 untuk platform merchant aggregator QRIS + so
|
||||
- 09-screen-inventory.md
|
||||
- 10-design-blueprint.md
|
||||
- 11-low-fi-wireframes.md
|
||||
- DEBIAN12_APP_SERVER_SETUP.md
|
||||
- 18-mqtt-broker-mosquitto-debian13.md
|
||||
|
||||
## Tujuan
|
||||
@ -185,7 +186,7 @@ npm run smoke:mqtt-real
|
||||
|
||||
Perintah ini membaca `.env`, menjalankan app lokal dengan `MQTT_PUBLISH_MODE=broker`, subscribe ke broker `devices/+/downlink/#`, lalu memverifikasi publish real untuk payment success, config push, dan dynamic QR response. Test akan membersihkan data merchant/transaksi/MQTT trace yang dibuat khusus smoke.
|
||||
|
||||
Smoke broker real terakhir lulus terhadap `mqtts://mqtt.iptek.co:8883`: broker connect ok, subscribe `devices/+/downlink/#`, menerima payment success, config push, dynamic QR response, dan cleanup data smoke.
|
||||
Smoke broker real terakhir lulus terhadap `mqtts://broker.bizone.id:8883`: broker connect ok, subscribe `devices/+/downlink/#`, menerima payment success, config push, dynamic QR response, dan cleanup data smoke.
|
||||
|
||||
### Async export worker
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
"load:test:staging": "node scripts/run-staging-load-report.mjs",
|
||||
"smoke:flow": "node scripts/smoke.mjs",
|
||||
"smoke:mqtt-real": "PORT=3115 MQTT_PUBLISH_MODE=broker node scripts/smoke-mqtt-real.mjs",
|
||||
"smoke:qf100": "node scripts/smoke-qf100-adapter.mjs",
|
||||
"deploy:check-env": "node scripts/check-production-env.mjs",
|
||||
"backup:production": "node scripts/backup-production.mjs",
|
||||
"restore:plan": "node scripts/restore-plan.mjs",
|
||||
|
||||
269
scripts/smoke-qf100-adapter.mjs
Normal file
269
scripts/smoke-qf100-adapter.mjs
Normal file
@ -0,0 +1,269 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
|
||||
const BASE = process.env.BASE_URL || `http://127.0.0.1:${process.env.PORT || "3000"}`;
|
||||
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || "admin-dev-token";
|
||||
const DEVICE_TOKEN = process.env.DEVICE_TOKEN || "device-dev-token";
|
||||
const SECRET = process.env.INTEGRATION_WEBHOOK_SECRET || "dev-callback-secret";
|
||||
const STATIC_SN = process.env.QF100_STATIC_SN || `QF100-STATIC-${Date.now()}`;
|
||||
const DYNAMIC_SN = process.env.QF100_DYNAMIC_SN || `QF100-DYNAMIC-${Date.now()}`;
|
||||
|
||||
function short(data) {
|
||||
const text = typeof data === "string" ? data : JSON.stringify(data || {});
|
||||
return text.length > 220 ? `${text.slice(0, 220)}...` : text;
|
||||
}
|
||||
|
||||
async function req(path, options = {}) {
|
||||
const response = await fetch(`${BASE}${path}`, {
|
||||
method: options.method || "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers || {})
|
||||
},
|
||||
body: Object.prototype.hasOwnProperty.call(options, "body") ? JSON.stringify(options.body) : undefined
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let body = null;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
body = text;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`${options._label || path} failed: ${response.status} ${short(body)}`);
|
||||
}
|
||||
|
||||
console.log(`${options._label || `${options.method || "GET"} ${path}`} => ${response.status} ${short(body)}`);
|
||||
return body?.data !== undefined ? body.data : body;
|
||||
}
|
||||
|
||||
function reqAdmin(path, options = {}) {
|
||||
return req(path, {
|
||||
...options,
|
||||
headers: {
|
||||
...(options.headers || {}),
|
||||
Authorization: `Bearer ${ADMIN_TOKEN}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function reqDevice(path, options = {}) {
|
||||
return req(path, {
|
||||
...options,
|
||||
headers: {
|
||||
...(options.headers || {}),
|
||||
Authorization: `Bearer ${DEVICE_TOKEN}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function createBundle({ ts, suffix, serialNumber, terminalMode, capability }) {
|
||||
const merchant = await reqAdmin("/admin/merchants", {
|
||||
method: "POST",
|
||||
body: {
|
||||
legal_name: `QF100 Smoke Merchant ${suffix} ${ts}`,
|
||||
brand_name: `QF100-${suffix}-${ts}`,
|
||||
settlement_account_reference: `bank:qf100:${suffix}:${ts}`,
|
||||
settlement_account_type: "merchant_bank_account",
|
||||
payout_mode: "merchant_direct"
|
||||
},
|
||||
_label: `POST /admin/merchants ${suffix}`
|
||||
});
|
||||
|
||||
const outlet = await reqAdmin(`/admin/merchants/${merchant.id}/outlets`, {
|
||||
method: "POST",
|
||||
body: { name: `QF100 Smoke Outlet ${suffix} ${ts}` },
|
||||
_label: `POST /admin/merchants/:id/outlets ${suffix}`
|
||||
});
|
||||
|
||||
const terminal = await reqAdmin(`/admin/outlets/${outlet.id}/terminals`, {
|
||||
method: "POST",
|
||||
body: { terminal_code: `QF100-${suffix}-TERM-${ts}`, qr_mode: terminalMode },
|
||||
_label: `POST /admin/outlets/:id/terminals ${suffix}`
|
||||
});
|
||||
|
||||
const device = await reqAdmin("/admin/devices", {
|
||||
method: "POST",
|
||||
body: {
|
||||
device_code: `QF100-${suffix}-DEV-${ts}`,
|
||||
serial_number: serialNumber,
|
||||
vendor: "QF",
|
||||
model: "QF100",
|
||||
communication_mode: "mqtt",
|
||||
status: "active",
|
||||
capability_profile_json: {
|
||||
mqtt_payload_profile: "qf100",
|
||||
...(capability || {})
|
||||
}
|
||||
},
|
||||
_label: `POST /admin/devices ${suffix}`
|
||||
});
|
||||
|
||||
await reqAdmin(`/admin/devices/${device.id}/bind`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
merchant_id: merchant.id,
|
||||
outlet_id: outlet.id,
|
||||
terminal_id: terminal.id
|
||||
},
|
||||
_label: `POST /admin/devices/:id/bind ${suffix}`
|
||||
});
|
||||
|
||||
return { merchant, outlet, terminal, device };
|
||||
}
|
||||
|
||||
async function pullQf100Config(serialNumber, label) {
|
||||
const query = new URLSearchParams({
|
||||
"dev-model": "QF100",
|
||||
"item-number": "00",
|
||||
"dev-sn": serialNumber,
|
||||
"fw-version": "1.0.0",
|
||||
"fw-build": "1",
|
||||
"app-config-version": "1",
|
||||
imei: `${label}-imei`,
|
||||
imsi: `${label}-imsi`,
|
||||
iccid: `${label}-iccid`
|
||||
});
|
||||
const config = await req(`/speaker/dev-config?${query.toString()}`, {
|
||||
_label: `GET /speaker/dev-config ${label}`
|
||||
});
|
||||
|
||||
assert(config["error-code"] === 0, `${label} config error-code must be 0`);
|
||||
assert(config.mqtt?.["broker-ip"], `${label} config must include mqtt.broker-ip`);
|
||||
assert(config.mqtt?.["broker-port"], `${label} config must include mqtt.broker-port`);
|
||||
assert(config.mqtt?.["subscribe-topic"]?.includes("/downlink/qf100"), `${label} must subscribe qf100 topic`);
|
||||
return config;
|
||||
}
|
||||
|
||||
async function waitForQf100PaymentMessage(deviceId) {
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
const data = await reqAdmin(`/admin/devices/${deviceId}/mqtt-messages?direction=downlink&message_type=payment_success&limit=10`, {
|
||||
_label: `GET /admin/devices/:id/mqtt-messages attempt ${i + 1}`
|
||||
});
|
||||
const found = data.messages?.find((message) => message.topic === `devices/${deviceId}/downlink/qf100`);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
throw new Error("QF100 payment downlink message not found");
|
||||
}
|
||||
|
||||
async function triggerStaticPayment({ bundle, ts }) {
|
||||
const partnerReference = `QF100-PR-${ts}`;
|
||||
await reqAdmin("/admin/transactions", {
|
||||
method: "POST",
|
||||
body: {
|
||||
partner_reference: partnerReference,
|
||||
merchant_id: bundle.merchant.id,
|
||||
outlet_id: bundle.outlet.id,
|
||||
terminal_id: bundle.terminal.id,
|
||||
device_id: bundle.device.id,
|
||||
amount: 15000,
|
||||
currency: "IDR",
|
||||
qr_mode: "static",
|
||||
initiation_mode: "static",
|
||||
status: "initiated"
|
||||
},
|
||||
_label: "POST /admin/transactions static"
|
||||
});
|
||||
|
||||
const callback = {
|
||||
partner_reference: partnerReference,
|
||||
partner_txn_id: `QF100-PTX-${ts}`,
|
||||
amount: 15000,
|
||||
currency: "IDR",
|
||||
payment_status: "paid",
|
||||
status: "paid",
|
||||
paid_at: new Date().toISOString()
|
||||
};
|
||||
const signature = createHmac("sha256", SECRET).update(JSON.stringify(callback)).digest("hex");
|
||||
await req("/integrations/qris/callback", {
|
||||
method: "POST",
|
||||
headers: { "X-Partner-Signature": signature },
|
||||
body: { ...callback, signature },
|
||||
_label: "POST /integrations/qris/callback static paid"
|
||||
});
|
||||
|
||||
const message = await waitForQf100PaymentMessage(bundle.device.id);
|
||||
assert(message.payload_json?.header?.category === 1, "QF100 payment payload header.category must be 1");
|
||||
assert(message.payload_json?.data?.["pay-amount"] === 15000, "QF100 payment payload pay-amount must match");
|
||||
return message;
|
||||
}
|
||||
|
||||
async function triggerDynamicMqttQr({ bundle, ts }) {
|
||||
const requestId = `QF100-DYN-${ts}`;
|
||||
const response = await reqDevice("/device/mqtt/uplink/dynamic-qr/request", {
|
||||
method: "POST",
|
||||
body: {
|
||||
message_type: "dynamic_qr_request",
|
||||
request_id: requestId,
|
||||
device_id: bundle.device.id,
|
||||
terminal_id: bundle.terminal.id,
|
||||
amount: 27500,
|
||||
currency: "IDR",
|
||||
created_at: new Date().toISOString()
|
||||
},
|
||||
_label: "POST /device/mqtt/uplink/dynamic-qr/request"
|
||||
});
|
||||
|
||||
assert(response.status === "success", "dynamic MQTT QR response must be success");
|
||||
assert(response.qr_payload, "dynamic MQTT QR response must include qr_payload");
|
||||
return response;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await req("/health", { _label: "GET /health" });
|
||||
const ts = Date.now();
|
||||
|
||||
const staticBundle = await createBundle({
|
||||
ts,
|
||||
suffix: "STATIC",
|
||||
serialNumber: STATIC_SN,
|
||||
terminalMode: "static",
|
||||
capability: {}
|
||||
});
|
||||
const dynamicBundle = await createBundle({
|
||||
ts,
|
||||
suffix: "DYNAMIC",
|
||||
serialNumber: DYNAMIC_SN,
|
||||
terminalMode: "dynamic_mqtt",
|
||||
capability: {
|
||||
dynamic_qr: {
|
||||
mqtt: true
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const staticConfig = await pullQf100Config(STATIC_SN, "static");
|
||||
const dynamicConfig = await pullQf100Config(DYNAMIC_SN, "dynamic");
|
||||
assert(staticConfig.mqtt["client-id"] === staticBundle.device.id, "static client-id must match device id");
|
||||
assert(dynamicConfig.mqtt["client-id"] === dynamicBundle.device.id, "dynamic client-id must match device id");
|
||||
|
||||
const staticPaymentMessage = await triggerStaticPayment({ bundle: staticBundle, ts });
|
||||
const dynamicQr = await triggerDynamicMqttQr({ bundle: dynamicBundle, ts });
|
||||
|
||||
console.log("\nQF100 adapter smoke passed");
|
||||
console.log(`static_sn=${STATIC_SN}`);
|
||||
console.log(`static_device_id=${staticBundle.device.id}`);
|
||||
console.log(`static_payment_topic=${staticPaymentMessage.topic}`);
|
||||
console.log(`dynamic_sn=${DYNAMIC_SN}`);
|
||||
console.log(`dynamic_device_id=${dynamicBundle.device.id}`);
|
||||
console.log(`dynamic_transaction_id=${dynamicQr.transaction_id}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -10,6 +10,7 @@ import adminRoutes from "./routes/admin";
|
||||
import integrationRoutes from "./routes/integrations";
|
||||
import deviceRoutes from "./routes/device";
|
||||
import merchantRoutes from "./routes/merchant";
|
||||
import speakerRoutes from "./routes/speaker";
|
||||
import { startNotificationOrchestrator } from "./shared/orchestrators/notificationOrchestrator";
|
||||
import { startDynamicQrExpiryScheduler } from "./shared/services/dynamicQrExpiryScheduler";
|
||||
import { startExportJobWorker } from "./shared/services/exportJobWorker";
|
||||
@ -135,12 +136,14 @@ app.use("/admin", (req, res, next) => {
|
||||
return next();
|
||||
});
|
||||
app.use("/device", deviceLimiter);
|
||||
app.use("/speaker", deviceLimiter);
|
||||
app.use("/integrations", rateLimit({ name: "integrations", windowMs: env.RATE_LIMIT_DEVICE_WINDOW_MS, max: env.RATE_LIMIT_DEVICE_MAX }));
|
||||
|
||||
app.use("/admin", adminRoutes);
|
||||
app.use("/merchant", merchantRoutes);
|
||||
app.use("/integrations", integrationRoutes);
|
||||
app.use("/device", deviceRoutes);
|
||||
app.use("/speaker", speakerRoutes);
|
||||
|
||||
app.use((err: Error, _req: Request, res: Response, next: NextFunction) => {
|
||||
handleErrors(err, _req, res, next);
|
||||
|
||||
@ -35,6 +35,11 @@ export const env = {
|
||||
MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS: Number(
|
||||
process.env.MQTT_PUBLISH_DEFAULT_RETRY_INTERVAL_MS || 15000
|
||||
),
|
||||
QF100_MQTT_BROKER_HOST: process.env.QF100_MQTT_BROKER_HOST || "",
|
||||
QF100_MQTT_BROKER_PORT: Number(process.env.QF100_MQTT_BROKER_PORT || 0),
|
||||
QF100_MQTT_USERNAME: process.env.QF100_MQTT_USERNAME || "",
|
||||
QF100_MQTT_PASSWORD: process.env.QF100_MQTT_PASSWORD || "",
|
||||
QF100_MQTT_KEEP_ALIVE_SECONDS: Number(process.env.QF100_MQTT_KEEP_ALIVE_SECONDS || 60),
|
||||
DYNAMIC_QR_EXPIRY_SCHEDULER_ENABLED: process.env.DYNAMIC_QR_EXPIRY_SCHEDULER_ENABLED || "true",
|
||||
DYNAMIC_QR_EXPIRY_SWEEP_INTERVAL_MS: Number(process.env.DYNAMIC_QR_EXPIRY_SWEEP_INTERVAL_MS || 60000),
|
||||
DYNAMIC_QR_EXPIRY_SWEEP_LIMIT: Number(process.env.DYNAMIC_QR_EXPIRY_SWEEP_LIMIT || 100),
|
||||
|
||||
125
src/routes/speaker.ts
Normal file
125
src/routes/speaker.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import { Router, Request, Response, NextFunction } from "express";
|
||||
import { env } from "../config/env";
|
||||
import { createDeviceHeartbeat } from "../shared/store/heartbeatStore";
|
||||
import { getDeviceBySerialNumber, patchDevice } from "../shared/store/deviceStore";
|
||||
|
||||
const router = Router();
|
||||
|
||||
type Qf100ConfigRequest = {
|
||||
"dev-model"?: string;
|
||||
"item-number"?: string;
|
||||
"dev-sn"?: string;
|
||||
"fw-version"?: string;
|
||||
"fw-build"?: number;
|
||||
"app-config-version"?: number;
|
||||
imei?: string;
|
||||
imsi?: string;
|
||||
iccid?: string;
|
||||
};
|
||||
|
||||
function parseBrokerUrl() {
|
||||
if (!env.MQTT_BROKER_URL) {
|
||||
return { host: "", port: 0 };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(env.MQTT_BROKER_URL);
|
||||
return {
|
||||
host: parsed.hostname,
|
||||
port: parsed.port ? Number(parsed.port) : parsed.protocol === "mqtts:" ? 8883 : 1883
|
||||
};
|
||||
} catch (_error) {
|
||||
return { host: "", port: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
function getRequestPayload(req: Request): Qf100ConfigRequest {
|
||||
return {
|
||||
...((req.query || {}) as Qf100ConfigRequest),
|
||||
...((req.body || {}) as Qf100ConfigRequest)
|
||||
};
|
||||
}
|
||||
|
||||
function vendorError(res: Response, code: number, message: string) {
|
||||
res.status(200).json({
|
||||
"error-code": code,
|
||||
message
|
||||
});
|
||||
}
|
||||
|
||||
router.get("/dev-config", async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const payload = getRequestPayload(req);
|
||||
const serialNumber = String(payload["dev-sn"] || "").trim();
|
||||
|
||||
if (!serialNumber) {
|
||||
return vendorError(res, 1001, "dev-sn is required");
|
||||
}
|
||||
|
||||
const device = await getDeviceBySerialNumber(serialNumber);
|
||||
if (!device || device.status !== "active") {
|
||||
return vendorError(res, 1002, "device not registered or inactive");
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const firmwareVersion = payload["fw-version"] ? String(payload["fw-version"]) : device.firmware_version;
|
||||
await patchDevice(device.id, {
|
||||
vendor: device.vendor || "QF",
|
||||
model: payload["dev-model"] ? String(payload["dev-model"]) : device.model || "QF100",
|
||||
communication_mode: "mqtt",
|
||||
last_seen_at: now.toISOString(),
|
||||
firmware_version: firmwareVersion
|
||||
});
|
||||
|
||||
await createDeviceHeartbeat({
|
||||
device_id: device.id,
|
||||
timestamp: now.toISOString(),
|
||||
firmware_version: firmwareVersion,
|
||||
state: "config_pull",
|
||||
payload_json: {
|
||||
source: "qf100_dev_config",
|
||||
dev_model: payload["dev-model"],
|
||||
item_number: payload["item-number"],
|
||||
dev_sn: serialNumber,
|
||||
fw_build: payload["fw-build"],
|
||||
app_config_version: payload["app-config-version"],
|
||||
imei: payload.imei,
|
||||
imsi: payload.imsi,
|
||||
iccid: payload.iccid
|
||||
}
|
||||
});
|
||||
|
||||
const brokerFromUrl = parseBrokerUrl();
|
||||
const brokerHost = env.QF100_MQTT_BROKER_HOST || brokerFromUrl.host;
|
||||
const brokerPort = env.QF100_MQTT_BROKER_PORT || brokerFromUrl.port;
|
||||
const username = env.QF100_MQTT_USERNAME || device.mqtt_username || device.id;
|
||||
const password = env.QF100_MQTT_PASSWORD || env.MQTT_PASSWORD;
|
||||
|
||||
if (!brokerHost || !brokerPort) {
|
||||
return vendorError(res, 1003, "mqtt broker is not configured");
|
||||
}
|
||||
|
||||
res.json({
|
||||
"error-code": 0,
|
||||
"bind-state": 1,
|
||||
"app-config-version": 1,
|
||||
"time-stamp": Math.floor(now.getTime() / 1000),
|
||||
"date-time": now.toISOString().replace(/[-:TZ.]/g, "").slice(0, 14),
|
||||
mqtt: {
|
||||
"broker-ip": brokerHost,
|
||||
"broker-port": brokerPort,
|
||||
"client-id": device.id,
|
||||
"user-name": username,
|
||||
password,
|
||||
"subscribe-topic": `devices/${device.id}/downlink/qf100`,
|
||||
"publish-topic": `devices/${device.id}/uplink/qf100`,
|
||||
"keep-alive": env.QF100_MQTT_KEEP_ALIVE_SECONDS,
|
||||
"cert-update": 0
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@ -159,6 +159,7 @@ CREATE TABLE IF NOT EXISTS devices (
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_devices_status_last_seen ON devices (status, last_seen_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_devices_serial_number ON devices (serial_number);
|
||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS mqtt_username TEXT;
|
||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS credential_secret_fingerprint TEXT;
|
||||
ALTER TABLE devices ADD COLUMN IF NOT EXISTS credential_status TEXT NOT NULL DEFAULT 'not_issued';
|
||||
|
||||
@ -10,8 +10,10 @@ import {
|
||||
updateNotification
|
||||
} from "../store/notificationStore";
|
||||
import { getMerchantById } from "../store/merchantStore";
|
||||
import { getDeviceById, type DeviceEntity } from "../store/deviceStore";
|
||||
import { createMqttMessage } from "../store/mqttMessageStore";
|
||||
import { getTransactionById, listTransactions, toTransactionPayload, TransactionEntity } from "../store/transactionStore";
|
||||
import { buildPaymentSuccessPayload, publishPaymentSuccess, MqttPublishResult } from "../services/mqttPublisher";
|
||||
import { buildPaymentSuccessPayload, publishPaymentSuccessForProtocol, MqttPublishResult } from "../services/mqttPublisher";
|
||||
import type { TransactionPaidEvent, TransactionPaidPayload } from "../events/transactionEvents";
|
||||
import { subscribeTransactionPaid } from "../events/transactionEvents";
|
||||
import { env } from "../../config/env";
|
||||
@ -81,6 +83,20 @@ function makeNextRetryDate(retryCount: number) {
|
||||
return new Date(Date.now() + intervalMs).toISOString();
|
||||
}
|
||||
|
||||
function resolvePaymentProtocol(device: DeviceEntity | null): "platform_v1" | "qf100" {
|
||||
const profile = device?.capability_profile_json || {};
|
||||
const configuredProfile = String(
|
||||
profile.mqtt_payload_profile || profile.protocol_profile || profile.vendor_protocol || ""
|
||||
).toLowerCase();
|
||||
const model = String(device?.model || "").toLowerCase();
|
||||
|
||||
if (configuredProfile === "qf100" || model.includes("qf100")) {
|
||||
return "qf100";
|
||||
}
|
||||
|
||||
return "platform_v1";
|
||||
}
|
||||
|
||||
async function getNotificationMerchantName(merchantId: string): Promise<string> {
|
||||
const merchant = await getMerchantById(merchantId);
|
||||
return merchant?.brand_name || merchant?.legal_name || merchantId;
|
||||
@ -107,7 +123,7 @@ function mapMqttFailureState(
|
||||
};
|
||||
}
|
||||
|
||||
async function markNotificationSent(notification: NotificationEntity, publishResult: MqttPublishResult) {
|
||||
async function markNotificationSent(notification: NotificationEntity, publishResult: MqttPublishResult<unknown>) {
|
||||
await updateNotification(notification.id, {
|
||||
delivery_status: "sent",
|
||||
retry_count: notification.retry_count,
|
||||
@ -116,7 +132,7 @@ async function markNotificationSent(notification: NotificationEntity, publishRes
|
||||
});
|
||||
}
|
||||
|
||||
async function markNotificationFailed(notification: NotificationEntity, publishResult: MqttPublishResult) {
|
||||
async function markNotificationFailed(notification: NotificationEntity, publishResult: MqttPublishResult<unknown>) {
|
||||
const next = mapMqttFailureState(notification.retry_count, publishResult.reason);
|
||||
await updateNotification(notification.id, {
|
||||
delivery_status: next.status,
|
||||
@ -209,7 +225,18 @@ async function publishNotificationNow(notification: NotificationEntity, eventPay
|
||||
event_id: notification.event_id
|
||||
});
|
||||
|
||||
const result = await publishPaymentSuccess(mqttPayload);
|
||||
const device = notification.device_id ? await getDeviceById(notification.device_id) : null;
|
||||
const result = await publishPaymentSuccessForProtocol(mqttPayload, resolvePaymentProtocol(device));
|
||||
await createMqttMessage({
|
||||
direction: "downlink",
|
||||
device_id: notification.device_id || String(effectivePayload.device_id || ""),
|
||||
topic: result.topic,
|
||||
message_type: "payment_success",
|
||||
correlation_id: notification.event_id,
|
||||
payload_json: result.payload as Record<string, unknown>,
|
||||
publish_status: result.ok ? "sent" : "failed",
|
||||
reason: result.reason
|
||||
});
|
||||
if (!result.ok) {
|
||||
await markNotificationFailed(notification, result);
|
||||
return;
|
||||
|
||||
@ -16,6 +16,17 @@ type PaymentSuccessPayload = {
|
||||
display_text: string;
|
||||
};
|
||||
|
||||
type PaymentSuccessProtocol = "platform_v1" | "qf100";
|
||||
|
||||
type Qf100PaymentSuccessPayload = {
|
||||
header: {
|
||||
category: 1;
|
||||
};
|
||||
data: {
|
||||
"pay-amount": number;
|
||||
};
|
||||
};
|
||||
|
||||
type DynamicQrResponsePayload = {
|
||||
message_type: "dynamic_qr_response";
|
||||
correlation_id: string;
|
||||
@ -179,6 +190,10 @@ export function makePaymentSuccessTopic(deviceId: string) {
|
||||
return `devices/${deviceId}/downlink/payment/success`;
|
||||
}
|
||||
|
||||
export function makeQf100DownlinkTopic(deviceId: string) {
|
||||
return `devices/${deviceId}/downlink/qf100`;
|
||||
}
|
||||
|
||||
export function makeDynamicQrResponseTopic(deviceId: string) {
|
||||
return `devices/${deviceId}/downlink/dynamic-qr/response`;
|
||||
}
|
||||
@ -241,6 +256,26 @@ export async function publishPaymentSuccess(payload: PaymentSuccessPayload): Pro
|
||||
return publishMqttPayload(payload.device_id, makePaymentSuccessTopic(payload.device_id), payload);
|
||||
}
|
||||
|
||||
export async function publishPaymentSuccessForProtocol(
|
||||
payload: PaymentSuccessPayload,
|
||||
protocol: PaymentSuccessProtocol
|
||||
): Promise<MqttPublishResult<PaymentSuccessPayload | Qf100PaymentSuccessPayload>> {
|
||||
if (protocol !== "qf100") {
|
||||
return publishMqttPayload(payload.device_id, makePaymentSuccessTopic(payload.device_id), payload);
|
||||
}
|
||||
|
||||
const qf100Payload: Qf100PaymentSuccessPayload = {
|
||||
header: {
|
||||
category: 1
|
||||
},
|
||||
data: {
|
||||
"pay-amount": payload.amount
|
||||
}
|
||||
};
|
||||
|
||||
return publishMqttPayload(payload.device_id, makeQf100DownlinkTopic(payload.device_id), qf100Payload);
|
||||
}
|
||||
|
||||
export async function publishDynamicQrResponse(deviceId: string, payload: DynamicQrResponsePayload) {
|
||||
return publishMqttPayload(deviceId, makeDynamicQrResponseTopic(deviceId), payload);
|
||||
}
|
||||
|
||||
@ -137,6 +137,13 @@ export async function getDeviceById(id: string): Promise<DeviceEntity | null> {
|
||||
return rows[0] ? mapDevice(rows[0]) : null;
|
||||
}
|
||||
|
||||
export async function getDeviceBySerialNumber(serialNumber: string): Promise<DeviceEntity | null> {
|
||||
const { rows } = await getPool().query("SELECT * FROM devices WHERE serial_number = $1 ORDER BY created_at DESC LIMIT 1", [
|
||||
serialNumber
|
||||
]);
|
||||
return rows[0] ? mapDevice(rows[0]) : null;
|
||||
}
|
||||
|
||||
export async function patchDevice(id: string, patch: Partial<DeviceEntity>): Promise<DeviceEntity> {
|
||||
const existing = await getDeviceById(id);
|
||||
if (!existing) {
|
||||
|
||||
Reference in New Issue
Block a user