From 5885280b49c0973a759d8c24cfb66cdce1eb59c2 Mon Sep 17 00:00:00 2001 From: hamid Date: Tue, 28 Jul 2026 23:18:54 +0330 Subject: [PATCH] remove user-secrets approach & prepare a pilot deploy --- .githooks/README.md | 19 +- .githooks/pre-commit | 52 ++++-- CLAUDE.md | 17 +- DEPLOY.md | 169 ++++++++++++++++++ client/.dockerignore | 18 ++ client/.env.production | 24 +++ client/Dockerfile | 36 ++++ client/next.config.mjs | 5 +- deploy/Caddyfile | 21 +++ dev/post-phase/refinement/RUNBOOK.md | 64 ++++--- docker-compose.yml | 91 ++++++++++ server/.dockerignore | 18 ++ server/CLAUDE.md | 17 +- server/CONVENTIONS.md | 4 +- server/Dockerfile | 58 +++--- .../src/API/Baya.Web.Api/Baya.Web.Api.csproj | 2 - .../Configuration/StartupSecretsGuard.cs | 12 +- .../Baya.Web.Api/appsettings.Development.json | 27 ++- .../Seams/Real/TelegramSmsSender.cs | 19 +- .../Seams/SeamOptions.cs | 25 +-- .../ServiceCollectionExtension.cs | 2 +- .../SeedDatabaseService/SeedDataBase.cs | 2 +- .../ServiceCollectionExtensions.cs | 4 +- .../src/Tests/Baya.Test.Api/BayaApiFactory.cs | 2 +- telegram-otp-bot/.dockerignore | 12 ++ telegram-otp-bot/.env.example | 12 +- telegram-otp-bot/Dockerfile | 20 +++ telegram-otp-bot/README.md | 29 ++- 28 files changed, 639 insertions(+), 142 deletions(-) create mode 100644 DEPLOY.md create mode 100644 client/.dockerignore create mode 100644 client/.env.production create mode 100644 client/Dockerfile create mode 100644 deploy/Caddyfile create mode 100644 docker-compose.yml create mode 100644 server/.dockerignore create mode 100644 telegram-otp-bot/.dockerignore create mode 100644 telegram-otp-bot/Dockerfile diff --git a/.githooks/README.md b/.githooks/README.md index 7314003..f25f094 100644 --- a/.githooks/README.md +++ b/.githooks/README.md @@ -10,14 +10,21 @@ git config core.hooksPath .githooks ## `pre-commit` — secret scan -A fast, dependency-free backstop for the root `CLAUDE.md` rule **"Never commit secrets"** +A fast, dependency-free backstop against a credential leaking into a file that shouldn't hold one (refinement-phase-5). It rejects a commit that stages: -- the historically-leaked SQL Server host `87.107.152.16`, -- the retired hardcoded admin password `qw123321`, -- a **real** connection-string password in any `appsettings*.json` (only the `SET_VIA_USER_SECRETS_OR_ENV` - placeholder is allowed — real values belong in user-secrets / environment variables), -- private-key material or an AWS access-key id, anywhere. +- the retired hardcoded admin password `qw123321`, anywhere, +- private-key material or an AWS access-key id, anywhere, +- the deployment's SQL Server host `87.107.152.16` **outside the declared config files**, +- a **real** connection-string password in any `appsettings*.json` **outside the declared config files** + (elsewhere only the `SET_VIA_USER_SECRETS_OR_ENV` placeholder is allowed). + +**Declared config files.** The pre-launch demo deployment configures itself from committed files rather +than a secret store ([DEPLOY.md](../DEPLOY.md)), so a short allow-list — `appsettings.Development.json`, +`docker-compose.yml`, `telegram-otp-bot/.env.example`, `DEPLOY.md` — is exempt from the last two checks. +The list is maintained in the `declared_config` function in the hook and is the honest record of where the +repo's secrets are. **Shrink it, never grow it**: once real users exist, those values must be rotated and +moved out of git. It scans only staged additions, so it is quick. It is **not** a replacement for a full scanner (gitleaks / trufflehog) in CI — it is the local first line of defence. diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 9e16a72..15ca2f2 100644 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -12,6 +12,23 @@ set -euo pipefail # Committed placeholders are allowed — real values are not. Keep in sync with StartupSecretsGuard. PLACEHOLDER='SET_VIA_USER_SECRETS_OR_ENV' +# Files that deliberately carry live deployment credentials, because the pre-launch demo deployment +# configures itself from committed files rather than a secret store (see DEPLOY.md). They are exempt from +# the connection-string and known-host checks ONLY — the private-key and AWS-key checks still apply to +# them, and every other file in the repo is scanned exactly as strictly as before. +# +# This list is the honest record of where the repo's secrets are. Shrink it, never grow it: the moment +# real users exist, these values must be rotated and moved out of git. +declared_config() { + case "$1" in + server/src/API/Baya.Web.Api/appsettings.Development.json) return 0 ;; + docker-compose.yml) return 0 ;; + telegram-otp-bot/.env.example) return 0 ;; + DEPLOY.md) return 0 ;; + *) return 1 ;; + esac +} + # Only scan added/changed lines in text files that are staged. staged=$(git diff --cached --name-only --diff-filter=ACM) [ -z "$staged" ] && exit 0 @@ -30,21 +47,23 @@ while IFS= read -r file; do added=$(git diff --cached -U0 -- "$file" | grep '^+' | grep -v '^+++' || true) [ -z "$added" ] && continue - # The historically-leaked SQL Server host — must never reappear. - echo "$added" | grep -Eq '87\.107\.152\.16' && report "$file: leaked SQL Server host 87.107.152.16" - - # The retired hardcoded admin password. + # The retired hardcoded admin password. Applies everywhere, no exemptions. echo "$added" | grep -Eq 'qw123321' && report "$file: hardcoded admin password 'qw123321'" - # A real (non-placeholder) connection-string password in a committed appsettings file. - case "$file" in - *appsettings*.json) - echo "$added" \ - | grep -Ei 'Password=[^;"'"'"' ]+' \ - | grep -viq "Password=${PLACEHOLDER}" \ - && report "$file: connection-string password must be '${PLACEHOLDER}' (real value belongs in user-secrets/env)" - ;; - esac + if ! declared_config "$file"; then + # The deployment's SQL Server host — outside the declared config files it is a leak. + echo "$added" | grep -Eq '87\.107\.152\.16' && report "$file: SQL Server host 87.107.152.16 outside the declared config files" + + # A real (non-placeholder) connection-string password in a committed appsettings file. + case "$file" in + *appsettings*.json) + echo "$added" \ + | grep -Ei 'Password=[^;"'"'"' ]+' \ + | grep -viq "Password=${PLACEHOLDER}" \ + && report "$file: connection-string password must be '${PLACEHOLDER}' (see DEPLOY.md for where real values live)" + ;; + esac + fi # Private keys and common cloud tokens, anywhere. echo "$added" | grep -Eq -- '-----BEGIN (RSA|EC|OPENSSH|PRIVATE) .*PRIVATE KEY-----' && report "$file: private key material" @@ -53,9 +72,10 @@ done <<< "$staged" if [ "$violations" -gt 0 ]; then echo "" - echo "Commit blocked: $violations potential secret(s) staged. Move the real value to user-secrets" - echo "(Development) or an environment variable (deploy) and commit only the '${PLACEHOLDER}' placeholder." - echo "See dev/post-phase/refinement/RUNBOOK.md. To override a false positive: git commit --no-verify" + echo "Commit blocked: $violations potential secret(s) staged. Real values belong in one of the declared" + echo "config files (see the 'declared_config' list in this hook, and DEPLOY.md); everything else commits" + echo "only the '${PLACEHOLDER}' placeholder." + echo "To override a false positive: git commit --no-verify" exit 1 fi diff --git a/CLAUDE.md b/CLAUDE.md index 64cacff..23eff8b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,10 +55,17 @@ solution — each project is built, linted, and run on its own. | [`server/`](server/) | Backend API | ASP.NET Core (.NET 10) · Clean Architecture · CQRS · EF Core | [server/CLAUDE.md](server/CLAUDE.md) | | [`product/`](product/) | Product docs | Markdown | — (see table above) | | [`dev/`](dev/) | Build plan (not app code) | Markdown | [dev/README.md](dev/README.md) | +| [`telegram-otp-bot/`](telegram-otp-bot/) | OTP relay (standalone) | Node 18+, zero deps | [telegram-otp-bot/README.md](telegram-otp-bot/README.md) | +| [`deploy/`](deploy/) | Reverse-proxy config | Caddyfile | [DEPLOY.md](DEPLOY.md) | The two communicate over **HTTP/JSON** (optionally gRPC). The client reads the API base URL from `NEXT_PUBLIC_API_URL`; the server listens on `https://localhost:5002` by default. +**Deployment** is three Docker containers — one `Dockerfile` per project directory, orchestrated by the +root [`docker-compose.yml`](docker-compose.yml) — behind an existing Caddy reverse proxy on the external +`caddy_net` network, serving `balinyaar.ir` (client) and `api.balinyaar.ir` (server). The database is +**not** containerised; it is a remote SQL Server. Full runbook: [DEPLOY.md](DEPLOY.md). + [`dev/`](dev/README.md) holds the **phased build plan** that takes the repo from its current baseline to the MVP: a chain of agent-runnable prompt files split into a `backend/` and a `frontend/` track ([dev/phases/](dev/phases/README.md)), the cross-project API [`contracts/`](dev/contracts/README.md), and @@ -80,8 +87,14 @@ project — there is nothing to build in it. 5. **Don't reintroduce template/starter scaffolding.** Both projects were derived from open-source starters; their branding, demo/showcase pages, and `_TITLE_`/`_DESCRIPTION_` placeholders were intentionally removed. Don't add them back. -6. **Never commit secrets.** Use `.env` (client) and `appsettings.*.json` / user-secrets (server). - Real connection strings, keys, and tokens never enter git. +6. **Configuration lives in files, not in a secret store.** `dotnet user-secrets` is **not** used — the + `` was removed from `Baya.Web.Api.csproj`, so that store isn't even read. Server config + (including keys) lives in `appsettings.*.json`; client config in `.env.development` / `.env.production`; + the deployment's container-specific overrides in `docker-compose.yml`. This is a deliberate pre-launch + trade for a demo deployment — **the repo therefore contains live credentials**. Before onboarding real + users, rotate them and move the secret half out of git (see [DEPLOY.md](DEPLOY.md) "Going to Production"). + One value is load-bearing and must never change: `Seams:FieldEncryption:Key`/`:HashKey` decrypt all + existing PII and derive the phone-lookup hash. 7. **Keep docs honest, and keep the architecture map current.** If you change how something works, update the `CLAUDE.md` that describes it in the same change. Each level documents its architecture in one canonical place — **this file's "Repository layout"** (repo), **client/CLAUDE.md "Project diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..eaebd01 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,169 @@ +# Deploying Balinyaar + +A first, shareable deployment of the whole stack under **balinyaar.ir**, in Docker, behind an existing +Caddy reverse proxy that terminates TLS. + +| Host | Serves | Container | +| --- | --- | --- | +| `balinyaar.ir`, `www.balinyaar.ir` | Next.js web client | `balinyaar-web:3000` | +| `api.balinyaar.ir` | ASP.NET Core API | `balinyaar-api:8080` | +| *(internal only)* | Telegram OTP relay | `balinyaar-otp-relay:5010` | + +The **database is not containerised** — it is the remote SQL Server already configured in +[server/src/API/Baya.Web.Api/appsettings.Development.json](server/src/API/Baya.Web.Api/appsettings.Development.json). +Nothing needs to be provisioned for it; the API just needs network reach to `87.107.152.16:1433`. + +--- + +## Configuration model + +**There is no `dotnet user-secrets` any more.** The `` was removed from +`Baya.Web.Api.csproj`, so the API no longer reads that store at all — a stale `secrets.json` on a dev +machine is now inert and can be deleted. Every value lives in a file in the repo: + +| What | Where | +| --- | --- | +| API config + secrets (DB, JWE keys, field-encryption keys, Telegram key, CORS, trusted proxies) | `server/src/API/Baya.Web.Api/appsettings.Development.json` | +| The two values that differ between a laptop and the container network | `docker-compose.yml` → `api.environment` | +| Client build-time config (API URL, site origin) | `client/.env.production` | +| Telegram relay config (bot token, chat ids, API key, proxy) | `docker-compose.yml` → `otp-relay.environment` | + +The API runs as **`ASPNETCORE_ENVIRONMENT=Development`**, so `appsettings.Development.json` is the file +that actually loads. An `appsettings.Production.json` would be ignored — put changes in the Development +file, or change the environment name first. + +The relay's shared secret appears twice and the two must match: `Seams:Sms:Telegram:ApiKey` in the +appsettings file and `API_KEY` in the compose file. It was rotated away from the value in +`telegram-otp-bot/.env.example`, which is published in git and in that project's README — +`TelegramSmsSender` now refuses to authenticate with it. **If you run the relay locally**, copy the +appsettings value into your own `telegram-otp-bot/.env`. + +> ⚠️ **`Seams:FieldEncryption:Key` and `:HashKey` must never change.** Every encrypted column in that +> database — phone numbers, 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; doing so only signs everyone out. + +--- + +## What running as Development means + +This was a deliberate choice so the demo and lifecycle seeders populate the shared database and the +screens aren't empty. It has real consequences, all of which are fine for a pre-launch demo among +people you trust, and none of which are acceptable once strangers can reach the site: + +- **The developer exception page is public.** Any unhandled 500 on `api.balinyaar.ir` returns a stack + trace and configuration detail to the caller. +- **`GET /api/v1/dev/last_otp/{phone}` is live.** Anyone who knows a registered phone number can read + its login code and sign in as that user. This is the single biggest exposure. +- **Swagger is served** at `api.balinyaar.ir/swagger`. +- **The seeders re-run on every container boot** (idempotent, so this is safe — they no-op on data that + already exists) and **migrations auto-apply on boot** rather than as a separate step. +- **gRPC reflection is enabled**, and the demo `bookings/convert` payment-capture simulator is wired. + +### Going to Production later + +1. Set `ASPNETCORE_ENVIRONMENT: Production` in `docker-compose.yml`. +2. Create `appsettings.Production.json` with the same content as the Development file, but with **real** + `IdentitySettings:SecretKey` / `Encryptkey` — `StartupSecretsGuard` rejects anything containing + `not-for-production` outside Development, so the current dev keys will refuse to boot (by design). + Keep `Seams:FieldEncryption` byte-identical. +3. Run migrations as a one-shot instead of on boot: + `docker compose run --rm api dotnet Baya.Web.Api.dll migrate` +4. Swap the OTP rail: `Seams:Sms:Provider` → `kavenegar`, with `Seams:Sms:ApiKey`/`Sender` filled in. + The Telegram relay broadcasts every code to a fixed recipient list, which stops being acceptable the + moment someone outside that list can request one. + +--- + +## First deploy + +### 1. Confirm the Caddy network exists + +The compose file joins `caddy_net` as an **external** network — it does not create it. + +```bash +docker network ls | grep caddy_net +``` + +### 2. Add the Balinyaar block to your Caddyfile + +Copy from [deploy/Caddyfile](deploy/Caddyfile) into the Caddyfile your Caddy container already loads: + +```caddyfile +balinyaar.ir, www.balinyaar.ir { + encode zstd gzip + reverse_proxy balinyaar-web:3000 +} + +api.balinyaar.ir { + encode zstd gzip + reverse_proxy balinyaar-api:8080 +} +``` + +Caddy obtains and renews the certificates for both hostnames itself. Reload it: + +```bash +docker exec caddy reload --config /etc/caddy/Caddyfile +``` + +### 3. Point DNS at the host + +`balinyaar.ir`, `www.balinyaar.ir` and `api.balinyaar.ir` all need an A record on the server's public IP +**before** Caddy can complete the ACME challenge. + +### 4. Confirm the proxy container is up + +The relay's hop to `api.telegram.org` is filtered in Iran and goes out through the proxy already on +`caddy_net`, configured as `TELEGRAM_PROXY_URL: http://hysteria-client:8081`. If that container has a +different name or port, change it in `docker-compose.yml` — a wrong value fails the relay at boot with a +clear message rather than silently per-OTP. + +### 5. Build and start + +```bash +docker compose up -d --build +docker compose ps +docker compose logs -f api +``` + +The API's first boot applies any pending migrations and runs the seeders against the remote database, so +it takes noticeably longer than later ones. + +--- + +## Verifying + +```bash +curl https://api.balinyaar.ir/healthz/live # process is up +curl https://api.balinyaar.ir/healthz/ready # + database and object storage reachable +curl -I https://balinyaar.ir # the public landing page +docker compose logs otp-relay | head # should print the bot's @username and the proxy label +``` + +A full login round-trip is the real check: request an OTP from the site and confirm the code arrives in +the Telegram chat. If it doesn't, `docker compose logs otp-relay` names the failing hop — a proxy error +and a Telegram API rejection look different. + +--- + +## Redeploying + +```bash +git pull +docker compose up -d --build +``` + +Rebuild the client whenever a `NEXT_PUBLIC_*` value in `client/.env.production` changes — those are +compiled into the browser bundle, so restarting the container alone changes nothing. + +## Persisted state + +Two named volumes survive rebuilds. Uploaded verification documents live in the first one; losing it +means the admin verification queue shows broken documents. + +| Volume | Holds | +| --- | --- | +| `api-object-storage` | Uploaded verification documents (local-disk `IObjectStorage` seam) | +| `api-logs` | Serilog JSON file sink | diff --git a/client/.dockerignore b/client/.dockerignore new file mode 100644 index 0000000..b3cf53b --- /dev/null +++ b/client/.dockerignore @@ -0,0 +1,18 @@ +node_modules +.next +out +coverage +.swc +graphify-out +*.tsbuildinfo + +# Local-only env files — .env.production IS copied, it is the deployed build's input. +.env +.env.local +.env.*.local + +Dockerfile +.dockerignore +CLAUDE.md +AGENTS.md +README.md diff --git a/client/.env.production b/client/.env.production new file mode 100644 index 0000000..a6b7fb6 --- /dev/null +++ b/client/.env.production @@ -0,0 +1,24 @@ +# Deployed (balinyaar.ir) values, read by `next build` when NODE_ENV=production. +# +# Every NEXT_PUBLIC_* value here is INLINED INTO THE CLIENT BUNDLE AT BUILD TIME — it is public by +# definition, and changing one requires rebuilding the image, not restarting the container. +# `.env.development` still owns the local `npm run dev` loop and is untouched by this file. + +# Enables analytics and public resources. +NEXT_PUBLIC_ENV = production + +# Off in a deployed build — `true` prints the resolved @/config (incl. the API URL) to the browser console. +NEXT_PUBLIC_DEBUG = false + +# Public origin of the web app. +NEXT_PUBLIC_PUBLIC_URL = https://balinyaar.ir + +# Absolute origin used only for metadata (OG tags, metadataBase, robots.ts, sitemap.ts) — never for API calls. +NEXT_PUBLIC_SITE_URL = https://balinyaar.ir + +# The API, reached from the BROWSER — so it is the public hostname Caddy serves, never the container name. +NEXT_PUBLIC_API_URL = https://api.balinyaar.ir + +# Neshan **web** key (client-embeddable maps/search) from https://platform.neshan.org. Unset: the address +# map-pin picker falls back to its bounded-canvas grid stand-in. Rebuild the client image after setting it. +# NEXT_PUBLIC_NESHAN_KEY = your-neshan-web-key diff --git a/client/Dockerfile b/client/Dockerfile new file mode 100644 index 0000000..3d87a40 --- /dev/null +++ b/client/Dockerfile @@ -0,0 +1,36 @@ +# Balinyaar web client — build context is `client/` (see the root docker-compose.yml). +# +# NEXT_PUBLIC_* values are inlined into the browser bundle by `next build`, so the API URL and site origin +# are BUILD-time inputs, not runtime env vars — setting them in compose would do nothing. They come from the +# committed .env.production, which `next build` reads because it runs with NODE_ENV=production; change a value +# there and rebuild the image. + +FROM node:22-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci + +FROM node:22-alpine AS build +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +ENV NEXT_TELEMETRY_DISABLED=1 +RUN npm run build + +FROM node:22-alpine AS final +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +# `output: 'standalone'` traces the runtime dependencies into .next/standalone; static assets and public/ +# are deliberately NOT included in that trace and must be copied alongside it, or every asset 404s. +COPY --from=build --chown=node:node /app/.next/standalone ./ +COPY --from=build --chown=node:node /app/.next/static ./.next/static +COPY --from=build --chown=node:node /app/public ./public + +USER node +EXPOSE 3000 +CMD ["node", "server.js"] diff --git a/client/next.config.mjs b/client/next.config.mjs index 5442187..277ad24 100644 --- a/client/next.config.mjs +++ b/client/next.config.mjs @@ -7,7 +7,10 @@ const nextConfig = { reactStrictMode: true, turbopack: { root: '.' - } + }, + // Emits .next/standalone — a self-contained server bundling only the traced runtime dependencies, so + // the Docker image carries no node_modules tree. Harmless for `npm run dev`/`npm run build` locally. + output: 'standalone' }; export default withNextIntl(nextConfig); diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..30a63bb --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,21 @@ +# Balinyaar — the block to add to your EXISTING Caddyfile (the Caddy container that owns caddy_net). +# +# This is not loaded by anything in this repo; it is a copy of what DEPLOY.md tells you to paste, kept +# here so the reverse-proxy contract lives next to the compose file that depends on it. +# +# Both upstreams are plain HTTP on the container network — Caddy is the only TLS terminator, and it +# obtains/renews the certificates for both hostnames automatically. + +balinyaar.ir, www.balinyaar.ir { + encode zstd gzip + reverse_proxy balinyaar-web:3000 +} + +api.balinyaar.ir { + encode zstd gzip + + # The API partitions its rate limiter on the client IP resolved from X-Forwarded-For, and trusts the + # docker bridge ranges listed under ForwardedHeaders:KnownNetworks. Caddy sets X-Forwarded-For and + # X-Forwarded-Proto by default, so no extra header directives are needed here. + reverse_proxy balinyaar-api:8080 +} diff --git a/dev/post-phase/refinement/RUNBOOK.md b/dev/post-phase/refinement/RUNBOOK.md index 1436233..af33ec1 100644 --- a/dev/post-phase/refinement/RUNBOOK.md +++ b/dev/post-phase/refinement/RUNBOOK.md @@ -47,24 +47,29 @@ Give it ~20–30s on first start (`docker compose ps` shows `healthy`). > Already have a SQL Server? Skip this and point the connection string in step 3 at it instead. -### 3. Point the API at the local database (via user-secrets — never a committed file) +### 3. Point the API at a database -The committed `appsettings*.json` carry a **placeholder** connection string on purpose. Supply the real -local one through `dotnet user-secrets` so no working credential ever lands in git. From the API project: +**`dotnet user-secrets` is no longer used** — the `` was removed from +`Baya.Web.Api.csproj`, so that store isn't read at all. A leftover `secrets.json` on your machine is inert +and can be deleted. All configuration lives in +[`appsettings.Development.json`](../../../server/src/API/Baya.Web.Api/appsettings.Development.json), which +already points at the **shared remote database** the deployed demo also uses — so a fresh clone boots with +no configuration step at all. -```bash -cd server/src/API/Baya.Web.Api -dotnet user-secrets set "ConnectionStrings:SqlServer" "Server=localhost,1433;Database=Baya;User Id=sa;Password=Balinyaar_Dev1433;TrustServerCertificate=True;Encrypt=False;" +To work against the throwaway local container from step 2 instead, edit `ConnectionStrings:SqlServer` in +that file (the `Password` must match `MSSQL_SA_PASSWORD` in `server/docker-compose.yml`): + +```jsonc +"SqlServer": "Server=localhost,1433;Database=Baya;User Id=sa;Password=Balinyaar_Dev1433;TrustServerCertificate=True;Encrypt=False;" ``` -The `Password` must match `MSSQL_SA_PASSWORD` in `docker-compose.yml`. User-secrets auto-load only in the -Development environment, so this never affects a deployed build. - -> **Env-var alternative** (e.g. for CI/containers): set `ConnectionStrings__SqlServer` (double underscore = -> the `:` config separator) instead of using user-secrets. +> **Env-var alternative** (CI/containers, or to avoid a local edit showing up in `git status`): set +> `ConnectionStrings__SqlServer` (double underscore = the `:` config separator) — it overrides the file. > PowerShell: `$env:ConnectionStrings__SqlServer = "Server=localhost,1433;Database=Baya;User Id=sa;Password=Balinyaar_Dev1433;TrustServerCertificate=True;Encrypt=False;"` > bash: `export ConnectionStrings__SqlServer="Server=localhost,1433;Database=Baya;User Id=sa;Password=Balinyaar_Dev1433;TrustServerCertificate=True;Encrypt=False;"` +> Deploying rather than developing? See [DEPLOY.md](../../../DEPLOY.md). + --- ## Run it (two terminals) @@ -126,12 +131,10 @@ endpoint works). Use them to see the real path populated: | `09120000030` | partner-center owner | بهنام رستگار (male) | owns مرکز پرستاری آرامش (merchant-of-record, sponsors علی کریمی). No admin/nurse role — log in, then navigate to `/partner` manually (REQ-038: no `/me` partner signal yet) | > The old username+password `admin`/`qw123321` account is **no longer auto-seeded** (refinement-phase-5 — -> no committed credential). To bootstrap a break-glass username+password admin, set both secrets before boot, -> then log in via the API (not the web UI, which is phone-OTP only): -> ```bash -> cd server/src/API/Baya.Web.Api -> dotnet user-secrets set "Seed:AdminUsername" "admin" -> dotnet user-secrets set "Seed:AdminPassword" "" +> no committed credential). To bootstrap a break-glass username+password admin, add both keys to +> `appsettings.Development.json` before boot, then log in via the API (not the web UI, which is phone-OTP only): +> ```jsonc +> "Seed": { "AdminUsername": "admin", "AdminPassword": "" } > ``` The **phone-OTP admins** (`09120000020` / `09120000021`, refinement-phase-2) are how you reach the `/admin` @@ -179,11 +182,11 @@ code to every configured chat id, so it is a test-group convenience, not an SMS `api.telegram.org` is filtered in Iran, so set `TELEGRAM_PROXY_URL` in its `.env` to your VPN/proxy client (`http://127.0.0.1:10809`, `socks5://…`, or the proxy container on a VPS). The boot banner prints the bot's `@username` — that line appearing means the token *and* the proxy work. -2. **Share the secret with the API** — the same value on both sides (relay `.env` `API_KEY`, API user-secret): - ```bash - cd server/src/API/Baya.Web.Api - dotnet user-secrets set "Seams:Sms:Telegram:ApiKey" "" - ``` +2. **Share the secret with the API** — the same value on both sides: the relay's `.env` `API_KEY` and + `Seams:Sms:Telegram:ApiKey` in `appsettings.Development.json`. The appsettings side is already filled + in; copy that value into your local `telegram-otp-bot/.env`. Do **not** use the one in `.env.example` — + it is published in git, so `TelegramSmsSender` rejects it with + `Seams:Sms:Telegram:ApiKey is not configured (unset, or still the published example key)`. 3. **Flip the provider** in `server/src/API/Baya.Web.Api/appsettings.Development.json`: ```jsonc "Seams": { "Sms": { "Provider": "telegram" } } // committed default is "mock" @@ -202,10 +205,13 @@ error) rather than pretending an undelivered code was sent. - **The API speaks HTTP/1.1 and HTTP/2** (Kestrel `Protocols: Http1AndHttp2`, refinement-phase-5 — the previous HTTP/2-only default broke non-TLS HTTP/1.1 hops). Over TLS the client negotiates h2 via ALPN, so gRPC and `fetch` both work; plain-HTTP hops fall back to HTTP/1.1. -- **Secrets fail fast.** On a fresh clone with no user-secrets the API refuses to start with - `Refusing to start: required secret configuration is missing…` — set the connection-string user-secret - (step 3) and boot again. Deployed environments must additionally supply real `IdentitySettings` JWE keys - and `Seams:FieldEncryption` keys (Development uses dev-only defaults from `appsettings.Development.json`). +- **Secrets fail fast.** If `ConnectionStrings` is blank or still the base file's + `SET_VIA_USER_SECRETS_OR_ENV` placeholder, the API refuses to start with + `Refusing to start: required secret configuration is missing…`. Production/Staging must additionally supply + real `IdentitySettings` JWE keys and `Seams:FieldEncryption` keys; Development uses the dev-only ones in + `appsettings.Development.json`. +- **Never change `Seams:FieldEncryption:Key`/`:HashKey`.** They decrypt every PII column in the shared + database and derive `users.PhoneHash`, which every login looks up. Changing either locks everyone out. - **Enable the secret-scan pre-commit hook** once per clone so a stray credential can't be committed: `git config core.hooksPath .githooks` (see [`.githooks/README.md`](../../../.githooks/README.md)). - **Behind a reverse proxy**, list its address in `ForwardedHeaders:KnownProxies` (or a CIDR in @@ -243,7 +249,7 @@ world, wipe the volume (`docker compose down -v`) and boot again. | Symptom | Fix | | --- | --- | | Browser: `net::ERR_CERT_AUTHORITY_INVALID` on `:5002` | Run `dotnet dev-certs https --trust` (setup step 1). | -| API startup: `Refusing to start: required secret configuration is missing…` | The connection-string user-secret isn't set (or still the placeholder). Do setup step 3. | -| API startup: `Login failed for user 'sa'` / connect timeout | DB not up or wrong password — check `docker compose ps` and that the user-secrets password matches `docker-compose.yml`. | +| API startup: `Refusing to start: required secret configuration is missing…` | `ConnectionStrings:SqlServer` is blank or still a placeholder. Do setup step 3. | +| API startup: `Login failed for user 'sa'` / connect timeout | DB not up or wrong password — check `docker compose ps` and that the password in `appsettings.Development.json` matches `docker-compose.yml`. | | Console: `...has been blocked by CORS policy` | `UseCors` missing/mis-ordered, or the browser origin isn't in `Cors:AllowedOrigins`. It must sit after `UseRouting` and before the rate limiter. | -| `dotnet user-secrets` errors with "could not find UserSecretsId" | Run it from `server/src/API/Baya.Web.Api` (the project with ``). | +| `dotnet user-secrets` errors with "could not find UserSecretsId" | Expected — user-secrets was removed. Edit `appsettings.Development.json` instead. | diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9c32656 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,91 @@ +# Balinyaar — full stack for the balinyaar.ir deployment. +# +# Three containers, no published ports: everything is reached through the EXISTING Caddy on `caddy_net`, +# which terminates TLS for balinyaar.ir (→ web) and api.balinyaar.ir (→ api). See DEPLOY.md for the +# Caddyfile block to add, and deploy/Caddyfile for a copy of it. +# +# The database is NOT here — it is the remote SQL Server already configured in +# server/src/API/Baya.Web.Api/appsettings.Development.json. +# +# docker compose up -d --build +# docker compose logs -f api + +services: + api: + build: + context: ./server + image: balinyaar-api + container_name: balinyaar-api + restart: unless-stopped + depends_on: + - otp-relay + environment: + # Deliberate: the deployed API runs as Development so the demo + lifecycle seeders populate the + # shared database and the screens aren't empty. This also exposes the developer exception page and + # GET /api/v1/dev/last_otp/{phone} publicly — acceptable for a pre-launch demo, NOT for real users. + # Switch to Production (and supply non-placeholder crypto keys) before launch — see DEPLOY.md. + ASPNETCORE_ENVIRONMENT: Development + + # The two values that genuinely differ between a laptop and this network. Everything else — crypto + # keys, connection strings, CORS origins, trusted proxy networks — lives in appsettings.Development.json. + Seams__Sms__Telegram__BaseUrl: http://balinyaar-otp-relay:5010 + Seams__ObjectStorage__RootPath: /app/data/object-storage + volumes: + # Uploaded verification documents live on the local-disk object-storage seam; without this they are + # inside the container and vanish on the next `up --build`. + - api-object-storage:/app/data/object-storage + - api-logs:/app/logs + networks: + - caddy_net + + web: + build: + context: ./client + image: balinyaar-web + container_name: balinyaar-web + restart: unless-stopped + # No environment here on purpose: every NEXT_PUBLIC_* value is compiled into the browser bundle at + # build time from client/.env.production. Setting one here would be silently ignored. + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + networks: + - caddy_net + + otp-relay: + build: + context: ./telegram-otp-bot + image: balinyaar-otp-relay + container_name: balinyaar-otp-relay + restart: unless-stopped + environment: + TELEGRAM_BOT_TOKEN: "8968527151:AAFiCuNGkXjOiLZfT6urU8tkW8SCsWDM0ic" + # Every id here receives EVERY login code, for every phone number. Keep it to people you trust. + TELEGRAM_CHAT_IDS: "1277103616,110209855" + # Must equal Seams:Sms:Telegram:ApiKey in the API's appsettings.Development.json. + API_KEY: "6a8dfaeea1aa375eb61da7663cadf23a3ea245fd939264dd" + # api.telegram.org is filtered in Iran — this hop goes out through the proxy container that already + # sits on caddy_net. A wrong value fails at boot with a clear message rather than per-OTP. + TELEGRAM_PROXY_URL: http://hysteria-client:8081 + # The code still arrives on Telegram; keeping it out of `docker logs` means a host-log reader can't + # harvest login codes. + REDACT_CODE_IN_LOGS: "true" + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:5010/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + networks: + - caddy_net + +volumes: + api-object-storage: + api-logs: + +networks: + caddy_net: + external: true diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000..a2ba7b2 --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1,18 @@ +# Build artefacts — copying them in poisons the container's restore/publish with host-built binaries. +**/bin/ +**/obj/ +**/logs/ +**/.vs/ +**/graphify-out/ + +# Never needed by the image +**/*.user +**/*.suo +Dockerfile +.dockerignore +docker-compose.yml +CLAUDE.md +CONVENTIONS.md +AGENTS.md +README.md +LICENSE.md diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 4182bd8..d167554 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -67,8 +67,10 @@ Persistence below). **In deployed environments**, boot instead only *checks* the reachable SQL Server is required to start. Startup **fails fast** (`StartupSecretsGuard`) if a load-bearing secret — the DB connection strings, and in deployed environments the JWE + field-encryption keys — is missing or left at its committed `SET_VIA_USER_SECRETS_OR_ENV` placeholder -(refinement-phase-5). Development supplies working dev-only crypto keys via `appsettings.Development.json`; only -the connection string must come from user-secrets (see [RUNBOOK](../dev/post-phase/refinement/RUNBOOK.md)). +(refinement-phase-5). **`dotnet user-secrets` is no longer used** — the `` was removed from +`Baya.Web.Api.csproj`, so that store is not read at all. Every value, connection strings and dev-only crypto keys +alike, lives in `appsettings.Development.json`; the deployment's two container-specific overrides live in the root +`docker-compose.yml` (see [DEPLOY.md](../DEPLOY.md)). --- @@ -128,11 +130,10 @@ stamped by `AuditFieldInterceptor` (Persistence), not in handlers. **real HTTP adapter** in `Baya.Infrastructure.CrossCutting/Seams/Real/`, **config-selected** by a per-rail `Seams:*:Provider` selector in `AddCrossCuttingSeams` (default = the mock, so an unconfigured env is unchanged; a typo falls closed to the mock). Real adapters use `HttpClient` (typed via `IHttpClientFactory`) + -`System.Text.Json` + BCL crypto — **no new NuGet packages**; credentials come from `Seams:*` (user-secrets/env, -never committed). Swapping is a registration change; **no handler is touched**. The adapters: +`System.Text.Json` + BCL crypto — **no new NuGet packages**; credentials come from `Seams:*` (appsettings/env). Swapping is a registration change; **no handler is touched**. The adapters: `KavenegarSmsSender` (`Sms:Provider=kavenegar` — **launch-critical**; when a real gateway is selected the Development OTP-in-logs bridge is **disabled**, so the OTP is never logged), `TelegramSmsSender` -(`Sms:Provider=telegram` — **Development-only, broadcast, not a gateway**: it posts to the standalone +(`Sms:Provider=telegram` — **broadcast, not a gateway**; the pre-launch demo OTP rail: it posts to the standalone `telegram-otp-bot/` relay, which pushes *every* code to a fixed list of Telegram chat ids, so manual testing beats reading OTPs out of the log. It is the **one non-mock SMS provider that keeps the OTP-capture bridge enabled** — see Startup wiring — and its `Seams:Sms:Telegram:ApiKey` is a user-secret, never committed), @@ -733,9 +734,9 @@ action to `sender.Send(...)`. Full conventions are in [CONVENTIONS.md](CONVENTIO - Dynamic permission system: `DynamicPermissionHandler` reads `[controller]` + `[action]` route values and checks role claims. Always use `[controller]`/`[action]` tokens so the keys stay consistent (see CONVENTIONS.md §1 Routing). -- Settings bound from `appsettings.json` → `IdentitySettings`. **JWE keys are never committed**: the - committed values are `SET_VIA_USER_SECRETS_OR_ENV` placeholders (real ones via user-secrets/env; Development - uses dev-only keys in `appsettings.Development.json`). `RequireHttpsMetadata` is **on outside Dev/Testing** +- Settings bound from `appsettings.json` → `IdentitySettings`. The base `appsettings.json` carries + `SET_VIA_USER_SECRETS_OR_ENV` placeholders that `StartupSecretsGuard` rejects; the real values live in the + environment-specific file (`appsettings.Development.json` holds the dev-only keys the demo deployment runs on). `RequireHttpsMetadata` is **on outside Dev/Testing** (passed into `RegisterIdentityServices`), the access-token lifetime is `ExpirationMinutes: 60`, and `Issuer`/`Audience` are real (`Balinyaar`/`BalinyaarClient`) — refinement-phase-5. - Auth and OTP endpoints must be rate-limited (CONVENTIONS.md §11) — `request_otp`/`verify_otp` use diff --git a/server/CONVENTIONS.md b/server/CONVENTIONS.md index 970d8e8..9feccc0 100644 --- a/server/CONVENTIONS.md +++ b/server/CONVENTIONS.md @@ -477,8 +477,8 @@ Place these tests in a dedicated `Baya.Test.Api` project so they can run against ## 11. Security rules -- **Never hardcode secrets.** Keys, connection strings, and tokens come from `appsettings.*.json` / user-secrets / environment variables, bound to typed settings classes. -- `SecretKey` and `Encryptkey` (in `IdentitySettings`) must be set in environment-specific config, never in `appsettings.json` committed to the repo. +- **Never hardcode secrets in C#.** Keys, connection strings, and tokens come from `appsettings.*.json` or environment variables, bound to typed settings classes — never a literal in a handler or service. (`dotnet user-secrets` is not used; see [DEPLOY.md](../DEPLOY.md) for the configuration model.) +- `SecretKey` and `Encryptkey` (in `IdentitySettings`) belong in the environment-specific file, never in the base `appsettings.json`, which stays at its `StartupSecretsGuard`-rejected placeholder. - Always validate all external input with FluentValidation before processing. - EF Core parameterizes queries automatically — never concatenate raw SQL. - If you must use raw SQL, use `FromSqlInterpolated` (parameterized), never `FromSqlRaw` with user data. diff --git a/server/Dockerfile b/server/Dockerfile index f3c2dc5..9d9115c 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,36 +1,40 @@ -#See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging. - -FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base -WORKDIR /app -EXPOSE 80 -EXPOSE 443 +# Balinyaar API — build context is `server/` (see the root docker-compose.yml). +# +# The csproj/props files are copied on their own first so `dotnet restore` lands in a layer that only +# re-runs when a project reference or package version actually changes; the source copy below it churns +# on every commit. Directory.Packages.props is the central version manifest — restore fails without it. FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src -COPY ["../Directory.Packages.props", "./"] -COPY ["src/API/Baya.Web.Api/Baya.Web.Api.csproj", "src/API/Baya.Web.Api/"] -COPY ["src/API/Baya.WebFramework/Baya.WebFramework.csproj", "src/API/Baya.WebFramework/"] -COPY ["src/API/Plugins/Baya.Web.Plugins.Grpc/Baya.Web.Plugins.Grpc.csproj", "src/API/Plugins/Baya.Web.Plugins.Grpc/"] -COPY ["src/Core/Baya.Application/Baya.Application.csproj", "src/Core/Baya.Application/"] -COPY ["src/Core/Baya.Domain/Baya.Domain.csproj", "src/Core/Baya.Domain/"] -COPY ["src/Infrastructure/Baya.Infrastructure.CrossCutting/Baya.Infrastructure.CrossCutting.csproj", "src/Infrastructure/Baya.Infrastructure.CrossCutting/"] -COPY ["src/Infrastructure/Baya.Infrastructure.Identity/Baya.Infrastructure.Identity.csproj", "src/Infrastructure/Baya.Infrastructure.Identity/"] -COPY ["src/Infrastructure/Baya.Infrastructure.Persistence/Baya.Infrastructure.Persistence.csproj", "src/Infrastructure/Baya.Infrastructure.Persistence/"] -COPY ["src/Infrastructure/Baya.Infrastructure.Monitoring/Baya.Infrastructure.Monitoring.csproj", "src/Infrastructure/Baya.Infrastructure.Monitoring/"] -COPY ["src/Shared/Baya.SharedKernel/Baya.SharedKernel.csproj", "src/Shared/Baya.SharedKernel/"] -COPY ["src/Tests/Baya.Test.Infrastructure.Identity/Baya.Test.Infrastructure.Identity/Baya.Test.Infrastructure.Identity.csproj", "src/Tests/Baya.Test.Infrastructure.Identity/Baya.Test.Infrastructure.Identity/"] -COPY ["src/Tests/Baya.Tests.Setup/Baya.Tests.Setup.csproj", "src/Tests/Baya.Tests.Setup/"] +COPY Directory.Packages.props ./ +COPY src/API/Baya.Web.Api/Baya.Web.Api.csproj src/API/Baya.Web.Api/ +COPY src/API/Baya.WebFramework/Baya.WebFramework.csproj src/API/Baya.WebFramework/ +COPY src/API/Plugins/Baya.Web.Plugins.Grpc/Baya.Web.Plugins.Grpc.csproj src/API/Plugins/Baya.Web.Plugins.Grpc/ +COPY src/Core/Baya.Application/Baya.Application.csproj src/Core/Baya.Application/ +COPY src/Core/Baya.Domain/Baya.Domain.csproj src/Core/Baya.Domain/ +COPY src/Infrastructure/Baya.Infrastructure.CrossCutting/Baya.Infrastructure.CrossCutting.csproj src/Infrastructure/Baya.Infrastructure.CrossCutting/ +COPY src/Infrastructure/Baya.Infrastructure.Identity/Baya.Infrastructure.Identity.csproj src/Infrastructure/Baya.Infrastructure.Identity/ +COPY src/Infrastructure/Baya.Infrastructure.Monitoring/Baya.Infrastructure.Monitoring.csproj src/Infrastructure/Baya.Infrastructure.Monitoring/ +COPY src/Infrastructure/Baya.Infrastructure.Persistence/Baya.Infrastructure.Persistence.csproj src/Infrastructure/Baya.Infrastructure.Persistence/ +COPY src/Shared/Baya.SharedKernel/Baya.SharedKernel.csproj src/Shared/Baya.SharedKernel/ + +RUN dotnet restore src/API/Baya.Web.Api/Baya.Web.Api.csproj -RUN dotnet restore "src/API/Baya.Web.Api/Baya.Web.Api.csproj" COPY . . -WORKDIR "src/API/Baya.Web.Api" -RUN dotnet build "Baya.Web.Api.csproj" -c Release -o /app/build +RUN dotnet publish src/API/Baya.Web.Api/Baya.Web.Api.csproj \ + -c Release -o /app/publish /p:UseAppHost=false --no-restore -FROM build AS publish -RUN dotnet publish "Baya.Web.Api.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore - -FROM base AS final +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final WORKDIR /app -COPY --from=publish /app/publish . +COPY --from=build /app/publish . + +# TLS is terminated by Caddy; the API speaks plain HTTP on the shared container network only. +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 + +# Written to by the Serilog file sink (Development) and the local-disk object storage seam — both are +# bind-mounted in compose so an image rebuild doesn't discard uploaded verification documents. +RUN mkdir -p /app/logs /app/data/object-storage + ENTRYPOINT ["dotnet", "Baya.Web.Api.dll"] diff --git a/server/src/API/Baya.Web.Api/Baya.Web.Api.csproj b/server/src/API/Baya.Web.Api/Baya.Web.Api.csproj index 95b4f3d..561f463 100644 --- a/server/src/API/Baya.Web.Api/Baya.Web.Api.csproj +++ b/server/src/API/Baya.Web.Api/Baya.Web.Api.csproj @@ -6,8 +6,6 @@ true true $(NoWarn);1591 - - baya-web-api diff --git a/server/src/API/Baya.Web.Api/Configuration/StartupSecretsGuard.cs b/server/src/API/Baya.Web.Api/Configuration/StartupSecretsGuard.cs index a1b727e..a0604fd 100644 --- a/server/src/API/Baya.Web.Api/Configuration/StartupSecretsGuard.cs +++ b/server/src/API/Baya.Web.Api/Configuration/StartupSecretsGuard.cs @@ -9,9 +9,9 @@ namespace Baya.Web.Api.Configuration; /// A database connection is required in every real environment; the JWE + field-encryption keys are /// required only in deployed environments (Development keeps working dev-only defaults in /// appsettings.Development.json, and the "Testing" environment runs on in-memory SQLite with -/// test-injected keys). The effect: a fresh clone with no user-secrets stops at boot with a clear -/// message instead of silently connecting somewhere unintended, and a deployment can never fall back -/// to a committed placeholder key. +/// test-injected keys). The effect: a clone whose configuration was never filled in stops at boot with +/// a clear message instead of silently connecting somewhere unintended, and a Production/Staging +/// deployment can never fall back to a committed placeholder or a dev-only key. /// public static class StartupSecretsGuard { @@ -39,7 +39,7 @@ public static class StartupSecretsGuard RequireReal(errors, "ConnectionStrings:logDb", config.GetConnectionString("logDb")); // Development supplies working dev-only keys via appsettings.Development.json; only deployed - // environments must inject real per-environment secrets (env vars / Key Vault / KMS). + // environments must supply real per-environment secrets. if (!builder.Environment.IsDevelopment()) { RequireReal(errors, "IdentitySettings:SecretKey", config["IdentitySettings:SecretKey"]); @@ -53,8 +53,8 @@ public static class StartupSecretsGuard throw new InvalidOperationException( "Refusing to start: required secret configuration is missing or still a committed placeholder. " + - "Provide real values via user-secrets (Development) or environment variables (deployed) — see " + - "dev/post-phase/refinement/RUNBOOK.md.\n - " + string.Join("\n - ", errors)); + "Provide real values in appsettings..json (or as Seams__…-style environment " + + "variables) — see DEPLOY.md.\n - " + string.Join("\n - ", errors)); } private static void RequireReal(List errors, string key, string? value) diff --git a/server/src/API/Baya.Web.Api/appsettings.Development.json b/server/src/API/Baya.Web.Api/appsettings.Development.json index 5c52be3..057d9d7 100644 --- a/server/src/API/Baya.Web.Api/appsettings.Development.json +++ b/server/src/API/Baya.Web.Api/appsettings.Development.json @@ -1,11 +1,11 @@ { "ConnectionStrings": { "SqlServer": "Server=87.107.152.16,1433;Database=Baya;User Id=hamid_root_un_sa;Password=N8@s5Taw1zWeh@#Hm;TrustServerCertificate=True;Encrypt=False;", - "logDb":"Server=87.107.152.16,1433;Database=Baya_Logs;User Id=hamid_root_un_sa;Password=N8@s5Taw1zWeh@#Hm;TrustServerCertificate=True;Encrypt=False;" + "logDb": "Server=87.107.152.16,1433;Database=Baya_Logs;User Id=hamid_root_un_sa;Password=N8@s5Taw1zWeh@#Hm;TrustServerCertificate=True;Encrypt=False;" }, "IdentitySettings": { - "SecretKey": "SET_VIA_USER_SECRETS_OR_ENV", - "Encryptkey": "SET_VIA_USER_SECRETS_OR_ENV", + "SecretKey": "dev-only-jwe-signing-key-not-for-production-0123456789abcdef", + "Encryptkey": "dev-only-16bytes", "Issuer": "Balinyaar", "Audience": "BalinyaarClient", "NotBeforeMinutes": "0", @@ -13,8 +13,9 @@ }, "Seams": { "FieldEncryption": { - "Key": "SET_VIA_USER_SECRETS_OR_ENV", - "HashKey": "SET_VIA_USER_SECRETS_OR_ENV" + "//": "DO NOT CHANGE. Every encrypted column in the Baya database (phones, addresses, IBANs, clinical notes) was written with these 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.", + "Key": "local-dev-field-encryption-key-not-for-production", + "HashKey": "local-dev-field-hash-key-not-for-production" }, "ObjectStorage": { "RootPath": "" @@ -23,7 +24,8 @@ "Provider": "telegram", "Telegram": { "BaseUrl": "http://127.0.0.1:5010", - "ApiKey": "ab8984974bc1fe5ce514d0fd74f71c8738b3aed92a7e4d86", + "//": "Must equal the relay's API_KEY. NOT the value in telegram-otp-bot/.env.example — that one is published, so TelegramSmsSender rejects it.", + "ApiKey": "6a8dfaeea1aa375eb61da7663cadf23a3ea245fd939264dd", "TimeoutSeconds": 10 } }, @@ -34,11 +36,20 @@ } }, "Cors": { - "AllowedOrigins": [] + "AllowedOrigins": [ + "https://balinyaar.ir", + "https://www.balinyaar.ir", + "http://localhost:3000" + ] }, "ForwardedHeaders": { + "//": "Docker bridge ranges — the reverse proxy (Caddy) shares a container network with the API, so its hop must be trusted for X-Forwarded-For to resolve the real client IP the rate limiter partitions on.", "KnownProxies": [], - "KnownNetworks": [] + "KnownNetworks": [ + "172.16.0.0/12", + "192.168.0.0/16", + "10.0.0.0/8" + ] }, "AllowedHosts": "*", "Kestrel": { diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/TelegramSmsSender.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/TelegramSmsSender.cs index 6222d63..d77c477 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/TelegramSmsSender.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/Real/TelegramSmsSender.cs @@ -13,9 +13,11 @@ namespace Baya.Infrastructure.CrossCutting.Seams.Real; /// single JSON call: POST /send_otp and POST /send, authenticated with the shared /// X-Api-Key secret. /// -/// Development only. The relay broadcasts every message to a fixed list of Telegram chat ids — every -/// recipient reads every code. It exists so manual testing beats reading OTPs out of the server log; it is not a -/// gateway and must never be selected in a deployed environment (see ). +/// Broadcast, not per-user routing. The relay sends every message to a fixed list of Telegram chat +/// ids — every recipient reads every code, whichever phone requested it. That makes it a shared-inbox channel for +/// a small trusted group, not an SMS gateway: it is the deliberate OTP rail for the pre-launch demo deployment +/// (no Iranian gateway contract yet), and must be replaced by before real +/// customers sign up (see ). /// /// The OTP is never logged — only the phone tail and the relay's HTTP outcome, exactly like /// . A non-2xx (the relay answers 502 when no recipient got the @@ -27,7 +29,9 @@ public sealed class TelegramSmsSender( IOptions options, ILogger logger) : ISmsSender { - /// The repo's committed stand-in for a secret — treated as "not configured". + /// The example key published in telegram-otp-bot/.env.example and its README. Anyone + /// reading the repo knows it, so it is a documentation sample, not a secret — treated as "not configured" + /// so a deployment can never quietly authenticate the OTP rail with a publicly-known value. private const string SecretPlaceholder = "ab8984974bc1fe5ce514d0fd74f71c8738b3aed92a7e4d86"; private readonly TelegramOptions _options = options.Value.Sms.Telegram; @@ -43,11 +47,12 @@ public sealed class TelegramSmsSender( // Fail with the config key rather than sending an unauthenticated request the relay answers with a bare // 401 — the cause of that 401 is invisible from this side. var apiKey = _options.ApiKey; - if (string.IsNullOrWhiteSpace(apiKey)) + if (string.IsNullOrWhiteSpace(apiKey) || apiKey == SecretPlaceholder) { throw new InvalidOperationException( - "Seams:Sms:Telegram:ApiKey is not configured. Set it (user-secrets or environment) to the same " + - "value as the relay's API_KEY, or select another Seams:Sms:Provider."); + "Seams:Sms:Telegram:ApiKey is not configured (unset, or still the published example key). Set it " + + "(appsettings or environment) to the same value as the relay's API_KEY, or select another " + + "Seams:Sms:Provider."); } using var request = new HttpRequestMessage(HttpMethod.Post, path) diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs index 68f5047..59510d5 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SeamOptions.cs @@ -2,7 +2,7 @@ namespace Baya.Infrastructure.CrossCutting.Seams; /// /// Options bound from the Seams configuration section. The mock seams read non-secret defaults -/// from here; production keys/paths come from environment variables or user-secrets, never committed. +/// from here; deployed keys/paths come from the environment-specific appsettings file or environment variables. /// /// Provider selection (refinement-phase-8). Each vendor rail carries a Provider selector /// (default = the mock, so an unconfigured environment behaves exactly as before). Setting it to a real @@ -37,7 +37,7 @@ public sealed class SeamOptions /// (IShahkarVerifier), e-KYC (IIdentityKycProvider), and استعلام شبا /// (IBankAccountOwnershipVerifier). Each seam opts in with its own Provider = finnotech selector, /// but they authenticate against the same tenant, so the connection facts live here once. All values are -/// secrets — user-secrets / environment, never committed. +/// secrets — the environment-specific appsettings file or environment variables. /// public sealed class FinnotechOptions { @@ -63,8 +63,9 @@ public static class SeamProviders public const string SmsIr = "smsir"; public const string Ghasedak = "ghasedak"; - /// Development convenience channel, not an SMS gateway — the local telegram-otp-bot/ - /// relay broadcasts every code to a fixed list of Telegram chat ids. Never select it in a real environment. + /// Broadcast relay, not an SMS gateway — the standalone telegram-otp-bot/ service + /// sends every code to a fixed list of Telegram chat ids. The pre-launch demo rail; replace with a real + /// gateway before onboarding customers outside the trusted group. public const string Telegram = "telegram"; // Object storage @@ -105,7 +106,7 @@ public sealed class SmsOptions /// (Development only). public string Provider { get; set; } = SeamProviders.Mock; - /// Gateway API key / token (secret — user-secrets or environment, never committed). + /// Gateway API key / token (secret — the environment-specific appsettings file or environment variables). public string ApiKey { get; set; } = string.Empty; /// The registered sender line (used by SendAsync free-form messages and non-template sends). @@ -122,13 +123,15 @@ public sealed class SmsOptions } /// -/// The Development-only Telegram OTP relay (the standalone telegram-otp-bot/ Node service), -/// selected by Seams:Sms:Provider = telegram. It replaces "read the OTP out of the server log" during -/// manual testing — the tester gets the code on their phone without paying an Iranian SMS gateway. +/// The Telegram OTP relay (the standalone telegram-otp-bot/ Node service), selected by +/// Seams:Sms:Provider = telegram. It replaces "read the OTP out of the server log" — the tester gets the +/// code on their phone without an Iranian SMS gateway contract. /// /// It is not an SMS gateway. There is no per-user routing: the relay broadcasts every code -/// to a fixed list of Telegram chat ids, so every configured recipient reads every login code. That is fine for -/// a test group and disqualifying for anything else — never point a deployed environment at it. +/// to a fixed list of Telegram chat ids, so every configured recipient reads every login code. That is workable +/// for a trusted demo group — which is why the pre-launch balinyaar.ir deployment uses it — and +/// disqualifying once anyone outside that group can request a code. Switch Seams:Sms:Provider to +/// kavenegar at that point; nothing else changes. /// public sealed class TelegramOptions { @@ -137,7 +140,7 @@ public sealed class TelegramOptions /// The shared secret sent as the relay's X-Api-Key header — it must equal the relay's /// API_KEY. Secret: committed config carries an empty/placeholder value; the real one comes - /// from user-secrets (Seams:Sms:Telegram:ApiKey) or the environment, never git. + /// from Seams:Sms:Telegram:ApiKey in appsettings or the environment. public string ApiKey { get; set; } = string.Empty; /// Per-request timeout. The relay itself talks to Telegram (over a proxy in a filtered region), so diff --git a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs index 3e85a56..dba16f6 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs @@ -18,7 +18,7 @@ public static class ServiceCollectionExtension /// token swaps in the real HTTP adapter behind the same Application contract — callers never change. An /// unconfigured/typo'd provider falls closed to the mock. This makes a partial rollout the normal case (real SMS /// + real geocoder while payments stay mocked in a pre-launch environment). Real adapters read credentials from - /// Seams:* (user-secrets/environment) and get an from the + /// Seams:* (appsettings/environment) and get an from the /// IHttpClientFactory. (The real in-app INotificationDispatcher needs the database, so it is /// registered in the Persistence layer.) /// diff --git a/server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/SeedDatabaseService/SeedDataBase.cs b/server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/SeedDatabaseService/SeedDataBase.cs index 71d66d0..f1175e2 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/SeedDatabaseService/SeedDataBase.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/SeedDatabaseService/SeedDataBase.cs @@ -42,7 +42,7 @@ public class SeedDataBase : ISeedDataBase } // The bootstrap admin is config-driven, never a committed credential: it is created only when both - // Seed:AdminUsername and Seed:AdminPassword are supplied (via user-secrets in Development, environment + // Seed:AdminUsername and Seed:AdminPassword are supplied (via the environment-specific appsettings file, environment // variables in a deployment). With neither configured — the default for Testing and any fresh boot — // no admin account is created, so no well-known password ever lands in a real database. Day-to-day // admins reach the backoffice through the phone-OTP demo seeds (Development) or are provisioned diff --git a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs index d6ac101..db40128 100644 --- a/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs +++ b/server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs @@ -1,4 +1,4 @@ -using Baya.Application.Contracts.Analytics; +using Baya.Application.Contracts.Analytics; using Baya.Application.Contracts.Audit; using Baya.Application.Contracts.Common; using Baya.Application.Contracts.Configuration; @@ -129,7 +129,7 @@ public static class ServiceCollectionExtensions /// /// Idempotently seeds one active standard payment gateway so the b10 card rail has a selectable /// provider out of the box. config_json is encrypted at rest by the EF converter on save (so it - /// must go through the DbContext, not HasData). Real merchant credentials come from user-secrets / + /// must go through the DbContext, not HasData). Real merchant credentials come from appsettings / /// environment per deployment — this sandbox row is non-secret and only enables the local/dev flow. /// public static async Task SeedPaymentGatewaysAsync(this WebApplication app) diff --git a/server/src/Tests/Baya.Test.Api/BayaApiFactory.cs b/server/src/Tests/Baya.Test.Api/BayaApiFactory.cs index 8de1196..5e732ad 100644 --- a/server/src/Tests/Baya.Test.Api/BayaApiFactory.cs +++ b/server/src/Tests/Baya.Test.Api/BayaApiFactory.cs @@ -34,7 +34,7 @@ public sealed class BayaApiFactory : WebApplicationFactory _keepAlive = new SqliteConnection(_connectionString); _keepAlive.Open(); - // The committed appsettings.json ships placeholder JWE keys (real ones come from user-secrets / + // The committed appsettings.json ships placeholder JWE keys (real ones come from the environment-specific appsettings file / // env in Development / deploy). The Testing host has neither, so supply working test keys via // environment variables — they sit after appsettings.json in the default config chain, so they // reliably override the placeholders. The Encrypt key must be exactly 16 bytes for the AES-128 diff --git a/telegram-otp-bot/.dockerignore b/telegram-otp-bot/.dockerignore new file mode 100644 index 0000000..4f6d1d3 --- /dev/null +++ b/telegram-otp-bot/.dockerignore @@ -0,0 +1,12 @@ +node_modules +*.log + +# Local secrets — the deployed values come from compose, and a copied .env would silently win on any +# key the compose environment doesn't set. +.env +.env.local + +Dockerfile +.dockerignore +README.md +INTEGRATION-PROMPT.md diff --git a/telegram-otp-bot/.env.example b/telegram-otp-bot/.env.example index f38bb2d..8f3f859 100644 --- a/telegram-otp-bot/.env.example +++ b/telegram-otp-bot/.env.example @@ -1,4 +1,6 @@ -# Copy to .env and fill in. Never commit .env. +# Copy to .env and fill in — for LOCAL runs (`npm start`) only. Never commit .env. +# The deployed stack sets every one of these in the root docker-compose.yml instead; nothing here is read +# inside the container. # From @BotFather — the full token, e.g. 1234567890:AAH.... TELEGRAM_BOT_TOKEN=8968527151:AAFiCuNGkXjOiLZfT6urU8tkW8SCsWDM0ic @@ -12,7 +14,10 @@ TELEGRAM_CHAT_IDS=1277103616,110209855 # REQUIRED. Shared secret the caller must send as the `X-Api-Key` header. # Minimum 16 chars; the process refuses to start without it. # Generate one: node -e "console.log(require('crypto').randomBytes(24).toString('hex'))" -# The same value goes into the .NET side's Seams:Sms:Telegram:ApiKey (user-secrets, never committed). +# The same value goes into the .NET side's Seams:Sms:Telegram:ApiKey (appsettings.Development.json). +# +# The value below is the PUBLISHED EXAMPLE — it is in git and in the README, so it is not a secret, and +# TelegramSmsSender deliberately refuses to authenticate with it. Replace it in your own .env. API_KEY=ab8984974bc1fe5ce514d0fd74f71c8738b3aed92a7e4d86 # HTTP listener @@ -27,6 +32,7 @@ REDACT_CODE_IN_LOGS=false # this process tunnels its Telegram calls through it (HTTP CONNECT or SOCKS5, with optional # user:pass@ credentials). Leave it unset for a direct connection — nothing else changes. # local machine with a VPN client: http://127.0.0.1:10809 / socks5://127.0.0.1:10808 -# VPS with a proxy container: http://proxy:1080 (the container name on the shared docker network) +# VPS with a proxy container: http://hysteria-client:8081 (the container name on caddy_net — +# what the deployed stack uses, set in the root docker-compose.yml) # HTTPS_PROXY / ALL_PROXY are honoured as a fallback if TELEGRAM_PROXY_URL is unset. # TELEGRAM_PROXY_URL=http://127.0.0.1:10809 diff --git a/telegram-otp-bot/Dockerfile b/telegram-otp-bot/Dockerfile new file mode 100644 index 0000000..0b48daa --- /dev/null +++ b/telegram-otp-bot/Dockerfile @@ -0,0 +1,20 @@ +# Balinyaar Telegram OTP relay — build context is `telegram-otp-bot/`. +# +# Zero dependencies (Node built-ins only), so there is no install step and no build stage: the image is +# the base runtime plus four source files. + +FROM node:22-alpine +WORKDIR /app + +COPY package.json ./ +COPY src ./src + +# The relay must accept connections from the API container, not just its own loopback. Everything else +# (bot token, chat ids, API key, proxy URL) is supplied by compose — src/env.js lets real environment +# variables win over any .env file, so nothing here depends on one existing. +ENV HOST=0.0.0.0 +ENV PORT=5010 + +USER node +EXPOSE 5010 +CMD ["node", "src/server.js"] diff --git a/telegram-otp-bot/README.md b/telegram-otp-bot/README.md index abc5078..abf92d0 100644 --- a/telegram-otp-bot/README.md +++ b/telegram-otp-bot/README.md @@ -1,15 +1,17 @@ # balinyaar-telegram-otp-bot -A **standalone, dev-only** Telegram relay. It is not part of `client/` or `server/` — it is its own -tiny Node project with **zero dependencies** (Node 18+ built-ins only: `node:http` + global `fetch`). +A **standalone** Telegram relay. It is not part of `client/` or `server/` — it is its own tiny Node +project with **zero dependencies** (Node 18+ built-ins only: `node:http` + global `fetch`). Its whole job: expose an HTTP endpoint that the .NET API calls, and forward the message to a fixed -list of Telegram chat ids. That replaces "read the OTP out of the server log" during manual testing — -you get the code on your phone instead, without paying an Iranian SMS gateway. +list of Telegram chat ids. That replaces "read the OTP out of the server log" — you get the code on +your phone instead, without an Iranian SMS gateway contract. -> **Development only.** There is no per-user routing: *every* configured recipient receives *every* -> OTP, regardless of which phone number requested it. That is fine for a test group; it is not an SMS -> gateway. Do not point a real environment at this. +> **Broadcast, not routing.** *Every* configured recipient receives *every* OTP, regardless of which +> phone number requested it. That makes this a shared inbox for a small trusted group, not an SMS +> gateway. It is the OTP rail for local development **and** for the pre-launch `balinyaar.ir` demo +> deployment — it must be swapped for `Seams:Sms:Provider = kavenegar` before anyone outside that +> trusted group can request a code. --- @@ -93,7 +95,7 @@ The two POST routes mirror the server's `ISmsSender` (`SendOtpAsync` / `SendAsyn | `API_KEY` | — | **Required**, min 16 chars. Shared secret expected in `X-Api-Key`. Process exits without it. | | `TELEGRAM_CHAT_IDS` | — | Comma-separated recipient chat ids. Empty ⇒ every send returns `503`. | | `PORT` | `5010` | HTTP port. | -| `HOST` | `127.0.0.1` | Bind address. Keep it loopback unless the API runs on another machine. | +| `HOST` | `127.0.0.1` | Bind address. Loopback locally; the Dockerfile sets `0.0.0.0` so the API container can reach it. | | `REDACT_CODE_IN_LOGS` | `false` | Keep the code out of *this process's* stdout (still delivered). | | `TELEGRAM_PROXY_URL` | — | Optional outbound proxy for the Telegram hop — see below. | @@ -101,7 +103,16 @@ The API key is the only access control — there is no IP allow-list and no TLS. loopback when the API runs on the same machine; if you must expose it, put it behind something that terminates TLS, or the key travels in clear text. -Values come from `.env` (git-ignored) or from real environment variables, which take precedence. +Values come from `.env` (git-ignored) or from real environment variables, which take precedence. In the +deployed stack there is no `.env` at all — the root `docker-compose.yml` supplies every variable directly +(and `.dockerignore` keeps a local `.env` out of the image, so it can't silently win). + +## Running in Docker + +The [`Dockerfile`](Dockerfile) here is built by the root [`docker-compose.yml`](../docker-compose.yml) as +the `otp-relay` service. Nothing is published to the host: the API reaches it as +`http://balinyaar-otp-relay:5010` over the shared `caddy_net` network, and its own hop to Telegram goes +through the proxy container on that same network. See [DEPLOY.md](../DEPLOY.md). ## Troubleshooting