18 KiB
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-07-30 against commit
d3ec723. Built by mechanically enumerating every leaf key in bothappsettings*.json, everyenvironment:entry indocker-compose.yml, every assignment in the threeclient/.env*files andtelegram-otp-bot/.env.example, and everyprocess.env.*read underclient/src/— then diffing the sets. The gaps that diff found are in § 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).
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 and, when Phase 5 writes it,
docs/roadmap/pre-launch.md.
⚠️
Seams:FieldEncryption:Keyand:HashKeyare load-bearing and must never change. Every encrypted column in the database — phones, addresses, IBANs, clinical notes — was written with those exact values, andusers.PhoneHash, which every login looks up, is derived fromHashKey. 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 |
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 and the pre-commit hook blocks its return |
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_KEYis a client-embeddable web key;Seams:Geocoding:ApiKeyis 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.
| 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 |
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.
NEXT_PUBLIC_EVV_MOCK_GPSandNEXT_PUBLIC_VERSIONare read by client code and declared in no.envfile. Both have working defaults. Adding them commented-out to.env.samplewould make them findable.OpenTelemetry:Otlp:Endpoint,Search:BackendandSeed:Admin*are read by server code and set nowhere. All three are intentionally opt-in, but a reader ofappsettings.jsoncannot learn they exist.Search:Backendis the sharpest: a wrong value throws at startup.client/.env.samplestill saysNEXT_PUBLIC_API_URL = https://localhost:5002— thehttpshalf of contradiction C-3, in the one file a fresh clone is meant to copy..env.developmenthas the correcthttp://.Seams:Sms:Telegram:ApiKeyand the relay'sAPI_KEYare 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.- There is no
appsettings.Production.json, andDEPLOY.mdstep 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 and the pre-commit hook both reject — 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 seven live files:
appsettings.json (×6), StartupSecretsGuard.cs, .githooks/pre-commit, .githooks/README.md,
Baya.Test.Api/StartupSecretsGuardTests.cs (×2), docs/rules/shared/git-and-gates.md and
docs/rules/server/structure.md. Renaming it is a server-code + git-hook + 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
exact seven-file worklist.