# 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 `` 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::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.