remove user-secrets approach & prepare a pilot deploy

This commit is contained in:
hamid
2026-07-28 23:18:54 +03:30
parent 630c7907ec
commit 5885280b49
28 changed files with 639 additions and 142 deletions
+13 -6
View File
@@ -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.
+28 -8
View File
@@ -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'"
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}' (real value belongs in user-secrets/env)"
&& 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
+15 -2
View File
@@ -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
`<UserSecretsId>` 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
+169
View File
@@ -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 `<UserSecretsId>` 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-container> 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 |
+18
View File
@@ -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
+24
View File
@@ -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
+36
View File
@@ -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"]
+4 -1
View File
@@ -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);
+21
View File
@@ -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
}
+35 -29
View File
@@ -47,24 +47,29 @@ Give it ~2030s 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 `<UserSecretsId>` 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" "<a-strong-password>"
> 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": "<a-strong-password>" }
> ```
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" "<the relay's API_KEY>"
```
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 `<UserSecretsId>`). |
| `dotnet user-secrets` errors with "could not find UserSecretsId" | Expected — user-secrets was removed. Edit `appsettings.Development.json` instead. |
+91
View File
@@ -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
+18
View File
@@ -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
+9 -8
View File
@@ -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 `<UserSecretsId>` 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
+2 -2
View File
@@ -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.
+31 -27
View File
@@ -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"]
@@ -6,8 +6,6 @@
<IsPackable>true</IsPackable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
<!-- Enables `dotnet user-secrets` for the local-dev connection string (never a committed secret). -->
<UserSecretsId>baya-web-api</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
@@ -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 <b>deployed</b> environments (Development keeps working dev-only defaults in
/// <c>appsettings.Development.json</c>, 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.
/// </summary>
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.<Environment>.json (or as Seams__…-style environment " +
"variables) — see DEPLOY.md.\n - " + string.Join("\n - ", errors));
}
private static void RequireReal(List<string> errors, string key, string? value)
@@ -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": {
@@ -13,9 +13,11 @@ namespace Baya.Infrastructure.CrossCutting.Seams.Real;
/// single JSON call: <c>POST /send_otp</c> and <c>POST /send</c>, authenticated with the shared
/// <c>X-Api-Key</c> secret.
///
/// <para><b>Development only.</b> 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 <see cref="TelegramOptions"/>).</para>
/// <para><b>Broadcast, not per-user routing.</b> 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 <see cref="KavenegarSmsSender"/> before real
/// customers sign up (see <see cref="TelegramOptions"/>).</para>
///
/// <para><b>The OTP is never logged</b> — only the phone tail and the relay's HTTP outcome, exactly like
/// <see cref="KavenegarSmsSender"/>. A non-2xx (the relay answers <c>502</c> when <i>no</i> recipient got the
@@ -27,7 +29,9 @@ public sealed class TelegramSmsSender(
IOptions<SeamOptions> options,
ILogger<TelegramSmsSender> logger) : ISmsSender
{
/// <summary>The repo's committed stand-in for a secret — treated as "not configured".</summary>
/// <summary>The example key published in <c>telegram-otp-bot/.env.example</c> 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.</summary>
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)
@@ -2,7 +2,7 @@ namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Options bound from the <c>Seams</c> 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.
///
/// <para><b>Provider selection (refinement-phase-8).</b> Each vendor rail carries a <c>Provider</c> 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
/// (<c>IShahkarVerifier</c>), e-KYC (<c>IIdentityKycProvider</c>), and استعلام شبا
/// (<c>IBankAccountOwnershipVerifier</c>). Each seam opts in with its own <c>Provider = finnotech</c> 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.
/// </summary>
public sealed class FinnotechOptions
{
@@ -63,8 +63,9 @@ public static class SeamProviders
public const string SmsIr = "smsir";
public const string Ghasedak = "ghasedak";
/// <summary><b>Development convenience channel, not an SMS gateway</b> — the local <c>telegram-otp-bot/</c>
/// relay broadcasts every code to a fixed list of Telegram chat ids. Never select it in a real environment.</summary>
/// <summary><b>Broadcast relay, not an SMS gateway</b> — the standalone <c>telegram-otp-bot/</c> 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.</summary>
public const string Telegram = "telegram";
// Object storage
@@ -105,7 +106,7 @@ public sealed class SmsOptions
/// (Development only).</summary>
public string Provider { get; set; } = SeamProviders.Mock;
/// <summary>Gateway API key / token (secret — user-secrets or environment, never committed).</summary>
/// <summary>Gateway API key / token (secret — the environment-specific appsettings file or environment variables).</summary>
public string ApiKey { get; set; } = string.Empty;
/// <summary>The registered sender line (used by <c>SendAsync</c> free-form messages and non-template sends).</summary>
@@ -122,13 +123,15 @@ public sealed class SmsOptions
}
/// <summary>
/// The <b>Development-only</b> Telegram OTP relay (the standalone <c>telegram-otp-bot/</c> Node service),
/// selected by <c>Seams:Sms:Provider = telegram</c>. 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 <c>telegram-otp-bot/</c> Node service), selected by
/// <c>Seams:Sms:Provider = telegram</c>. It replaces "read the OTP out of the server log" — the tester gets the
/// code on their phone without an Iranian SMS gateway contract.
///
/// <para><b>It is not an SMS gateway.</b> There is no per-user routing: the relay <i>broadcasts</i> 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.</para>
/// 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 <c>balinyaar.ir</c> deployment uses it — and
/// disqualifying once anyone outside that group can request a code. Switch <c>Seams:Sms:Provider</c> to
/// <c>kavenegar</c> at that point; nothing else changes.</para>
/// </summary>
public sealed class TelegramOptions
{
@@ -137,7 +140,7 @@ public sealed class TelegramOptions
/// <summary>The shared secret sent as the relay's <c>X-Api-Key</c> header — it must equal the relay's
/// <c>API_KEY</c>. <b>Secret:</b> committed config carries an empty/placeholder value; the real one comes
/// from user-secrets (<c>Seams:Sms:Telegram:ApiKey</c>) or the environment, never git.</summary>
/// from <c>Seams:Sms:Telegram:ApiKey</c> in appsettings or the environment.</summary>
public string ApiKey { get; set; } = string.Empty;
/// <summary>Per-request timeout. The relay itself talks to Telegram (over a proxy in a filtered region), so
@@ -18,7 +18,7 @@ public static class ServiceCollectionExtension
/// token swaps in the real HTTP adapter behind the same Application contract — <b>callers never change</b>. 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
/// <c>Seams:*</c> (user-secrets/environment) and get an <see cref="System.Net.Http.HttpClient"/> from the
/// <c>Seams:*</c> (appsettings/environment) and get an <see cref="System.Net.Http.HttpClient"/> from the
/// <c>IHttpClientFactory</c>. (The real in-app <c>INotificationDispatcher</c> needs the database, so it is
/// registered in the Persistence layer.)
/// </summary>
@@ -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
@@ -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
/// <summary>
/// Idempotently seeds one active <c>standard</c> payment gateway so the b10 card rail has a selectable
/// provider out of the box. <c>config_json</c> is encrypted at rest by the EF converter on save (so it
/// must go through the DbContext, not <c>HasData</c>). Real merchant credentials come from user-secrets /
/// must go through the DbContext, not <c>HasData</c>). Real merchant credentials come from appsettings /
/// environment per deployment — this sandbox row is non-secret and only enables the local/dev flow.
/// </summary>
public static async Task SeedPaymentGatewaysAsync(this WebApplication app)
@@ -34,7 +34,7 @@ public sealed class BayaApiFactory : WebApplicationFactory<Program>
_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
+12
View File
@@ -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
+9 -3
View File
@@ -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
+20
View File
@@ -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"]
+20 -9
View File
@@ -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