Prepare QF100 pilot and Debian app deploy

This commit is contained in:
Wira Basalamah
2026-06-04 11:20:16 +07:00
parent 648e77cee9
commit 8a2e202606
17 changed files with 1135 additions and 216 deletions

View File

@ -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
View File

@ -2,3 +2,4 @@ node_modules/
npm-debug.log*
.DS_Store
.env
QF100-60s-l511-SecondApp-260107/

View File

@ -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

View File

@ -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`.

View 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'
```

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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",

View 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);
});

View File

@ -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);

View File

@ -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
View 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;

View File

@ -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';

View File

@ -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;

View File

@ -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);
}

View File

@ -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) {