# Topology — the runtime dependency graph

What talks to what at runtime, what breaks when each hop is down, and which file configures it. This is the
answer to a deploy question; the deploy *procedure* is [DEPLOY.md](../../DEPLOY.md) and every key is in
[config-matrix.md](config-matrix.md).

> Last verified: 2026-07-30 against commit `d3ec723`, `docker-compose.yml`, `deploy/Caddyfile` and
> `server/src/API/Baya.Web.Api/Program.cs`.

---

## Deployed

```mermaid
graph LR
  browser["Browser<br/>balinyaar.ir"]

  subgraph net["caddy_net (external docker network)"]
    caddy["Caddy<br/><i>pre-existing, not in this repo</i><br/>TLS terminator"]
    web["balinyaar-web:3000<br/>Next.js 16"]
    api["balinyaar-api:8080<br/>ASP.NET Core 10"]
    relay["balinyaar-otp-relay:5010<br/>Node, zero deps"]
    proxy["hysteria-client:8081<br/><i>pre-existing</i>"]
  end

  sql[("Remote SQL Server<br/>87.107.152.16:1433<br/><b>not containerised</b>")]
  vol[["api-object-storage<br/>docker volume"]]
  tg["api.telegram.org"]
  rails["External rails<br/>PSP · BNPL · Finnotech · Neshan · مودیان<br/><i>all mocked by default</i>"]

  browser -->|"HTTPS"| caddy
  caddy -->|"HTTP :3000"| web
  browser -->|"HTTPS api.balinyaar.ir<br/><b>every API call</b>"| caddy
  caddy -->|"HTTP :8080<br/>+ X-Forwarded-For"| api
  api -->|"TCP 1433"| sql
  api -->|"HTTP + X-Api-Key"| relay
  api --> vol
  api -.->|"per seam config"| rails
  relay -->|"HTTP CONNECT"| proxy
  proxy --> tg

  web -.->|"builds absolute URLs only<br/><b>no server-to-server calls</b>"| api
```

**The single most important edge is the dotted one.** The browser calls the API directly at
`https://api.balinyaar.ir`; the `web` container does **not** proxy or fetch on the browser's behalf. That is
why `NEXT_PUBLIC_API_URL` must be the **public hostname**, never the container name — a container name is
unresolvable from a browser. It is also why every `NEXT_PUBLIC_*` change needs an image rebuild: the value
is compiled into the bundle.

## Every edge

