# CareMeet — assembly runbook

Six services. This is the order to bring them up and what breaks first when you don't.

## Verified working

The API layer has been run against a real MariaDB instance and exercised end to end. `tools/smoke_test.sh` passes 16 checks covering login, lockout, tenant onboarding, duplicate handling, HMAC signature rejection, replay rejection, consultation creation, RS256 token verification, plan limits and cross-tenant isolation.

Run it after any change to auth, tenancy or token issuance — it catches the class of break unit tests miss: the seams between components.

```bash
./tools/dev_up.sh          # terminal 1 — keys, config, admin, API on :8080
./tools/smoke_test.sh      # terminal 2
```

## What assembly already caught

**`mb_strlen` without ext-mbstring.** Validation crashed with a 500 on tenant onboarding. The extension is not guaranteed on a hospital's own server and this codebase deliberately has no dependencies. Replaced with a UTF-8 aware fallback — which also matters correctness-wise: a patient name in Devanagari must not be rejected as "too long" because its bytes were counted instead of its characters.

That bug was invisible to linting and would have surfaced during a client's first onboarding call. It is exactly why assembly comes before more features.

**A test-harness lesson worth keeping.** The SFU test first reported "ffmpeg rejected the SDP" — a false alarm. ffmpeg only prints the streams it found once probing gives up waiting for RTP, which takes several seconds; the test was killing it at six. The SDP was always valid. If you tighten timeouts in these tests, expect to reintroduce this.

Also: `$RANDOM` is empty under `dash`. A test script using it silently reuses the same value every run, which surfaces as spurious duplicate-key errors that look like product bugs.

**What still has never been run: a real call between two browsers.** Media negotiation, TURN relay through coturn, actual RTP flowing into a recording, and reconnection on network loss all need real network interfaces and two machines. Everything below is unverified and is the first task for whoever owns this next.

## Startup order

| # | Service | Depends on | Port | Verify |
|---|---|---|---|---|
| 1 | MySQL | — | 3306 | `SELECT COUNT(*) FROM plans` returns 4 |
| 2 | Redis | — | 6379 | `redis-cli ping` |
| 3 | coturn | — | 3478, 5349, 49152-65535/udp | `turnutils_uclient` |
| 4 | SFU | MySQL, Redis | 5000 (private), 40000-49999 | `GET /healthz` |
| 5 | Signaling | MySQL, Redis, SFU | 4000, 4001 (private) | `GET /healthz` |
| 6 | API | MySQL, signaling | 443 | `GET /v1/health` |
| 7 | Notification worker | MySQL | — | log line on start |
| 8 | Transcode workers | MySQL, Redis, storage | — | log line on start |

Signaling and the SFU both register in `media_nodes`. **A service that starts but does not heartbeat will silently never receive rooms** — check `last_heartbeat_at` before assuming a node is in service.

## The five failures that will happen first

**1. `ANNOUNCED_IP` not set to the public address.** On any NAT'd cloud VM, mediasoup advertises the private IP, ICE never completes, and every call fails with no useful error. The single most common mediasoup deployment mistake. Check this before anything else when calls connect but carry no media.

**2. UDP not open on 40000-49999.** Calls fall back to TCP and appear to work in testing, then perform badly under load. Verify you are getting UDP candidates, not just a working call.

**3. JWT key mismatch.** The API signs with the private key; every signaling node verifies with the public half. Copy the wrong file, or regenerate on one host only, and every join fails with `token_invalid`. Symptom looks like an auth bug and is a deployment bug.

**4. `media_nodes` rows pointing at hostnames that do not resolve** from the signaling host. Room placement reads this table; it does not verify reachability. Seed it with names signaling can actually resolve.

**5. Clock skew between the API and an integrating product.** HMAC signatures carry a timestamp and are rejected beyond 300 seconds. A hospital server with a drifting clock produces intermittent `invalid_signature` errors that look random. Run NTP.

## First real consultation — the sequence to test

1. Onboard a tenant, activate it, add a doctor with an NMC number.
2. Create a consultation through the signed API. Confirm two join tokens come back.
3. Open the doctor link on one machine, the patient link on another **on a different network** — same-LAN testing hides every TURN problem you have.
4. Watch the browser console for the ICE candidate type. `host` or `srflx` means direct; `relay` means TURN is carrying it. Both work; only one costs bandwidth.
5. Kill wifi on the patient side mid-call. It should reconnect within a few seconds, not drop.
6. Enable recording. Confirm it refuses until both participants have consented, then produces a file.
7. Check `session_quality_summary` has a row with a MOS score.

If all seven pass on two different networks, the platform works. Everything after that is scale, not correctness.

## Production notes

- Never hard-restart a busy SFU. `SIGTERM` marks it draining and waits for rooms to empty; `TimeoutStopSec` must exceed that window.
- Signaling behind a load balancer needs WebSocket upgrade, sticky sessions, and a drain timeout above 30 seconds.
- Transcode workers belong on separate hosts from the SFU. Transcoding must never compete with live media for CPU.
- Back up the JWT private key and the notification master key somewhere other than the server. Losing the first invalidates nothing but blocks new tokens; losing the second invalidates every stored provider secret for every hospital.
