Skip to main content

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
  • buf CLI (gRPC schema lint)
  • grpcurl (gRPC client for manual probes)
  • nats CLI (JetStream debug)
  • psql ≥ 16
  • mkcert (local CA for HTTPS testing)
  • Optional: k6 for 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 LEARNING discovery 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

SymptomLikely causeFix
Pod refuses to boot with "sovereignty guard"CHAN_EXTERNAL_LLM_ENABLED=trueSet false
RouteWithFallback returns REFUSED_CONSENT_UNKNOWNmock-consent-ledger down or unreachabledocker compose restart mock-consent-ledger
Adapter circuit stuck OPEN locallyMock returning 5xxcurl -X DELETE http://localhost:18443/admin/fault
Webhook signature 401META_APP_SECRET mismatch between mock and serviceRe-export from .env
Outbox backlog growsNATS consumer pausednats 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