Consent Ledger Service — Local Dev Setup
Version: 1.0 Status: Draft Owner: Trust & Safety Last Updated: 2026-04-21 Companion: TESTING_STRATEGY · DEPLOYMENT_TOPOLOGY
1. Prerequisites
| Tool | Version | Purpose |
|---|---|---|
| Node.js | 20 LTS+ | Runtime |
| pnpm | 9+ | Package manager |
| Docker Desktop | 24+ | Postgres, Redis, NATS, mock-ATRA-DND containers |
| grpcurl | latest | gRPC testing |
| psql | 16+ | Direct DB access for verifying audit chain |
| nats CLI | 0.1+ | NATS stream/subject inspection |
| mkcert | latest | Local mTLS dev certs (optional; default dev runs without TLS) |
| jq | 1.7+ | Pretty-print structured logs |
2. Clone and install
git clone git@github.com:ghasi/sms-gateway.git
cd sms-gateway/services/consent-ledger-service
pnpm install
3. Start infrastructure
The repo provides a service-scoped docker-compose.yml at services/consent-ledger-service/docker-compose.dev.yml that includes the mock ATRA DND server.
docker compose -f docker-compose.dev.yml up -d
This starts:
| Service | Host:Port | Notes |
|---|---|---|
| PostgreSQL 16 | localhost:5432 | user ghasi, password ghasi_dev, db ghasi_dev; schema consent |
| Redis 7 (single-node) | localhost:6379 | DB 4 used by service |
| NATS 2.10 (JetStream enabled) | localhost:4222 | Streams pre-created |
| Mock ATRA DND server | localhost:2222 (SFTP) + localhost:8080 (HTTPS) | Serves /dnd/latest.csv from a mounted fixture |
| Vault dev mode | localhost:8200 | Token dev-only-root-token; pre-seeded paths |
The compose file mounts test/fixtures/atra-dnd/ into the mock ATRA container; you can swap the fixture during testing.
4. Database setup
pnpm prisma migrate dev --name init_consent_schema
pnpm db:seed
The seed script (scripts/seed.ts) populates:
- Default STOP-keyword catalog (English, Dari, Pashto, Arabic — see DOMAIN_MODEL §6)
- Sample tenant
t_dev_acmewith three opt-in records and one opt-out across scopes - Sample DND entries (~50 fake
+9379999XXXXMSISDNs in the reserved test range) - Mock policy
consent.policy.stop_scope = PER_TENANT - Mock ack-back templates for all four languages
5. Environment variables
Create .env.local in services/consent-ledger-service/:
NODE_ENV=development
LOG_LEVEL=debug
GRPC_PORT=50071
HTTP_PORT=3071
DATABASE_URL=postgresql://ghasi:ghasi_dev@localhost:5432/ghasi_dev?schema=consent
DATABASE_REPLICA_URL=postgresql://ghasi:ghasi_dev@localhost:5432/ghasi_dev?schema=consent
REDIS_URL=redis://localhost:6379/4
NATS_URL=nats://localhost:4222
# Disable mTLS for local dev
GRPC_TLS_ENABLED=false
# Vault dev mode
VAULT_ADDR=http://localhost:8200
VAULT_TOKEN=dev-only-root-token
VAULT_TRANSIT_KEY_PATH=transit/ghasi-consent-audit-signing
# Mock ATRA endpoint
ATRA_DND_ENDPOINT=http://localhost:8080/dnd/latest.csv
ATRA_PGP_FINGERPRINT=DEV0000000000000000000000000000000000000
# Pepper version - dev fixed
PEPPER_VERSION=v_dev
# Per-evaluation budgets
EVAL_BUDGET_MS=15
STOP_MO_BUDGET_MS=1500
# Region (for metric labels)
REGION=af-kabul-dev
To enable mTLS locally (for testing the production wiring):
pnpm gen:dev-certs # uses mkcert; output to ./certs/
GRPC_TLS_ENABLED=true
TLS_CERT_PATH=./certs/server.crt
TLS_KEY_PATH=./certs/server.key
TLS_CA_PATH=./certs/ca.crt
6. Run the service
# Hot-reload dev
pnpm start:dev
# With debugger attached
pnpm start:debug
# Production-mode locally
pnpm build && pnpm start:prod
Health endpoints:
http://localhost:3071/health/livehttp://localhost:3071/health/readyhttp://localhost:3071/metrics
7. Test the gRPC endpoints
CheckConsent
grpcurl -plaintext \
-proto src/proto/consent.proto \
-d '{
"tenant_id": "t_dev_acme",
"msisdn": "+93701234567",
"scope": "MARKETING",
"trace_id": "local-dev-1"
}' \
localhost:50071 \
ghasi.sms.consent.v1.ConsentLedgerService/CheckConsent
Expected (after seed): { "allowed": true, "reason": "ALLOWED_TENANT_RECORD", "recordId": "cn_..." }.
RecordConsent
grpcurl -plaintext \
-proto src/proto/consent.proto \
-d '{
"tenant_id": "t_dev_acme",
"msisdn": "+93709988776",
"scope": "MARKETING",
"method": "TENANT_API",
"source": { "type": "WEB_FORM", "ref": "campaign-x", "captured_at": "2026-04-21T10:00:00Z" }
}' \
localhost:50071 \
ghasi.sms.consent.v1.ConsentLedgerService/RecordConsent
RevokeConsent
grpcurl -plaintext \
-proto src/proto/consent.proto \
-d '{
"tenant_id": "t_dev_acme",
"msisdn": "+93709988776",
"scope": "MARKETING",
"reason": "TENANT_API"
}' \
localhost:50071 \
ghasi.sms.consent.v1.ConsentLedgerService/RevokeConsent
Test fail-closed
docker compose -f docker-compose.dev.yml stop postgres redis
# Now run CheckConsent above; expect:
# { "allowed": false, "reason": "CONSENT_UNKNOWN" }
docker compose -f docker-compose.dev.yml start postgres redis
8. Test the REST API
# Generate a dev JWT (mocks auth-service; tied to seeded tenant + role)
DEV_JWT=$(pnpm token:dev --tenant t_dev_acme --role consent:write --quiet)
# Create a record
curl -X POST http://localhost:3071/v1/consent/records \
-H "Authorization: Bearer $DEV_JWT" \
-H "Content-Type: application/json" \
-d '{
"msisdn": "+93709988776",
"scope": "MARKETING",
"verificationMethod": "WEB_FORM",
"source": { "type": "WEB_FORM", "ref": "spring-2026", "capturedAt": "2026-04-21T10:00:00Z" }
}'
# Initiate double-opt-in
curl -X POST http://localhost:3071/v1/consent/double-opt-in/initiate \
-H "Authorization: Bearer $DEV_JWT" \
-d '{ "msisdn": "+93709988776", "scope": "MARKETING" }'
# Citizen self-service (uses dev citizen JWT generator)
CITIZEN_JWT=$(pnpm token:citizen --msisdn +93701234567 --quiet)
curl -H "Authorization: Bearer $CITIZEN_JWT" \
http://localhost:3071/v1/consent/citizen/records
# Admin: trigger DND resync
ADMIN_JWT=$(pnpm token:dev --role platform.consent.admin --quiet)
curl -X POST -H "Authorization: Bearer $ADMIN_JWT" \
http://localhost:3071/v1/admin/consent/dnd/resync
9. Simulate STOP MO
# Publish a fake MO to NATS (mimicking channel-router-service)
nats pub sms.mo.inbound '{
"schemaVersion": "1",
"eventId": "00000000-0000-0000-0000-000000000001",
"moId": "mo_dev_001",
"msisdn": "+93701234567",
"senderIdReceived": "ACMEBANK",
"body": "STOP",
"encoding": "GSM7",
"language": "EN",
"smscReceivedAt": "2026-04-21T11:00:00Z",
"traceId": "dev-mo-1",
"at": "2026-04-21T11:00:00Z"
}'
# Watch the consumer process it
nats stream view CONSENT_EVENTS
# Verify revoke landed
psql $DATABASE_URL -c "SELECT consent_id, status, revoked_at, revoked_reason FROM consent.records WHERE msisdn='+93701234567' ORDER BY created_at DESC LIMIT 3;"
Try multilingual:
# Pashto
nats pub sms.mo.inbound '{ "schemaVersion": "1", "moId": "mo_dev_002", "msisdn": "+93701234567", "senderIdReceived": "ACMEBANK", "body": "بنديدل", "language": "PS", "smscReceivedAt": "2026-04-21T11:01:00Z", "at": "2026-04-21T11:01:00Z", "eventId": "00000000-0000-0000-0000-000000000002" }'
10. Verify audit chain
pnpm audit:verify --partition consent_audit_2026_04
# OK output: "Chain verified: 12,345 rows, no breaks"
To intentionally tamper and confirm detection:
-- INSIDE a test partition only — this rule rejection happens at runtime in prod:
BEGIN;
ALTER TABLE consent.audit_2026_04 DISABLE RULE consent_audit_no_update;
UPDATE consent.audit_2026_04 SET payload = jsonb_set(payload, '{tampered}', 'true') WHERE seq = 100;
ALTER TABLE consent.audit_2026_04 ENABLE RULE consent_audit_no_update;
COMMIT;
pnpm audit:verify --partition consent_audit_2026_04
# Expected: "CHAIN BROKEN at seq 100: payload_hash mismatch"
(Restore from a clean snapshot before continuing dev.)
11. Running tests
# Unit
pnpm test
# Unit + coverage
pnpm test:cov
# Integration (Testcontainers)
pnpm test:integration
# Watch mode
pnpm test:watch
# E2E (Playwright + spawned service)
pnpm test:e2e
# Load test (ghz; requires the service running)
pnpm test:load
12. Database migrations
# Create a migration
pnpm prisma migrate dev --name add_new_audit_event_type
# Apply to dev DB
pnpm prisma migrate deploy
# Reset (destructive)
pnpm prisma migrate reset
# Provision next-month audit partition
pnpm scripts/maintain-partitions.ts --month 2026-05
13. Helpful dev commands
# Seed an extra rule keyword
pnpm seed:keyword --language EN --keyword "leaveme" --tenant t_dev_acme
# Trigger DND sync manually (fetches from mock ATRA)
pnpm worker:dnd-sync
# Verify hash chain
pnpm audit:verify
# Force erasure for a MSISDN
pnpm scripts/erasure.ts --msisdn +93701234567
# Clear Redis caches
pnpm cache:clear
# Tail structured logs as JSON
pnpm logs:tail | jq
# Generate gRPC TS types from proto
pnpm proto:generate
# Pretty-print outbox backlog
psql $DATABASE_URL -c "SELECT subject, count(*) FROM consent.outbox WHERE published_at IS NULL GROUP BY subject;"
14. Common issues
| Issue | Fix |
|---|---|
Could not connect to Postgres | docker compose -f docker-compose.dev.yml up postgres -d; check DATABASE_URL |
| gRPC call hangs | Confirm GRPC_TLS_ENABLED=false in .env.local; or supply -cacert/-cert/-key to grpcurl |
consent.audit write rejected with permission denied | The append-only rules block UPDATE/DELETE. Inserts work — that's the point. Check you're INSERTing not UPDATEing. |
| STOP MO not processed | Confirm consumer is consuming from CONSENT_MO_CONSUMER; nats consumer info CONSENT_EVENTS CONSENT_MO_CONSUMER |
| Mock ATRA returns 404 | Check test/fixtures/atra-dnd/latest.csv is mounted; restart mock-atra container |
| Vault dev mode errors | vault status; if locked, vault operator unseal is unnecessary in dev mode — restart the container |
| Tests fail with "relation does not exist" | pnpm prisma migrate dev |
| Cache changes not reflected | TTL is 300 s; pnpm cache:clear or wait |
consent_residency_violation_total > 0 in dev | Expected if you point to a non-Afghan endpoint; this is a dev warning, not an error |
15. Useful URLs in dev
| URL | Purpose |
|---|---|
http://localhost:3071/v1/consent/openapi.json | OpenAPI spec |
http://localhost:3071/metrics | Prometheus metrics |
http://localhost:8080/dnd/latest.csv | Mock ATRA DND feed |
http://localhost:8200/ui | Vault UI (token from env) |
nats://localhost:4222 | NATS server (use nats CLI) |