# Config matrix

Every configuration key on both sides of the seam, plus docker and the OTP relay, with where it is set and
who reads it.

> Last verified: 2026-08-02 against commit `51e86a1`. Built by **mechanically enumerating** every leaf key
> in both `appsettings*.json`, every `environment:` entry in `docker-compose.yml`, every assignment in the
> three `client/.env*` files and `telegram-otp-bot/.env.example`, and every `process.env.*` read under
> `client/src/` — then diffing the sets. The gaps that diff found are in
> [§ What the diff found](#what-the-diff-found).

---

## Configuration lives in files. `dotnet user-secrets` is not used.

The `<UserSecretsId>` was **removed** from `Baya.Web.Api.csproj`, so that store is **not even read**. A
stale `secrets.json` on a developer machine is inert and can be deleted. Any instruction anywhere in this
repo to set a Balinyaar value with `dotnet user-secrets` is stale — including the placeholder string
`SET_VIA_USER_SECRETS_OR_ENV`, whose *name* is a historical artifact (see
[§ The placeholder's name](#the-placeholders-name)).

**Every value is a file in git**, which means **the repository contains live credentials** — a deliberate
pre-launch trade for a demo deployment. Before onboarding real users they must be rotated and the secret
half moved out of git: [DEPLOY.md § Going to Production](../../DEPLOY.md) and, when Phase 5 writes it,
`docs/roadmap/pre-launch.md`.

> ⚠️ **`Seams:FieldEncryption:Key` and `:HashKey` are load-bearing and must never change.** Every encrypted
> column in the database — phones, addresses, IBANs, clinical notes — was written with those exact values,
> and `users.PhoneHash`, which **every login** looks up, is derived from `HashKey`. Rotating either makes
> the existing data unreadable and locks every account out. The JWE keys
> (`IdentitySettings:SecretKey`/`Encryptkey`) are safe to rotate — that only signs everyone out.

### There is no `appsettings.Production.json`

Only `appsettings.json` (placeholders) and `appsettings.Development.json` (real values) exist. The
deployment runs `ASPNETCORE_ENVIRONMENT=Development`, so **`appsettings.Development.json` *is* the
production config.** Creating an `appsettings.Production.json` today would change nothing until the
environment name changes too.

---

## Server — `appsettings.json` / `appsettings.Development.json`

`appsettings.json` holds a rejected placeholder for every secret; `appsettings.Development.json` holds the
real value. Environment-variable overrides use the double-underscore form
(`Seams__Sms__Telegram__BaseUrl`).

| Key | Set in | Read by | Required | Default / committed value |
| --- | --- | --- | --- | --- |
| `ConnectionStrings:SqlServer` | both | `AddPersistenceServices`, `StartupSecretsGuard`, readiness probe | **yes, always** | placeholder / `87.107.152.16,1433;Database=Baya` |
| `ConnectionStrings:logDb` | both | Serilog sink, `StartupSecretsGuard`, readiness probe (**deployed only**) | **yes, always** | placeholder / `…;Database=Baya_Logs` |
| `IdentitySettings:SecretKey` | both | JWE signing | yes **when deployed** | placeholder / `dev-only-…-not-for-production` |
| `IdentitySettings:Encryptkey` | both | JWE AES-128 encryption | yes **when deployed** | placeholder / `dev-only-16bytes` |
| `IdentitySettings:Issuer` | both | token validation | — | `Balinyaar` |
| `IdentitySettings:Audience` | both | token validation | — | `BalinyaarClient` |
| `IdentitySettings:NotBeforeMinutes` | both | token validation | — | `0` |
| `IdentitySettings:ExpirationMinutes` | both | access-token lifetime | — | `60` — **but the client's cookie expires at 15 min**, see [api-contract.md](api-contract.md#auth) |
| **`Seams:FieldEncryption:Key`** | both | `IFieldEncryptor` (process-wide singleton) | yes **when deployed** | placeholder / `local-dev-field-encryption-key-not-for-production` · **IMMUTABLE** |
| **`Seams:FieldEncryption:HashKey`** | both | deterministic lookup hashes incl. `users.PhoneHash` | yes **when deployed** | placeholder / `local-dev-field-hash-key-not-for-production` · **IMMUTABLE** |
| `Seams:ObjectStorage:RootPath` | both + compose | local-disk blob root | when provider = `local` | `""` / compose sets `/app/data/object-storage` |
| `Seams:Sms:Provider` | Dev only | SMS seam selector **and** the OTP-capture gate in `Program.cs` | — | `telegram` (default in code: `mock`) |
| `Seams:Sms:Telegram:BaseUrl` | Dev + **compose** | `TelegramSmsSender` | when provider = `telegram` | `http://127.0.0.1:5010` / compose: `http://balinyaar-otp-relay:5010` |
| `Seams:Sms:Telegram:ApiKey` | Dev only | relay `X-Api-Key` — **must equal the relay's `API_KEY`** | when provider = `telegram` | `6a8dfaee…` (rotated away from the published example) |
| `Seams:Sms:Telegram:TimeoutSeconds` | Dev only | HTTP timeout | — | `10` |
| `Seams:Geocoding:ReturnNullCoordinates` | both | mock geocoder | — | `false` |
| `Seams:Geocoding:LowConfidenceMarker` | both | mock geocoder | — | `NO_GEO` |
| `Seams:Geocoding:ResolvedConfidence` | both | mock geocoder | — | `0.9` |
| `Cors:AllowedOrigins` | both | `AddCorsPolicies` | — | `[]` → falls back to `http://localhost:3000` / the three real origins |
| `ForwardedHeaders:KnownProxies` | both | `AddForwardedHeadersConfiguration` | — | `[]` |
| `ForwardedHeaders:KnownNetworks` | both | ditto — **required for the rate limiter to see the real client IP behind Caddy** | deployed | `[]` / the three docker bridge ranges |
| `AllowedHosts` | both | host filtering | — | `*` |
| `Kestrel:EndpointDefaults:Protocols` | both | Kestrel — `Http1AndHttp2` is what lets gRPC share the port | — | `Http1AndHttp2` |

### Keys the code reads that no file sets

Every one has a working default, so nothing is broken — but none is discoverable from the config files.

| Key | Read by | Behaviour when unset | Notes |
| --- | --- | --- | --- |
| `OpenTelemetry:Otlp:Endpoint` | `SetupOpenTelemetry` | **OTLP export is not wired at all.** Prometheus `/metrics` still works | Opt-in by design, so no exporter spams an absent collector |
| `Search:Backend` | `AddPersistenceServices` | `SqlNurseSearch` | Any value other than `sql`/empty **throws at startup** — Elasticsearch is deferred and fails loudly |
| `Seed:AdminUsername` / `:AdminPassword` / `:AdminEmail` | `SeedDataBase` | no break-glass admin is seeded | The hardcoded `admin`/`qw123321` was removed in refinement-phase-5 |
| `Seams:<rail>:Provider` (×11) | `AddCrossCuttingSeams` | **`mock`** | The seam selectors, below |

### The seam selectors

`SeamOptions` binds the whole `Seams` section. **Every rail defaults to its mock**, so an unconfigured
environment behaves exactly as before and a **partial rollout is the normal case** — real SMS and a real
geocoder while payments stay mocked is three config keys.

| Selector | `mock` (default) → | Real values |
| --- | --- | --- |
| `Seams:Sms:Provider` | log the OTP | `kavenegar` `smsir` `ghasedak` · `telegram` *(Development relay, not a gateway)* |
| `Seams:ObjectStorage:Provider` | `local` disk | `s3` (MinIO / ArvanCloud, path-style) |
| `Seams:Geocoding:Provider` | deterministic point | `neshan` |
| `Seams:Shahkar:Provider` | designated test values | `finnotech` |
| `Seams:IdentityKyc:Provider` | designated test values | `finnotech` |
| `Seams:BankOwnership:Provider` | designated test values | `finnotech` |
| `Seams:Payments:Provider` | deterministic capture | `zarinpal` `sadad` `vandar` `jibit` |
| `Seams:Bnpl:Provider` | one mock provider | `real` → `IBnplProviderResolver` per `provider_code` |
| `Seams:BankTransfer:Provider` | settles every payout | `jibit` `vandar` `sadad` |
| `Seams:Moadian:Provider` | stays `pending` | `moadian` |
| `Seams:Currency` *(no selector)* | `TomanToIrrMultiplier = 10` | a redenomination is a config change |

`Seams:Finnotech:{BaseUrl,ClientId,AccessToken}` are shared by the three trust rails — they authenticate
against one tenant, so the connection facts live once.

Each mock also carries **test knobs** whose only purpose is to make a failure path reachable:
`Shahkar:SharedSimPhone` `09120000000` · `Shahkar:MismatchNationalId` `1111111111` ·
`IdentityKyc:FailNationalId` `0000000000` · `BankOwnership:MismatchIban` `IR0000…0000` ·
`Bnpl:NotEligibleMobile` `09120000099` · `BankTransfer:FailIban` · `BankTransfer:ForceFailure` ·
`PaymentCapture:ForceFailure` · `Moadian:ForceRegistered` · `ReviewModeration:AutoApproveClean` ·
`LicenseVerification:AutoApprove` · `Payments:InvalidSignatureMarker` `INVALID_SIGNATURE`.

---

## Client — `client/.env.*`

**Every `NEXT_PUBLIC_*` value is inlined into the browser bundle at build time.** It is public by
definition, and changing one requires **rebuilding the image**, not restarting the container. This is why
`docker-compose.yml` deliberately sets no `environment:` for the `web` service — anything there would be
silently ignored.

| Key | `.env.development` | `.env.production` | Read by | Required |
| --- | --- | --- | --- | --- |
| `NEXT_PUBLIC_API_URL` | `http://localhost:5002` | `https://api.balinyaar.ir` | `config.ts` → `API_URL` | **yes** — `envRequired`, boot fails without it |
| `NEXT_PUBLIC_ENV` | `development` | `production` | `getCurrentEnvironment()` → `IS_PRODUCTION` | — |
| `NEXT_PUBLIC_DEBUG` | `true` | `false` | `IS_DEBUG` — `true` **prints the resolved config, including the API URL, to the browser console** | — |
| `NEXT_PUBLIC_PUBLIC_URL` | `http://localhost:3000` | `https://balinyaar.ir` | `PUBLIC_URL` (optional) | — |
| `NEXT_PUBLIC_SITE_URL` | *(unset)* | `https://balinyaar.ir` | `SITE_URL` — **metadata only** (OG tags, `metadataBase`, `robots.ts`, `sitemap.ts`), never API calls | — · falls back to `http://localhost:3000` |
| `NEXT_PUBLIC_NESHAN_KEY` | *(unset)* | *(commented out)* | `NESHAN_WEB_KEY` — the Neshan **web** key | — · unset ⇒ `AddressMapPicker` uses its bounded-canvas grid, so dev/CI/jsdom work without it |
| `NEXT_PUBLIC_EVV_MOCK_GPS` | **not in any file** | **not in any file** | `bookings/constants.ts` | — · `in_range` when the bookings mock is on, else `off`. Values: `off` `in_range` `out_of_range` `denied` |
| `NEXT_PUBLIC_VERSION` | **not in any file** | **not in any file** | `getCurrentVersion()`, after `npm_package_version` | — · falls back to `'unknown'` |

> **Two Neshan keys exist and they are different products.** `NEXT_PUBLIC_NESHAN_KEY` is a client-embeddable
> **web** key; `Seams:Geocoding:ApiKey` is the **server** geocoding key. Never share one value between them.

`client/.env.sample` is the copy-me template for a fresh clone. It is **not** loaded by Next.js.

---

## Docker — `docker-compose.yml`

Three containers, **no published ports** — everything is reached through the existing Caddy on the external
`caddy_net`. Full graph in [topology.md](topology.md).

| Service | Variable | Value | Why it is here and not in a file |
| --- | --- | --- | --- |
| `api` | `ASPNETCORE_ENVIRONMENT` | `Development` | **Deliberate**, so the demo + lifecycle seeders populate the shared DB. Consequences in [DEPLOY.md](../../DEPLOY.md) |
| `api` | `Seams__Sms__Telegram__BaseUrl` | `http://balinyaar-otp-relay:5010` | Container DNS instead of loopback |
| `api` | `Seams__ObjectStorage__RootPath` | `/app/data/object-storage` | Must land on the named volume |
| `web` | *(none)* | — | Every `NEXT_PUBLIC_*` is baked at build time; a variable here would be ignored |
| `otp-relay` | `TELEGRAM_BOT_TOKEN` | `8968527151:AAF…` | Live credential in git |
| `otp-relay` | `TELEGRAM_CHAT_IDS` | `1277103616,110209855` | **Every id receives every login code, for every phone number** |
| `otp-relay` | `API_KEY` | `6a8dfaee…` | **Must equal `Seams:Sms:Telegram:ApiKey`** |
| `otp-relay` | `TELEGRAM_PROXY_URL` | `http://hysteria-client:8081` | `api.telegram.org` is filtered in Iran; a wrong value fails at boot, not per-OTP |
| `otp-relay` | `REDACT_CODE_IN_LOGS` | `"true"` | Keeps codes out of `docker logs` so a host-log reader cannot harvest them |

Volumes: `api-object-storage` (uploaded verification documents — **losing it breaks the admin queue**) and
`api-logs` (Serilog file sink).

## Caddy — `deploy/Caddyfile`

Not loaded by anything in this repo; it is a copy of the block `DEPLOY.md` tells you to paste into the
Caddy container that owns `caddy_net`.

| Hostname | Upstream | Notes |
| --- | --- | --- |
| `balinyaar.ir`, `www.balinyaar.ir` | `balinyaar-web:3000` | |
| `api.balinyaar.ir` | `balinyaar-api:8080` | **8080 in-container, not 5002** — 5002 is the local `launchSettings.json` port |

Caddy is the only TLS terminator and renews both certificates itself. It sets `X-Forwarded-For` and
`X-Forwarded-Proto` by default, which is why no header directives are needed — but the API must trust the
hop via `ForwardedHeaders:KnownNetworks`, or the rate limiter partitions every request onto Caddy's IP.

## CORS

`CorsServiceExtension`, policy `BalinyaarWebClient`.

| | |
| --- | --- |
| Origins | `Cors:AllowedOrigins`; falls back to `http://localhost:3000` when unset or empty |
| Headers | `Authorization` · `Content-Type` · `Accept-Language` · `Idempotency-Key` — explicit, **not** `AllowAnyHeader`, so the surface is auditable |
| Methods | any |
| **Credentials** | **not allowed.** The client authenticates with a bearer header, not a cookie, so `AllowCredentials()` is unnecessary |
| Pipeline position | after `UseRouting()`, **before** the rate limiter and authentication, so a pre-flight `OPTIONS` is answered rather than rejected as 429/401 |

Adding a browser origin means editing `Cors:AllowedOrigins` **and** rebuilding the client if its
`NEXT_PUBLIC_API_URL` changes.

## Telegram OTP relay — `telegram-otp-bot`

A standalone zero-dependency Node service. **It is not an SMS gateway**: there is no per-user routing — it
*broadcasts* every code to a fixed list of chat ids. Workable for a trusted demo group, disqualifying the
moment anyone outside it can request a code. Switching `Seams:Sms:Provider` to `kavenegar` at that point
changes nothing else.

| Variable | Required | Default | Notes |
| --- | --- | --- | --- |
| `TELEGRAM_BOT_TOKEN` | **yes** | — | From @BotFather |
| `TELEGRAM_CHAT_IDS` | **yes** | — | Comma-separated. **Each recipient must have messaged the bot first** — Telegram forbids a bot opening a conversation |
| `API_KEY` | **yes** | — | Min 16 chars; the process refuses to start without it. Must equal `Seams:Sms:Telegram:ApiKey`. **The value in `.env.example` is published, and `TelegramSmsSender` deliberately refuses to authenticate with it** |
| `PORT` | — | `5010` | |
| `HOST` | — | `127.0.0.1` | |
| `REDACT_CODE_IN_LOGS` | — | `false` | `true` in the deployment |
| `TELEGRAM_PROXY_URL` | — | unset ⇒ direct | HTTP CONNECT or SOCKS5. `HTTPS_PROXY`/`ALL_PROXY` are honoured as a fallback |

`telegram-otp-bot/.env` is for **local `npm start` only** — nothing in it is read inside the container.

## The database is not containerised

It is a remote SQL Server at `87.107.152.16:1433`, already provisioned and already seeded. Nothing in
`docker-compose.yml` creates it; the API only needs network reach. Two databases: `Baya` (app) and
`Baya_Logs` (Serilog sink).

## Health, metrics, and the secrets guard

| Endpoint | Checks |
| --- | --- |
| `/healthz/live` | process only — deliberately dependency-free, so a dependency outage never restarts a healthy instance |
| `/healthz/ready` | app DB · log DB (**deployed only** — its connection string is a placeholder in Development) · an object-storage **write** round-trip |
| `/HealthCheck` | the aggregate, retained for existing probes |
| `/metrics` | Prometheus scrape. OpenTelemetry is the only metrics source; the duplicate prometheus-net stack was removed |

`StartupSecretsGuard` runs before any service reads configuration and **refuses to boot** on a missing or
placeholder value. It requires both connection strings in every environment, and the four crypto keys only
when **not** Development. It is skipped entirely in the `Testing` environment. Placeholder markers:
`SET_VIA_USER_SECRETS_OR_ENV`, `not-for-production`, `change-me`,
`ShouldBe-LongerThan-16Char-SecretKey`, `16CharEncryptKey`.

---

## What the diff found

Enumerating both sets and subtracting them surfaced five things. None is a broken deployment; all five are
places where the config is not discoverable from the config files.

1. **`NEXT_PUBLIC_EVV_MOCK_GPS` and `NEXT_PUBLIC_VERSION` are read by client code and declared in no `.env`
   file.** Both have working defaults. Adding them commented-out to `.env.sample` would make them findable.
2. **`OpenTelemetry:Otlp:Endpoint`, `Search:Backend` and `Seed:Admin*` are read by server code and set
   nowhere.** All three are intentionally opt-in, but a reader of `appsettings.json` cannot learn they
   exist. `Search:Backend` is the sharpest: a wrong value **throws at startup**.
3. **`client/.env.sample` still says `NEXT_PUBLIC_API_URL = https://localhost:5002`** — the `https` half of
   contradiction **C-3**, in the one file a fresh clone is meant to copy. `.env.development` has the
   correct `http://`.
4. **`Seams:Sms:Telegram:ApiKey` and the relay's `API_KEY` are the same secret in two files** with no
   mechanism keeping them equal. They currently match. A mismatch fails every OTP send at runtime, not at
   boot.
5. **There is no `appsettings.Production.json`,** and `DEPLOY.md` step 2 of "Going to Production" is
   therefore a *create*, not an *edit*.

### The placeholder's name

`SET_VIA_USER_SECRETS_OR_ENV` names a store that no longer exists (contradiction **C-2**). The *behaviour*
is correct — it is a sentinel that `StartupSecretsGuard` rejects — but the name instructs a reader to use a
removed mechanism.

It was **not renamed in this phase**, because the string is load-bearing in several live files:
`appsettings.json` (×6), `StartupSecretsGuard.cs`, `Baya.Test.Api/StartupSecretsGuardTests.cs` (×2), and
`docs/rules/server/structure.md`. Renaming it is a server-code + test change requiring `dotnet build` and
`dotnet test` to prove the gate still fires — out of scope for a documentation phase. **This section is the
authoritative statement of the mechanism**; the rename is filed for Phase 4 with that worklist.
