Skip to main content

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

ToolVersionPurpose
Node.js20 LTS+Runtime
pnpm9+Package manager
Docker Desktop24+Postgres, Redis, NATS, mock-ATRA-DND containers
grpcurllatestgRPC testing
psql16+Direct DB access for verifying audit chain
nats CLI0.1+NATS stream/subject inspection
mkcertlatestLocal mTLS dev certs (optional; default dev runs without TLS)
jq1.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:

ServiceHost:PortNotes
PostgreSQL 16localhost:5432user ghasi, password ghasi_dev, db ghasi_dev; schema consent
Redis 7 (single-node)localhost:6379DB 4 used by service
NATS 2.10 (JetStream enabled)localhost:4222Streams pre-created
Mock ATRA DND serverlocalhost:2222 (SFTP) + localhost:8080 (HTTPS)Serves /dnd/latest.csv from a mounted fixture
Vault dev modelocalhost:8200Token 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_acme with three opt-in records and one opt-out across scopes
  • Sample DND entries (~50 fake +9379999XXXX MSISDNs 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/live
  • http://localhost:3071/health/ready
  • http://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

IssueFix
Could not connect to Postgresdocker compose -f docker-compose.dev.yml up postgres -d; check DATABASE_URL
gRPC call hangsConfirm GRPC_TLS_ENABLED=false in .env.local; or supply -cacert/-cert/-key to grpcurl
consent.audit write rejected with permission deniedThe append-only rules block UPDATE/DELETE. Inserts work — that's the point. Check you're INSERTing not UPDATEing.
STOP MO not processedConfirm consumer is consuming from CONSENT_MO_CONSUMER; nats consumer info CONSENT_EVENTS CONSENT_MO_CONSUMER
Mock ATRA returns 404Check test/fixtures/atra-dnd/latest.csv is mounted; restart mock-atra container
Vault dev mode errorsvault 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 reflectedTTL is 300 s; pnpm cache:clear or wait
consent_residency_violation_total > 0 in devExpected if you point to a non-Afghan endpoint; this is a dev warning, not an error

15. Useful URLs in dev

URLPurpose
http://localhost:3071/v1/consent/openapi.jsonOpenAPI spec
http://localhost:3071/metricsPrometheus metrics
http://localhost:8080/dnd/latest.csvMock ATRA DND feed
http://localhost:8200/uiVault UI (token from env)
nats://localhost:4222NATS server (use nats CLI)