Channel Router Service — Local Dev Setup
Version: 1.0 Status: Draft Owner: Messaging Core Last Updated: 2026-04-21 Companion: DEPLOYMENT_TOPOLOGY · TESTING_STRATEGY
This document describes the canonical local dev environment for the channel-router. The aim is first contribution to passing tests in ≤ 30 minutes on a developer laptop.
1. Prerequisites
- Docker 24+ with Compose v2
- Node.js 22 LTS + pnpm 9
bufCLI (gRPC schema lint)grpcurl(gRPC client for manual probes)natsCLI (JetStream debug)psql≥ 16mkcert(local CA for HTTPS testing)- Optional:
k6for load tests
2. Repository layout
services/channel-router-service/
├── src/
│ ├── domain/ # Aggregates, VOs (DOMAIN_MODEL)
│ ├── application/ # Use cases (APPLICATION_LOGIC)
│ ├── infra/
│ │ ├── grpc/ # ChannelRouterService.v1 server
│ │ ├── http/ # REST handlers
│ │ ├── adapters/ # WhatsApp, Telegram, Viber, Voice, Email, SMPP
│ │ ├── persistence/ # Postgres / Redis repositories
│ │ └── messaging/ # NATS consumers + outbox relay
│ └── ml/ # Triton client, feature builder
├── prisma/ # PG schema + migrations (or Drizzle)
├── test/
│ ├── unit/
│ ├── integration/ # Testcontainers
│ ├── contract/ # Pact
│ └── fixtures/ # WhatsApp, Telegram, Viber webhook payloads
├── docker-compose.yml # Local stack
├── .env.example
├── package.json
└── README.md
3. Local Docker Compose stack
# services/channel-router-service/docker-compose.yml
version: "3.9"
name: chan-dev
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: chan
POSTGRES_PASSWORD: chan
POSTGRES_DB: chan
ports: ["5471:5432"]
volumes: ["./test/fixtures/init-db.sql:/docker-entrypoint-initdb.d/init.sql"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U chan"]
interval: 2s
retries: 30
redis:
image: redis:7-alpine
command: ["redis-server", "--save", "", "--appendonly", "no"]
ports: ["6471:6379"]
nats:
image: nats:2.10-alpine
command: ["-js", "-sd", "/data"]
ports: ["4471:4222", "8471:8222"]
volumes: ["nats-data:/data"]
vault:
image: hashicorp/vault:1.16
cap_add: [IPC_LOCK]
environment:
VAULT_DEV_ROOT_TOKEN_ID: "root"
VAULT_DEV_LISTEN_ADDRESS: "0.0.0.0:8200"
ports: ["8471:8200"]
command: ["server", "-dev"]
# Mock OTT providers
mock-whatsapp:
image: ghasi/mock-whatsapp-cloud:dev
environment:
MOCK_WEBHOOK_TARGET: http://channel-router:3071/v1/webhooks/whatsapp
META_APP_SECRET: "mock-app-secret-do-not-use-in-prod"
ports: ["18443:8443"]
mock-telegram:
image: ghasi/mock-telegram-bot:dev
environment:
WEBHOOK_TARGET: http://channel-router:3071/v1/webhooks/telegram/secret-path-dev
ports: ["18444:8443"]
mock-viber:
image: ghasi/mock-viber-pa:dev
environment:
WEBHOOK_TARGET: http://channel-router:3071/v1/webhooks/viber
VIBER_AUTH_TOKEN: "mock-viber-token-dev"
ports: ["18445:8443"]
mock-voice-otp:
image: ghasi/mock-voice-otp:dev
ports: ["50095:50095"]
mock-consent-ledger:
image: ghasi/mock-consent-ledger:dev
ports: ["50051:50051"]
mock-compliance-engine:
image: ghasi/mock-compliance-engine:dev
ports: ["50052:50052"]
mock-sender-id-registry:
image: ghasi/mock-sender-id-registry:dev
ports: ["50081:50081"]
mock-webhook-dispatcher:
image: ghasi/mock-webhook-dispatcher:dev
ports: ["50091:50091"]
mock-smpp-gateway:
image: ghasi/mock-smpp:dev
ports: ["2775:2775"]
triton:
image: nvcr.io/nvidia/tritonserver:24.05-cpu-only
command: ["tritonserver", "--model-repository=/models"]
volumes: ["./test/fixtures/triton-models:/models"]
ports: ["8001:8001"]
volumes:
nats-data:
Bring up:
docker compose up -d
docker compose ps
docker compose logs -f channel-router # once running
4. Environment variables
.env.example:
# Service
NODE_ENV=development
LOG_LEVEL=debug
GRPC_DATA_PORT=50071
GRPC_CTRL_PORT=50072
HTTP_PORT=3071
METRICS_PORT=9061
# Datastores
DATABASE_URL=postgresql://chan:chan@localhost:5471/chan
REDIS_URL=redis://localhost:6471
NATS_URL=nats://localhost:4471
# Vault (dev-mode)
VAULT_ADDR=http://localhost:8471
VAULT_TOKEN=root
# Dependency mocks
CONSENT_LEDGER_URL=localhost:50051
COMPLIANCE_ENGINE_URL=localhost:50052
SENDER_ID_REGISTRY_URL=localhost:50081
WEBHOOK_DISPATCHER_URL=localhost:50091
TRITON_URL=localhost:8001
# OTT mocks
WHATSAPP_API_BASE=https://localhost:18443/v20.0
META_APP_SECRET=mock-app-secret-do-not-use-in-prod
TELEGRAM_API_BASE=https://localhost:18444
VIBER_API_BASE=https://localhost:18445
# Sovereignty guard (must be false locally too)
CHAN_EXTERNAL_LLM_ENABLED=false
CHAN_ML_PREFERENCE_ORDERING_ENABLED=true
# Tunables
ROUTE_DECISION_BUDGET_MS=50
GATE_DEADLINE_MS=15
MAX_INFLIGHT_GRPC=1000
MAX_INFLIGHT_CONSUMER=200
5. Bootstrap commands
# Install
pnpm install
# Generate proto bindings
pnpm proto:gen
# Run migrations
pnpm db:migrate
# Seed dev data
pnpm db:seed
# Start service
pnpm dev # tsx watch with auto-reload
pnpm db:seed populates:
- Tenants
t_pilot_bank,t_pilot_ministry,t_test_ecommerce. - Default fallback policies for
otp,txn,marketing,alert. - Sample inbound routes (shortcode
2211 → t_pilot_bank,3344 → t_pilot_ministry). - Sample WhatsApp/Telegram/Viber adapter configs (using mock provider creds in Vault).
- 1000 sample recipient profiles in
LEARNINGdiscovery state.
6. Common dev commands
6.1 Probe gRPC RouteWithFallback
grpcurl -plaintext \
-d '{
"notification_id": "n_dev_001",
"recipient_id": "r_dev_001",
"tenant_id": "t_pilot_bank",
"use_case": "otp",
"msisdn": "+93701234567",
"body": "Your OTP is 123456",
"segments": 1,
"encoding": "GSM7",
"sender_id": "PILOT_BANK"
}' \
localhost:50071 \
ghasi.sms.channel.v1.ChannelRouterService/RouteWithFallback
6.2 Simulate OTT failure to trigger fallback
# Force WhatsApp mock into 5xx mode
curl -X POST http://localhost:18443/admin/fault \
-d '{"mode": "always_5xx", "ttlSeconds": 120}'
# Send dispatch
grpcurl ... RouteWithFallback ...
# Watch fallback events
nats consumer add CHANNEL_EVENTS dev-watch \
--subject="channel.fallback.taken.v1" --pull
nats consumer next CHANNEL_EVENTS dev-watch --count=10
6.3 Test MO routing to mock webhook
# Publish synthetic mo.allowed.v1
nats pub mo.allowed.v1 '{
"messageId": "mo_dev_001",
"originatorMsisdn": "+93701234567",
"destination": "2211",
"body": "Hello",
"mno": "AWCC",
"receivedAt": "2026-04-21T10:00:00Z"
}'
# Inspect mock webhook capture
curl http://localhost:50091/__captured | jq .
6.4 Test STOP-keyword closure
nats pub mo.allowed.v1 '{
"messageId": "mo_dev_002",
"originatorMsisdn": "+93701234567",
"destination": "2211",
"body": "STOP",
"mno": "AWCC",
"receivedAt": "2026-04-21T10:01:00Z"
}'
# Verify mock-consent-ledger received RecordOptOut
curl http://localhost:50051/__captured | jq '.recordOptOut'
6.5 Inspect conversation session
grpcurl -plaintext \
-d '{"tenant_id":"t_pilot_bank","sender_id":"PILOT_BANK","msisdn":"+93701234567"}' \
localhost:50071 \
ghasi.sms.channel.v1.ChannelRouterService/GetConversationSession
6.6 Inspect Redis state
redis-cli -p 6471 KEYS 'chan:session:*'
redis-cli -p 6471 HGETALL 'chan:session:PILOT_BANK:0a1b2c...'
redis-cli -p 6471 ZRANGE chan:deadlines 0 -1 WITHSCORES
6.7 Run tests
pnpm test:unit
pnpm test:integration # spins up Testcontainers
pnpm test:contract
pnpm test:e2e # requires full stack
pnpm test:load # k6/ghz; opt-in
7. Useful queries
-- Recent executions
SELECT execution_id, tenant_id, use_case, outcome, total_cost_ngn, fallback_path
FROM chan.fallback_executions
WHERE started_at > now() - interval '1 hour'
ORDER BY started_at DESC LIMIT 20;
-- Fallback ratio (last hour)
SELECT
COUNT(*) FILTER (WHERE outcome = 'DELIVERED' AND jsonb_array_length(fallback_path) > 1)::float
/ COUNT(*) AS fallback_ratio
FROM chan.fallback_executions
WHERE started_at > now() - interval '1 hour';
-- Adapter circuit states
SELECT provider, circuit_state, circuit_opened_at
FROM chan.channel_adapter_configs
ORDER BY provider;
-- Active sessions per tenant
SELECT tenant_id, COUNT(*) AS active
FROM chan.conversations
WHERE status = 'OPEN'
GROUP BY tenant_id;
8. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Pod refuses to boot with "sovereignty guard" | CHAN_EXTERNAL_LLM_ENABLED=true | Set false |
RouteWithFallback returns REFUSED_CONSENT_UNKNOWN | mock-consent-ledger down or unreachable | docker compose restart mock-consent-ledger |
| Adapter circuit stuck OPEN locally | Mock returning 5xx | curl -X DELETE http://localhost:18443/admin/fault |
| Webhook signature 401 | META_APP_SECRET mismatch between mock and service | Re-export from .env |
| Outbox backlog grows | NATS consumer paused | nats consumer info CHANNEL_EVENTS chan-router |
9. Resetting state
# Wipe everything and restart
docker compose down -v
docker compose up -d
pnpm db:migrate && pnpm db:seed