diff --git a/.env.example b/.env.example index 3d26b00..2956d49 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index ba58a91..174a717 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ npm-debug.log* .DS_Store .env +QF100-60s-l511-SecondApp-260107/ diff --git a/18-mqtt-broker-mosquitto-debian13.md b/18-mqtt-broker-mosquitto-debian13.md index fa69008..f81c5ac 100644 --- a/18-mqtt-broker-mosquitto-debian13.md +++ b/18-mqtt-broker-mosquitto-debian13.md @@ -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 diff --git a/CODEX_HANDOFF.md b/CODEX_HANDOFF.md index 93a9bfe..d9bf5d7 100644 --- a/CODEX_HANDOFF.md +++ b/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= \ + QF100_DYNAMIC_SN= \ + 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`. diff --git a/DEBIAN12_APP_SERVER_SETUP.md b/DEBIAN12_APP_SERVER_SETUP.md new file mode 100644 index 0000000..ba00a91 --- /dev/null +++ b/DEBIAN12_APP_SERVER_SETUP.md @@ -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 /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 --name --role admin --password ' +sudo -u qrisapp bash -lc 'set -a; source /etc/qris-soundbox/qris-soundbox.env; set +a; npm run merchant:create-user -- --merchant --email --name --role owner --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' +``` diff --git a/DECISIONS_LOG.md b/DECISIONS_LOG.md index 83b9102..e299c7d 100644 --- a/DECISIONS_LOG.md +++ b/DECISIONS_LOG.md @@ -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. diff --git a/DEPLOYMENT_READINESS.md b/DEPLOYMENT_READINESS.md index ae8d51e..5cfaeed 100644 --- a/DEPLOYMENT_READINESS.md +++ b/DEPLOYMENT_READINESS.md @@ -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 diff --git a/README.md b/README.md index a4558e6..d53e45e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package.json b/package.json index d860142..2d4f7f6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/smoke-qf100-adapter.mjs b/scripts/smoke-qf100-adapter.mjs new file mode 100644 index 0000000..9f0f0e5 --- /dev/null +++ b/scripts/smoke-qf100-adapter.mjs @@ -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); +}); diff --git a/src/app.ts b/src/app.ts index b5fa072..c088363 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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); diff --git a/src/config/env.ts b/src/config/env.ts index 526e316..398f541 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -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), diff --git a/src/routes/speaker.ts b/src/routes/speaker.ts new file mode 100644 index 0000000..055acbc --- /dev/null +++ b/src/routes/speaker.ts @@ -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; diff --git a/src/shared/db/pool.ts b/src/shared/db/pool.ts index 4fc3c15..2651696 100644 --- a/src/shared/db/pool.ts +++ b/src/shared/db/pool.ts @@ -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'; diff --git a/src/shared/orchestrators/notificationOrchestrator.ts b/src/shared/orchestrators/notificationOrchestrator.ts index 06ec6a9..468bbd6 100644 --- a/src/shared/orchestrators/notificationOrchestrator.ts +++ b/src/shared/orchestrators/notificationOrchestrator.ts @@ -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 { 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) { 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) { 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, + publish_status: result.ok ? "sent" : "failed", + reason: result.reason + }); if (!result.ok) { await markNotificationFailed(notification, result); return; diff --git a/src/shared/services/mqttPublisher.ts b/src/shared/services/mqttPublisher.ts index d7be4a4..75bb801 100644 --- a/src/shared/services/mqttPublisher.ts +++ b/src/shared/services/mqttPublisher.ts @@ -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> { + 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); } diff --git a/src/shared/store/deviceStore.ts b/src/shared/store/deviceStore.ts index 600990e..c581d9f 100644 --- a/src/shared/store/deviceStore.ts +++ b/src/shared/store/deviceStore.ts @@ -137,6 +137,13 @@ export async function getDeviceById(id: string): Promise { return rows[0] ? mapDevice(rows[0]) : null; } +export async function getDeviceBySerialNumber(serialNumber: string): Promise { + 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): Promise { const existing = await getDeviceById(id); if (!existing) {