# Testing setup — boot the stack, get a code, log in

Everything you need to run Balinyaar locally and walk any flow in [index.md](index.md). **Executed, not
transcribed:** every command, URL, status code and console line below was run against this repo on the date
in the stamp. Where a predecessor doc says something different, this file says so and says which is right.

> Last verified: 2026-08-02 against commit `c841bde`, on Windows 11 / .NET SDK 10.0.300-preview / Node 24.11.1.

**Predecessors, and their standing:** [RUNBOOK.md](../../dev/post-phase/refinement/RUNBOOK.md) and
[manual-testing-plan.md](../../dev/post-phase/manual-testing-plan.md) are **superseded by this file**. Both
contain instructions that no longer work — see [What the old docs get wrong](#what-the-old-docs-get-wrong).

---

## The five-minute path

Three terminals. The third one is not optional — see [Getting an OTP](#getting-an-otp).

```bash
# 1 — API.  Plain HTTP on :5002.  Needs the SMS provider overridden, or login 500s.
cd server
Seams__Sms__Provider=mock dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj
#   PowerShell:  $env:Seams__Sms__Provider="mock"; dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj

# 2 — client
cd client && npm install && npm run dev        # http://localhost:3000/fa

# 3 — read the OTP (the console does NOT print the code)
curl http://localhost:5002/api/v1/dev/last_otp/09120000010
```

Then open **`http://localhost:3000/fa/login`**, enter `09120000010`, and paste the code from terminal 3.

No database setup step. The committed dev config already points at a seeded remote SQL Server.

---

## Prerequisites

| | Verified working | Notes |
| --- | --- | --- |
| .NET SDK | `10.0.300-preview.0.26177.108` | `NETSDK1057` (preview SDK) is an expected warning |
| Node | `24.11.1` / npm `11.6.2` | README says 18+; 24 is fine |
| SQL Server | none locally | the dev config uses a **remote** instance — nothing to install |
| Docker | **not required** | only for the optional local-DB path, which is *unverified* here |

`dotnet build Baya.sln` completes with **0 errors, 95 warnings** on a clean clone. The warnings are
pre-existing (`NU1903` vulnerability advisories on `Microsoft.OpenApi` / `SQLitePCLRaw`, `NU1510`,
`NETSDK1057`). They are not yours; don't "fix" them.

---

## Configuration — where it lives

**`dotnet user-secrets` is not used and is not read.** The `<UserSecretsId>` was removed from
`Baya.Web.Api.csproj` in `5885280`. Any instruction to `dotnet user-secrets set …` is dead — the command
will error with "could not find UserSecretsId", and even a leftover `secrets.json` on your machine is inert.

| What | Where | Read when |
| --- | --- | --- |
| Server config + keys | [`server/src/API/Baya.Web.Api/appsettings.Development.json`](../../server/src/API/Baya.Web.Api/appsettings.Development.json) | process start |
| Server base file | `appsettings.json` — placeholders only, guarded by `StartupSecretsGuard` | process start |
| Client dev config | [`client/.env.development`](../../client/.env.development) | `next dev` |
| Client prod config | `client/.env.production` | **`next build`** — inlined into the bundle |
| Deployment overrides | [`docker-compose.yml`](../../docker-compose.yml) (`Seams__…` env vars) | container start |

Any server key can be overridden by an environment variable using `__` for `:` —
`Seams__Sms__Provider`, `ConnectionStrings__SqlServer`. That is how the five-minute path avoids editing a
committed file. Full matrix: [docs/integration/config-matrix.md](../integration/config-matrix.md).

### The four crypto values, and why they are load-bearing

```jsonc
"IdentitySettings": { "SecretKey": …, "Encryptkey": … }   // signs + encrypts the JWE access token
"Seams": { "FieldEncryption": { "Key": …, "HashKey": … } } // decrypts PII; derives users.PhoneHash
```

`Seams:FieldEncryption:Key` and `:HashKey` **must match whatever the target database was encrypted under.**
Every phone, address, IBAN and clinical note in `Baya` was written with the committed values. Boot against
that database with different ones and you get *silent* failure first — every phone lookup misses, so every
login says "no such account" — then `Padding is invalid and cannot be removed` on the first PII read. The
appsettings file carries a `"//"` comment saying exactly this. Do not rotate them.

`IdentitySettings` may be changed freely; it only invalidates tokens already issued.

> **The repo contains live credentials on purpose** — a pre-launch trade for a demo deployment. Rotating them
> is a "Going to Production" step in [DEPLOY.md](../../DEPLOY.md), not a local setup step.

---

## Which database

**The committed dev config points at a remote SQL Server: `87.107.152.16,1433` → `Baya` (+ `Baya_Logs`).**
It is the same instance the deployed demo uses, it is **already migrated and already seeded**, and it is
shared — your writes are visible to everyone else pointed at it.

Verified reachable (`Test-NetConnection … -Port 1433` → `TcpTestSucceeded: True`) and the API's
`sql-app` health check reports `Healthy`.

### The local alternative — **UNVERIFIED**

[`server/docker-compose.yml`](../../server/docker-compose.yml) provisions SQL Server 2022 on
`localhost:1433` with the dev-only SA password `Balinyaar_Dev1433`. Point the API at it with:

```bash
export ConnectionStrings__SqlServer="Server=localhost,1433;Database=Baya;User Id=sa;Password=Balinyaar_Dev1433;TrustServerCertificate=True;Encrypt=False;"
```

The API self-migrates and self-seeds on boot, so an empty instance is enough. **This path was not executed
for this stamp** — Docker is not installed on the verification machine. Treat the remote path as the tested
one and this as documented-but-unproven.

Two things that are true either way: the local DB starts empty, so you get a *freshly dated* demo world
(see [staleness](#the-seeded-world-and-how-stale-it-is)); and it is encrypted under whatever
`Seams:FieldEncryption` values you boot with, so it is not interchangeable with the remote one.

---

## Boot

```bash
cd server
Seams__Sms__Provider=mock dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj
```

**`http://localhost:5002` — plain HTTP.** `launchSettings.json` binds no TLS. There is no
`https://localhost:5002`, and `dotnet dev-certs https --trust` has nothing to trust; skip it. Swagger is at
`http://localhost:5002/swagger`, the OpenAPI doc at `/swagger/v1/swagger.json` (178 paths).

A healthy boot logs, in order:

```
[INF] Demo world already seeded — no-op. Search index re-derived: 3 nurses, 27 rows.
[INF] Demo lifecycle already seeded — no-op. Search index re-derived: 3 nurses, 27 rows.
[INF] Recurring job scheduler starting with 7 job(s): booking_request_expiry, notification_retention,
      verification_expiry_scan, no_show_sweep, weekly_payout_generation, moadian_reconciliation,
      audit_log_retention
[INF] مودیان reconciliation scanned 8 invoice(s); 0 reached registered
```

Noise you can ignore: ~11 `WRN Entity 'X' has a global query filter …` lines, and two
`WRN HTTP/2 is not enabled for 127.0.0.1:5002` lines (expected — HTTP/2 needs TLS; HTTP/1.1 is used).

### Health

| Endpoint | Expected | Actual |
| --- | --- | --- |
| `GET /healthz/live` | `200 Healthy` | ✅ `200` |
| `GET /healthz/ready` | `200 Healthy` | ❌ **`503 Unhealthy` on Windows** |

`/healthz/ready` fails on the `object-storage` probe, not the database:

```
IOException: The process cannot access the file
  'C:\Users\<you>\AppData\Local\Temp\balinyaar-object-storage\healthz\object-storage-probe'
  because it is being used by another process.
  at LocalDiskObjectStorage.DeleteAsync … at ObjectStorageWriteHealthCheck…
```

`sql-app` reports `Healthy` in the same response. It is a real code defect, not configuration:
`ObjectStorageWriteHealthCheck.cs:29` opens the probe blob with `await using var` and line 33 deletes it
**while the `FileStream` handle is still open**. POSIX `unlink` permits that, so the Linux container passes;
Windows `File.Delete` throws. **Do not read a red `/healthz/ready` as "the app is broken"** — check the
`entries` object. Phase 4 backlog material.

`Seams:ObjectStorage:RootPath` is `""` in dev, which resolves to `%TEMP%\balinyaar-object-storage`
(`LocalDiskObjectStorage.cs:18-20`). Deployed, `docker-compose.yml` sets `/app/data/object-storage`.

### Client

```bash
cd client && npm install && npm run dev
```

`✓ Ready in 6.8s` on `http://localhost:3000`. **Routes are locale-prefixed**: `/fa` and `/en` return `200`,
bare `/` returns **`404` under `next dev`**. That is a Turbopack dev-server quirk on the root path, not a
routing bug — **confirmed by building for production:**

```bash
cd client && npm run build && PORT=3001 npm run start
```

`next build` exits 0. Against `:3001`, `/` → `307 → /fa`; guest `/fa` returns `200` and renders the welcome
landing itself (byte-for-byte the same page as `/fa/welcome`, differing only in the URL — which is the
middleware **rewrite**, not a redirect); `/fa/search` → `307 → /fa/login?next=%2Fsearch`. **Never judge
root-path or guest routing from `next dev`.**

`client/.env.development` already carries `NEXT_PUBLIC_API_URL = http://localhost:5002`, correctly on HTTP.
**Do not copy `.env.sample`** — it still says `https://localhost:5002` and will break every call with an
opaque network error.

---

## Getting an OTP

This is where a fresh clone fails, so read the whole section.

### The problem

`appsettings.Development.json` ships with:

```jsonc
"Seams": { "Sms": { "Provider": "telegram", "Telegram": { "BaseUrl": "http://127.0.0.1:5010", … } } }
```

With nothing listening on `:5010`, `POST /api/v1/auth/request_otp` returns **`500 Server Error`**:

```
[WRN] Telegram OTP relay delivery failed for phone ending 0010 — http 502
[ERR] Telegram OTP relay delivery failed (http 502).
      at TelegramSmsSender.PostAsync(…) TelegramSmsSender.cs:line 73
```

This is deliberate — the relay fails **loudly** rather than pretending an undelivered code was sent. In the
browser it surfaces as an error toast on the login screen and you never reach the code step.

The code is still generated and persisted before the send, so `dev/last_otp` works even while `request_otp`
500s. That is a usable-but-ugly fallback, not a fix.

### The fix — one of three

| Option | Command | Result |
| --- | --- | --- |
| **A (recommended)** | boot with `Seams__Sms__Provider=mock` | `request_otp` → `200`, no relay needed |
| B | set `"Provider": "mock"` in `appsettings.Development.json` | same, but shows up in `git status` |
| C | run the Telegram relay | code arrives in Telegram — see below |

Option A verified:

```json
POST /api/v1/auth/request_otp  {"phone":"09120000002"}   →  HTTP 200
{"data":{"otpSent":true,"resendAvailableInSeconds":120,"codeLength":6,"expiresInSeconds":60},"isSuccess":true}
```

### Reading the code

**`GET /api/v1/dev/last_otp/{phone}` is the only way to read the code.** It is anonymous and
Development-only (`404` in any other environment).

```bash
curl http://localhost:5002/api/v1/dev/last_otp/09120000010
# {"data":{"phone":"09120000010","code":"724740"},"isSuccess":true,"statusCode":200,…}
```

The capture bridge is registered only for a capture-safe sender — `mock`, `telegram`, or unset
(`Program.cs:94-100`). Select the real `kavenegar` gateway and the endpoint stops returning codes, by design.

> **The server console does NOT print the OTP.** With `Provider=mock` the line is
> `[INF] MOCK SMS — OTP issued to phone ending in 0002` — the phone suffix only, **no code**. RUNBOOK.md and
> manual-testing-plan.md both promise `MOCK SMS — OTP code 123456 for phone ending in 0001`. That string does
> not exist in the codebase. Verified: zero 6-digit sequences appear anywhere in a full boot+login log.

### Option C — the Telegram relay

```bash
cd telegram-otp-bot && npm start      # zero dependencies, no npm install
```

Two things must line up, and on the verification machine **neither did**:

1. `telegram-otp-bot/.env` `API_KEY` **must equal** `Seams:Sms:Telegram:ApiKey` in
   `appsettings.Development.json`. On the verification machine it did not: the local `.env` still holds
   `ab8984974bc1fe5ce514d0fd74f71c8738b3aed92a7e4d86` — byte-for-byte the key published in `.env.example`.
   `TelegramSmsSender.cs:35` hardcodes that exact string as a rejected placeholder, so the relay would
   reject the API's calls even with everything running. Copy the appsettings value into `.env`.
2. `api.telegram.org` is filtered in Iran, so `TELEGRAM_PROXY_URL` must point at a working proxy.

It **broadcasts** every code to every configured chat id, so it is a shared inbox for a trusted group, not
an SMS gateway.

### Limits that will bite you

| Limit | Value | Source |
| --- | --- | --- |
| OTP length | 6 digits | `IdentityDefaults.OtpCodeLength` |
| OTP validity | **60 s** | `IdentityDefaults.OtpExpirySeconds` |
| Resend window | **120 s** per phone | `platform_configs.auth_otp_resend_seconds` |
| Wrong attempts | **5**, then `code: "otp_locked"` | `Lockout.MaxFailedAccessAttempts` |
| **Endpoint rate limit** | **5 requests / 60 s per IP** | `otp` policy, `RateLimitingServiceExtension.cs` |

**The `otp` rate-limit policy covers `request_otp` *and* `verify_otp`.** Five combined calls inside one
minute and both endpoints return `429` — `verify_otp` with an **empty body**, which looks like a crash. A
scripted login is 2 calls, so **you get two logins per minute, total.** See
[Scripting logins](#scripting-logins).

---

## Demo accounts

Read out of [`DemoWorldDefinitions.cs`](../../server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoWorldDefinitions.cs)
and [`DemoLifecycleDefinitions.cs`](../../server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoLifecycleDefinitions.cs)
— the seeders are the authority, not any doc — and each one confirmed by a live `GET /api/v1/me`.

| Phone | `/me` roles | Who | Demonstrates |
| --- | --- | --- | --- |
| `09120000001` | `nurse` | زهرا عزیزی (f) | **verified** · 3 priced variants · whole-city Tehran + districts 1, 3 · 2 credentials · bank account |
| `09120000002` | `nurse` | علی کریمی (m) | **verified** · 2 variants · districts 3, 6, 12 · sponsored by the partner center |
| `09120000003` | `nurse` | مریم احمدی (f) | **unverified** (`status: in_review`, `isBookable: false`, blocking `moh_competency_license` + `criminal_record`) — must never appear in search |
| `09120000010` | `customer` | سارا محمدی (f) | 2 patients · 1 Tehran address · owns 6 of the 8 seeded bookings |
| `09120000011` | `customer` | رضا حسینی (m) | 1 infant patient · 1 address · owns the BNPL + payout-eligible bookings |
| `09120000020` | `super_admin` | نگار مدیری (f) | the full backoffice — **but see the RBAC gap below** |
| `09120000021` | `finance` | کامران مالی (m) | scoped backoffice; the client's `useAdminCapabilities()` shows only money consoles |
| `09120000030` | `customer` | بهنام رستگار (m) | owns مرکز پرستاری آرامش (merchant-of-record). **No partner role** — `/me` returns `["customer"]`; navigate to `/fa/partner` manually |

Admin sub-roles are **server-granted** — `POST /me/select_role` only accepts `customer` and `nurse`
(`RoleNames.SelfAssignable`).

### ⚠ The seeded admins cannot reach any admin endpoint

**Every `[Authorize(DynamicPermission)]` route returns `403` for `09120000020` and `09120000021`.** Verified
against all 16 admin GET operations in the live swagger — `platform_config`, `audit`, `holidays`,
`admin_verifications`, `admin_payouts`, `admin_refunds`, `admin_evv`, `admin_bnpl`, `admin/tickets`,
`admin/reviews`, `admin_cancellation_policies`, `admin/partner-centers`. All `403`. The same token gets
`200` from `/me`, and nurse/customer endpoints work normally, so this is not a token problem.

Root cause, in `DynamicPermissionService.CanAccess`:

```csharp
if (user.IsInRole("admin")) return true;
var key = $"{area}:{controller}:";
return user.FindAll(ConstantPolicies.DynamicPermission).Any(c => c.Value.Equals(key, …));
```

It grants on the **literal** role `"admin"` or on a per-controller `DynamicPermission` claim. The demo
admins hold `super_admin` / `finance` (`RoleNames.cs:13-17`), and refinement-phase-5 stopped auto-seeding the
`admin`/`qw123321` account — so **no seeded account satisfies either branch.**

Consequence for testing: **the entire admin backoffice is untestable end-to-end on the real path.** The
client hides this because `USE_ADMIN_MOCK = true`, so the console renders fully on in-browser fake data.
See [admin-backoffice.md](admin-backoffice.md). Workaround: add both `"Seed": { "AdminUsername",
"AdminPassword" }` keys to `appsettings.Development.json` before boot to mint a literal-`admin` account, and
call the API directly (the web login is phone-OTP only).

---

## The seeded world, and how stale it is

`DemoWorldSeeder` builds the personas; `DemoLifecycleSeeder` layers 8 bookings plus the money, reviews,
tickets, notifications and records behind them. Both are **idempotent and Development-only**, and log
`already seeded — no-op` on every subsequent boot.

Live counts on the shared remote DB at this stamp:

| Thing | Count | Read with |
| --- | --- | --- |
| Bookings | 8 (6 for `…010`, 2 for `…011`) | `GET /bookings/list?role=customer` |
| Booking requests | 15 (11 nurse 1, 4 nurse 2) | `GET /booking_requests/list` |
| Search rows | 27 across 3 nurses; `category=1&city=101` → **9**, `category=3` → **0** | `GET /search/nurses` (anonymous) |
| Patients / addresses | 2 / 1 for `…010` | `GET /patients/list`, `GET /customer_addresses/list` |
| Notifications | 8, 6 unread | `GET /notifications/get_notifications` |
| Tickets | 9 | `GET /tickets` |
| Reviews | nurse 1 → `averageRating: 5`, `publishedCount: 1` | `GET /nurses/1/reviews` (anonymous) |
| Refunds | booking 7 → `succeeded`, `psp_card`, `2000000` | `GET /refunds/by_booking/7` |
| Payouts | nurse 1: paid `3187500`, clawback outstanding `212500`; nurse 2: eligible `2720000` | `GET /nurse_payouts/earnings_balance` |
| Care records | 2 for patient 1 | `GET /patients/1/care_records` |

### ⚠ It has aged out — and this is not cosmetic

`DemoLifecycleDefinitions` expresses every timestamp as an **offset from seed time**, and the seeder anchors
to the *first* run's epoch, never to wall-clock now. The seeder's own comment is explicit:

> *"A wipe + reseed is what moves the demo world forward in time."*

**This world was seeded on 2026-07-26. It is 7 days old.** What that has already broken:

| Scenario as designed | State today | Effect |
| --- | --- | --- |
| B1 `upcoming` — scheduled +3 d | scheduled 2026-07-29, **in the past** | nothing is actually "upcoming" |
| B3 `completed_in_window` — dispute window open | window closed 2026-07-28 | the dispute/review-moderation path can't be walked |
| B2 `in_progress` — session 3 checked in *today* | scheduled 2026-07-26 | mid-engagement is 7 days stale |
| One `pending` request awaiting the nurse | `expired_no_response` (the `booking_request_expiry` job ran) | — |
| Two `accepted` requests awaiting payment | `payment_deadline_expired` | — |

**There is no longer a single `pending` or `accepted` booking request in the world.** Confirmed across both
nurses: statuses are only `converted`, `rejected_by_nurse`, `cancelled_by_customer`, `expired_no_response`,
`payment_deadline_expired`.

So **[booking-request](booking-request.md) and [checkout-and-payment](checkout-and-payment.md) have nothing
seeded to act on.** Create a fresh request yourself (customer → search → C4/C5) — that path works and is the
intended way to exercise both — or reseed.

---

## The scheduler is running while you test

`RecurringJobSchedulerHostedService` starts with the API and gives each of the 7 jobs its own loop. **Every
job fires once immediately at boot**, then on its own cadence, re-reading its interval from
`platform_configs` each tick. It does **not** run under the `Testing` environment.

Three of them will change state under you:

| Job | Cadence | What you will notice |
| --- | --- | --- |
| `booking_request_expiry` | **every 60 s** (hardcoded) | A request you leave un-actioned flips to `expired_no_response`, then `payment_deadline_expired`. **This is what aged the seeded world out**, and it will do the same to yours — accept and pay promptly |
| `no_show_sweep` | hourly | A session whose start time passed with no EVV check-in gets flagged missed |
| `weekly_payout_generation` | at boot, then 7 d | Creates a **draft** payout batch you did not ask for. Generation only — it never moves money; `process` stays an explicit admin action |

The other four (`notification_retention`, `verification_expiry_scan`, `moadian_reconciliation`,
`audit_log_retention`) are no-ops on a freshly seeded database. `moadian_reconciliation` logs
`scanned 8 invoice(s); 0 reached registered` because `IMoadianClient` is on its mock.

---

## Reset

There is **no in-app reseed**. Both seeders guard on natural keys (a persona's phone, a ticket's reference
code, the partner permit number, a request's customer/nurse/variant/date tuple), so re-running never
duplicates and never refreshes. Moving the world forward in time means dropping the data first.

| Target | Procedure | Verified |
| --- | --- | --- |
| Local Docker DB | `cd server && docker compose down -v && docker compose up -d`, then `dotnet run` | ✗ no Docker here |
| Remote shared DB | `DROP DATABASE Baya` (and `Baya_Logs`), then boot — `MigrateAsync` + both seeders run | ✗ **not attempted** |

**Do not drop the shared remote database casually.** It backs the `balinyaar.ir` demo deployment and is used
by other people. If you need a clean, freshly-dated world, use a local instance.

Schema-only, without booting the app:

```bash
cd server
dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence \
                          --startup-project src/API/Baya.Web.Api
```

Reference and demo seeds still run on the next app boot.

---

## Scripting logins

Two API calls per login, both on the `otp` policy, 5 per 60 s per IP. **Space logins ≥ 40 s apart** or you
will 429. This script minted tokens for all 8 demo accounts:

```bash
login() {
  P="$1"
  curl -s -X POST http://localhost:5002/api/v1/auth/request_otp \
       -H "Content-Type: application/json" -d "{\"phone\":\"$P\"}" > /dev/null
  C=$(curl -s "http://localhost:5002/api/v1/dev/last_otp/$P" \
      | node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).data.code")
  curl -s -X POST http://localhost:5002/api/v1/auth/verify_otp \
       -H "Content-Type: application/json" \
       -d "{\"phone\":\"$P\",\"code\":\"$C\",\"deviceInfo\":\"cli\"}" \
    | node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).data.accessToken"
}
for P in 09120000001 09120000002 09120000003 09120000010 \
         09120000011 09120000020 09120000021 09120000030; do
  echo "T_$P=$(login $P)"; sleep 40
done
```

Then `curl -H "Authorization: Bearer $T_09120000010" http://localhost:5002/api/v1/me`.

Then `curl -H "Authorization: Bearer $T_09120000010" http://localhost:5002/api/v1/me`.

Four things to get right:

- **The field is `phone`, not `phoneNumber`.** `phoneNumber` returns `400` with
  `"The Phone field is required."`
- **`Authorization: Bearer <token>`, never a cookie.** The client stores the JWE in a cookie it reads
  itself and sends as a header; the server's CORS policy does not allow credentials.
- Access tokens last **60 minutes** (`IdentitySettings.ExpirationMinutes`).
- **One live token per account. Logging in again kills the previous one.** Verified: after re-running the
  script, the earlier tokens for the re-minted phones all returned `401` while untouched accounts kept
  working. `AppUserManagerImplementation.VerifyUserCode` calls `UpdateSecurityStampAsync` on every
  successful verification, and the bearer handler's `OnTokenValidated` runs
  `ValidateSecurityStampAsync` — so a new login invalidates every token previously issued to that user.
  **Two people cannot share a demo account**, and a second browser profile will silently log the first out.

---

## What the old docs get wrong

Each row was checked against running code. **This file is right; the predecessor is stale.**

| # | Claim | Where | Reality |
| --- | --- | --- | --- |
| 1 | Set crypto keys with `dotnet user-secrets` | manual-testing-plan | Removed in `5885280`; the store is not read. Edit `appsettings.Development.json` or use `__` env vars |
| 2 | API on `https://localhost:5002` | RUNBOOK ×6, manual-testing-plan | **`http://`**. No TLS binding exists |
| 3 | Run `dotnet dev-certs https --trust` | RUNBOOK setup step 1 | Nothing to trust. Skip |
| 4 | `client/.env.development` has `https://localhost:5002` | RUNBOOK | The file says `http://`. But **`.env.sample` still says `https://`** — a live trap |
| 5 | `Seams:Sms:Provider` "committed default is `mock`" | RUNBOOK, Telegram step 3 | It is **`telegram`**, so `request_otp` **500s** on a fresh clone |
| 6 | Console prints `MOCK SMS — OTP code 123456 …` | RUNBOOK, manual-testing-plan | It prints `MOCK SMS — OTP issued to phone ending in 0002` — **no code**. Use `dev/last_otp` |
| 7 | "Only `auth` is real; 21 of 22 domains are mocked" | RUNBOOK "Good to know" | Stale by refinement-phase-4. **15 of 22 are real**, 7 mocked — see [index.md](index.md) |
| 8 | `docker compose down -v` resets the world | RUNBOOK | Only for the local-DB path. The committed config uses a **remote** DB where it does nothing |
| 9 | Demo world shows "upcoming" / open dispute windows | manual-testing-plan | Seeded 2026-07-26 and **aged out**; no `pending`/`accepted` requests remain |
| 10 | `09120000020` gives "full backoffice" | RUNBOOK | `403` on **every** admin endpoint — see [the RBAC gap](#-the-seeded-admins-cannot-reach-any-admin-endpoint) |

Contradictions C-1, C-3, C-4 and C-5 from
[_plan/open-contradictions.md](../_plan/open-contradictions.md) are settled by rows 1–4 and 8.

---

## Troubleshooting

| Symptom | Cause | Fix |
| --- | --- | --- |
| `request_otp` → `500`, log shows `Telegram OTP relay delivery failed (http 502)` | `Provider=telegram`, relay not running | Boot with `Seams__Sms__Provider=mock` |
| `request_otp` → `429`; `verify_otp` → `429` **with an empty body** | `otp` policy, 5/60 s per IP | Wait 60 s. Space scripted logins ≥ 40 s |
| `400` `"The Phone field is required."` | Sent `phoneNumber` | The field is `phone` |
| `403` on every `/api/v1/admin*` route | `DynamicPermission` doesn't recognise `super_admin` | No clean workaround — see the RBAC gap |
| `/healthz/ready` → `503` | object-storage probe file lock (Windows) | Cosmetic. Check `entries.sql-app` instead |
| `dev/last_otp` → `404 "No OTP has been issued for this phone yet."` | No `request_otp` for that phone yet, or not Development | Call `request_otp` first (a `500` from it still stores the code) |
| Login "succeeds" but the account is unknown / `Padding is invalid` | `Seams:FieldEncryption` doesn't match the DB | Restore the committed values |
| `Refusing to start: required secret configuration is missing…` | `ConnectionStrings` blank or still `SET_VIA_USER_SECRETS_OR_ENV` | Use `appsettings.Development.json` or `ConnectionStrings__SqlServer` |
| Every `curl` returns `502` | A machine-wide `HTTP_PROXY`/`HTTPS_PROXY` intercepting localhost | `curl --noproxy '*'`, or clear `NO_PROXY` |
| Client `404` on `http://localhost:3000/` | Locale prefixes; `next dev` root-path quirk | Use `/fa`. Verify root behaviour with a prod build |
| Browser: blocked by CORS | Origin not in `Cors:AllowedOrigins` | `http://localhost:3000` is listed by default |
| `dotnet user-secrets`: "could not find UserSecretsId" | Expected | Edit appsettings instead |
| A `500` returns a raw stack trace instead of the `ApiResult` envelope | `ExceptionHandler` returns `false` in Development on purpose | Expected locally. Don't document the error shape from a dev response |
| A booking request flipped status while you were reading it | `booking_request_expiry` runs **every 60 s** | Expected. Act on requests promptly |
| A token that worked a minute ago now `401`s | Someone (or another tab) logged into the same demo account — the security stamp rotated | Use one account per tester, or re-login |

---

## Where to go next

[index.md](index.md) — the flow atlas: what is built, what is mocked, and how to walk each journey.
