Files
baya-monorepo/archive/docs/integration/config-matrix.md
T
2026-08-02 20:01:31 +03:30

263 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.