| # | From → To | Carries | Breaks if down | Configured in |
| --- | --- | --- | --- | --- |
| 1 | browser → Caddy | All HTTPS for both hostnames | Everything. Caddy is the only TLS terminator and the only published port | `deploy/Caddyfile` (pasted into the pre-existing Caddy) |
| 2 | Caddy → `balinyaar-web:3000` | The Next.js app shell, RSC payloads, static assets | The site does not load. **The API keeps working** — they are independent hostnames | `Caddyfile` · `docker-compose.yml` |
| 3 | browser → Caddy → `balinyaar-api:8080` | Every `/api/v1/*` call, bearer-authenticated | The site loads and every screen shows its error state. No data, no login | `NEXT_PUBLIC_API_URL` (build-time) · `Cors:AllowedOrigins` |
| 4 | Caddy → API, `X-Forwarded-For` | The real client IP | The rate limiter partitions every request onto **Caddy's** IP, so one noisy client 429s everyone | `ForwardedHeaders:KnownNetworks` — the docker bridge ranges |
| 5 | API → remote SQL Server | All application data + the Serilog sink | **Total outage.** `/healthz/ready` fails and the API will not start | `ConnectionStrings:SqlServer` / `:logDb` |
| 6 | API → `balinyaar-otp-relay:5010` | OTP codes, `X-Api-Key` authenticated | **Nobody can log in.** Every other authenticated screen keeps working for existing sessions | `Seams__Sms__Telegram__BaseUrl` (compose) · `Seams:Sms:Telegram:ApiKey` |
| 7 | relay → `hysteria-client:8081` → Telegram | The code delivery itself | Same as 6 — codes are generated but never arrive. Fails **at relay boot** with a clear message, not silently per-OTP | `TELEGRAM_PROXY_URL` |
| 8 | API → `api-object-storage` volume | Verification documents, avatars | Uploads fail; `/healthz/ready` fails (it does a real write probe). **Without the volume, existing documents vanish on the next `up --build`** | `Seams__ObjectStorage__RootPath` + the named volume |
| 9 | API → external rails | Payments, BNPL, KYC, geocoding, e-invoicing | **Nothing, by default** — every rail is `mock`. Real behaviour begins the moment a `Provider` selector changes | `Seams:<rail>:Provider` — see [config-matrix.md](config-matrix.md#the-seam-selectors) |
| 10 | PSP / BNPL / transferor → API webhooks | Payment capture, BNPL settlement, payout reconciliation | Payments are taken but **bookings are never confirmed** — the webhook is what creates the booking | `webhook` rate-limit policy, anonymous routes, per-provider signing secrets |

## Ports

| Port | Where it applies | Note |
| --- | --- | --- |
| `443` | The host | Caddy. **The only published port on the machine** |
| `3000` | `caddy_net` only | Next.js. Not published |
| `8080` | `caddy_net` only | The API **in-container** |
| `5002` | A developer laptop only | The local `launchSettings.json` port — **plain HTTP**, and nothing in the deployment uses it |
| `5010` | `caddy_net` only | The OTP relay |
| `1433` | Outbound to `87.107.152.16` | Remote SQL Server |

> **5002 vs 8080 catches people out.** Most prose in this repo says 5002 because that is the local port.
> The container binds 8080 (the ASP.NET default in a container) and the Caddyfile points there.

## Startup order

`depends_on: [otp-relay]` puts the relay before the API, but that only orders *container start*, not
readiness. What actually gates the API's boot:

1. **`StartupSecretsGuard`** — refuses to start on a missing or placeholder secret, before any service reads
   config.
2. **A reachable SQL Server** — required to start, full stop.
3. **In Development** (which is what the deployment runs): apply migrations, then seed default users,
   payment gateways, the demo world and the demo lifecycle. All idempotent — a re-boot logs
   `Demo world already seeded — no-op.` **First boot takes ~40 s** against the remote DB.
4. **When deployed as a non-Development environment**: DDL is a separate one-shot
   (`dotnet Baya.Web.Api.dll migrate`); boot only *checks* the schema is current and fails fast on a pending
   migration, then seeds roles.

The relay refuses to start without `API_KEY` (min 16 chars) or with an unreachable proxy — both fail loudly
at boot rather than per-request.

## Request pipeline inside the API

Order matters, and two placements are deliberate:

```
UseForwardedHeaders   ← first, so the resolved client IP is in place before anything reads it
UseSwaggerAndUi
UseRouting
UseCors               ← after routing, BEFORE the rate limiter and auth, so a pre-flight OPTIONS
UseRateLimiter           is answered rather than rejected as 429 or 401
UseAuthentication
UseAuthorization
MapControllers
UseMetrics · UseHealthChecks
ConfigureGrpcPipeline
```

## What is not in the graph

| | Why |
| --- | --- |
| A database container | The DB is remote and pre-provisioned. `RUNBOOK.md`'s local-SQL-in-Docker path describes a **different world** — an unseeded one (contradiction **C-5**) |
| Redis | `ICacheService` / `IDistributedLock` are single-process today. When a multi-instance deployment needs a real one, a `redis` readiness check gets added alongside it |
| A job runner | The weekly payout batch is generated by an **in-process** `IRecurringJob` scheduler. No external cron, no queue |
| A message broker | Nothing is asynchronous across a process boundary |
| An OTLP collector | `OpenTelemetry:Otlp:Endpoint` is unset, so nothing is exported. Prometheus scrapes `/metrics` directly |
| A second API instance | Single instance. Multi-instance needs the distributed lock to become real first |

## Local development

Same code, a different graph — no Caddy, no containers, and the **same remote database**:

```
localhost:3000  (npm run dev)  ──▸  localhost:5002  (dotnet run, plain HTTP)
                                         │
                                         ├──▸  87.107.152.16:1433   (the same remote DB)
                                         └──▸  127.0.0.1:5010       (the relay, if you run it)
```

Three things to know before the first run:

- **The API is plain HTTP locally.** `dotnet dev-certs https --trust` is not needed and there is no
  certificate to trust (contradiction **C-4**).
- **You share the deployment's database.** Booting in Development migrates and seeds against it. The
  seeders are idempotent, but you and the demo site are looking at the same rows.
- **If you run the relay locally**, copy `Seams:Sms:Telegram:ApiKey` from `appsettings.Development.json`
  into your own `telegram-otp-bot/.env`. The value in `.env.example` is published and
  `TelegramSmsSender` deliberately rejects it.
