Compare commits

...

2 Commits

Author SHA1 Message Date
hamid 5885280b49 remove user-secrets approach & prepare a pilot deploy 2026-07-28 23:18:54 +03:30
hamid 630c7907ec integrate telegram bot 2026-07-28 22:25:15 +03:30
36 changed files with 1376 additions and 175 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
}
+61 -24
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`
@@ -164,6 +167,37 @@ Prove search works without the frontend: open Swagger →
`GET /api/v1/me` all return **200** with the `ApiResult` envelope, and there is **no CORS error** in the
console. That is the first real authenticated request between the two projects.
### OTP over Telegram (optional — instead of reading the log)
For manual testing you can have the code arrive **on your phone in Telegram** rather than in the server
console. A standalone dev-only relay ([`telegram-otp-bot/`](../../../telegram-otp-bot/README.md)) forwards it;
the API talks to it through the normal `ISmsSender` seam. **Development only** — the relay *broadcasts* every
code to every configured chat id, so it is a test-group convenience, not an SMS gateway.
1. **Start the relay** (see its README for creating the bot with @BotFather and discovering chat ids —
each recipient must press **Start** in Telegram first, then `GET /chat_ids`):
```bash
cd telegram-otp-bot && npm start # no npm install — zero dependencies
```
`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: 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"
```
4. **Log in** as usual — the 6-digit code arrives in Telegram. The `dev/last_otp` helper keeps working
alongside it (`telegram` is the one non-mock provider that leaves the capture bridge on), so scripts and
e2e tests are unaffected. Set the provider back to `mock` to return to reading the console.
If the relay is down or reaches nobody it answers `502` and **login fails loudly** (`request_otp` returns an
error) rather than pretending an undelivered code was sent.
---
## Good to know
@@ -171,10 +205,13 @@ Prove search works without the frontend: open Swagger →
- **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
@@ -212,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
+19 -11
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,10 +130,14 @@ 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:
`KavenegarSmsSender` (`Sms:Provider=kavenegar`**launch-critical**; when a real provider is selected the
Development OTP-in-logs bridge is **disabled**, so the OTP is never logged), `Finnotech{Shahkar,IdentityKyc,
`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`**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),
`Finnotech{Shahkar,IdentityKyc,
BankAccountOwnership}` (`{Shahkar,IdentityKyc,BankOwnership}:Provider=finnotech`, shared `Seams:Finnotech`
creds), `NeshanGeocoder` (`Geocoding:Provider=neshan`), `S3ObjectStorage` (`ObjectStorage:Provider=s3` — MinIO/
S3/ArvanCloud via **manual AWS SigV4**, presigned GET = the real b6 signed-URL contract), `ZarinPalPaymentProvider`
@@ -611,8 +617,10 @@ AddForwardedHeadersConfiguration(config) // refinement-phase-5: trust Forwarded
AddRateLimitingPolicies() // built-in rate limiter: per-resolved-IP global + named (otp/auth/sensitive/webhook)
AddSwagger("v1", "v1.1") · RegisterValidatorsAsServices() · AddMapster()
ConfigureGrpcPluginServices(builder.Environment) // refinement-phase-9: gRPC reflection registered only in Development
// Development-only: AddDevelopmentOtpCapture() (refinement-phase-0) decorates ISmsSender to capture each
// OTP in-memory for the GET /api/v1/dev/last_otp/{phone} helper — never wired outside Development.
// Development-only: AddDevelopmentOtpCapture() (refinement-phase-0) decorates the registered ISmsSender to
// capture each OTP in-memory for the GET /api/v1/dev/last_otp/{phone} helper — never wired outside Development,
// and only for a capture-safe Seams:Sms:Provider (`mock` / unset, or the Development-only `telegram` relay).
// A real gateway (kavenegar) disables it, so the code only ever leaves the process over the SMS wire.
```
Pipeline order: **forwarded headers** → exception handler → Swagger → routing → **CORS → rate limiter →
@@ -726,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)
+10 -5
View File
@@ -86,12 +86,17 @@ builder.Services.AddApplicationServices()
.AddRateLimitingPolicies();
// Development-only: capture each OTP in-memory so GET /api/v1/dev/last_otp/{phone} can complete a login
// without an SMS gateway. refinement-phase-8: the capture bridge runs ONLY while the log-only mock SMS sender is
// selected — once a real gateway (Seams:Sms:Provider) ships, the OTP is delivered over the wire and never logged
// or captured. Nothing here is wired in any other environment.
// without an SMS gateway. refinement-phase-8: the capture bridge runs ONLY for a capture-safe sender — the
// log-only mock, or the Development-only `telegram` relay (telegram-otp-bot/), which is a manual-testing
// convenience rather than a gateway and keeps the helper (and its e2e tests) working. A real gateway
// (kavenegar, and any future one) delivers over the wire and must never have the code logged or captured.
// Nothing here is wired in any other environment.
var smsProvider = configuration["Seams:Sms:Provider"];
var usingMockSms = string.IsNullOrWhiteSpace(smsProvider) || smsProvider.Equals("mock", StringComparison.OrdinalIgnoreCase);
if (builder.Environment.IsDevelopment() && usingMockSms)
var otpCaptureAllowedProvider =
string.IsNullOrWhiteSpace(smsProvider) ||
smsProvider.Equals("mock", StringComparison.OrdinalIgnoreCase) ||
smsProvider.Equals("telegram", StringComparison.OrdinalIgnoreCase);
if (builder.Environment.IsDevelopment() && otpCaptureAllowedProvider)
builder.Services.AddDevelopmentOtpCapture();
// The IPaymentCaptureSimulator + bookings/convert path is a Development/Testing affordance (b10's real webhook
@@ -4,8 +4,8 @@
"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,12 +13,22 @@
},
"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": ""
},
"Sms": {
"Provider": "telegram",
"Telegram": {
"BaseUrl": "http://127.0.0.1:5010",
"//": "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
}
},
"Geocoding": {
"ReturnNullCoordinates": false,
"LowConfidenceMarker": "NO_GEO",
@@ -26,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": {
@@ -0,0 +1,96 @@
#nullable enable
using System.Text;
using System.Text.Json;
using Baya.Application.Contracts.Common;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams.Real;
/// <summary>
/// <see cref="ISmsSender"/> over the standalone <b>Telegram OTP relay</b> (<c>telegram-otp-bot/</c>), selected by
/// <c>Seams:Sms:Provider = telegram</c>. Its two POST routes mirror this contract one-for-one, so each method is a
/// 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>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
/// message) or an <c>ok != true</c> body is a delivery failure and throws, so <c>RequestOtpCommand</c> reports a
/// real send failure instead of silently "succeeding" on an undelivered code.</para>
/// </summary>
public sealed class TelegramSmsSender(
HttpClient httpClient,
IOptions<SeamOptions> options,
ILogger<TelegramSmsSender> logger) : ISmsSender
{
/// <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;
public Task SendOtpAsync(string phone, string code, CancellationToken cancellationToken = default)
=> PostAsync("send_otp", new { phone, code }, phone, cancellationToken);
public Task SendAsync(string phone, string message, CancellationToken cancellationToken = default)
=> PostAsync("send", new { phone, message }, phone, cancellationToken);
private async Task PostAsync(string path, object payload, string phone, CancellationToken cancellationToken)
{
// 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) || apiKey == SecretPlaceholder)
{
throw new InvalidOperationException(
"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)
{
// Snake-case keys (phone/code/message) are exactly what the relay reads off the body.
Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"),
};
request.Headers.Add("X-Api-Key", apiKey);
using var response = await httpClient.SendAsync(request, cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode || !IsDelivered(body))
{
logger.LogWarning(
"Telegram OTP relay delivery failed for phone ending {PhoneTail} — http {Http}",
Tail(phone), (int)response.StatusCode);
throw new InvalidOperationException(
$"Telegram OTP relay delivery failed (http {(int)response.StatusCode}).");
}
logger.LogInformation("Telegram OTP relay accepted the message for phone ending {PhoneTail}", Tail(phone));
}
/// <summary>A 2xx still carries the per-recipient outcome in <c>ok</c>; anything but <c>true</c> is a failure.</summary>
private static bool IsDelivered(string body)
{
try
{
using var doc = JsonDocument.Parse(body);
return doc.RootElement.TryGetProperty("ok", out var ok) && ok.ValueKind == JsonValueKind.True;
}
catch (JsonException)
{
return false;
}
}
private static string Tail(string phone) =>
string.IsNullOrEmpty(phone) ? "????" : phone[^Math.Min(4, phone.Length)..];
}
@@ -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,6 +63,11 @@ public static class SeamProviders
public const string SmsIr = "smsir";
public const string Ghasedak = "ghasedak";
/// <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
public const string S3 = "s3";
@@ -89,15 +94,19 @@ public static class SeamProviders
/// <summary>
/// The outbound SMS rail (<c>ISmsSender</c>). <see cref="Provider"/> = <c>mock</c> logs the OTP (the b2
/// <c>LoggingSmsSender</c>); set it to <c>kavenegar</c> / <c>smsir</c> / <c>ghasedak</c> to deliver over a real
/// Iranian gateway. <b>refinement-phase-8:</b> when a real provider is selected the Development OTP-in-logs/echo
/// Iranian gateway. <b>refinement-phase-8:</b> when a real gateway is selected the Development OTP-in-logs/echo
/// bridge is disabled — the OTP must never be logged once real SMS ships.
///
/// <para><c>telegram</c> is the one exception: it is a <b>Development convenience channel, not an SMS gateway</b>
/// (see <see cref="TelegramOptions"/>), so the OTP-capture bridge stays enabled alongside it.</para>
/// </summary>
public sealed class SmsOptions
{
/// <summary><c>mock</c> (default) | <c>kavenegar</c> | <c>smsir</c> | <c>ghasedak</c>.</summary>
/// <summary><c>mock</c> (default) | <c>kavenegar</c> | <c>smsir</c> | <c>ghasedak</c> | <c>telegram</c>
/// (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>
@@ -108,6 +117,35 @@ public sealed class SmsOptions
/// <summary>The approved OTP template/pattern name the gateway sends the code through (verify-lookup APIs).</summary>
public string OtpTemplate { get; set; } = string.Empty;
/// <summary>Connection facts for the <c>telegram</c> relay; ignored by every other provider.</summary>
public TelegramOptions Telegram { get; set; } = new();
}
/// <summary>
/// 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 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
{
/// <summary>The relay's root URL, e.g. <c>http://127.0.0.1:5010</c>.</summary>
public string BaseUrl { get; set; } = string.Empty;
/// <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 <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
/// it needs more headroom than a loopback call suggests.</summary>
public int TimeoutSeconds { get; set; } = 10;
}
/// <summary>
@@ -8,27 +8,42 @@ public static class DevelopmentSeamExtensions
{
/// <summary>
/// Development-only wiring for the OTP bring-up bridge. Registers <see cref="DevOtpStore"/> and decorates
/// the <see cref="ISmsSender"/> registered by <c>AddCrossCuttingSeams</c> (the log-only
/// <see cref="LoggingSmsSender"/>) with <see cref="DevCapturingSmsSender"/>, so each OTP is also captured
/// in memory for <c>/api/v1/dev/last_otp/{phone}</c> to serve. MUST be called only inside
/// <c>builder.Environment.IsDevelopment()</c>: nothing here is wired in any other environment, which —
/// together with the endpoint's own <c>IsDevelopment()</c> guard — makes the OTP echo impossible to enable
/// outside Development. Superseded by the real SMS gateway in refinement Phase 8.
/// the <see cref="ISmsSender"/> registered by <c>AddCrossCuttingSeams</c> with
/// <see cref="DevCapturingSmsSender"/>, so each OTP is also captured in memory for
/// <c>/api/v1/dev/last_otp/{phone}</c> to serve. MUST be called only inside
/// <c>builder.Environment.IsDevelopment()</c>, and only for a capture-safe provider (the log-only
/// <see cref="LoggingSmsSender"/> or the Development-only Telegram relay — <c>Program.cs</c> owns that
/// condition): nothing here is wired in any other environment, which — together with the endpoint's own
/// <c>IsDevelopment()</c> guard — makes the OTP echo impossible to enable outside Development.
/// </summary>
public static IServiceCollection AddDevelopmentOtpCapture(this IServiceCollection services)
{
services.AddSingleton<DevOtpStore>();
// Re-register ISmsSender as the capturing decorator over a fresh LoggingSmsSender (built through DI so
// it still gets its ILogger). The last registration wins for a single resolve, so callers transparently
// get the decorator; the code is still logged exactly as before, just also captured for the dev endpoint.
// Decorate whatever ISmsSender is already registered rather than assuming the mock — with
// Seams:Sms:Provider = telegram the inner sender is TelegramSmsSender, and re-creating a LoggingSmsSender
// here would silently swallow the delivery instead of capturing alongside it. Last registration wins for
// a single resolve, so callers transparently get the decorator and delivery behaviour is unchanged.
var inner = services.LastOrDefault(d => d.ServiceType == typeof(ISmsSender))
?? throw new InvalidOperationException(
"AddDevelopmentOtpCapture must run after AddCrossCuttingSeams — no ISmsSender is registered.");
services.Remove(inner);
services.AddSingleton<ISmsSender>(sp => new DevCapturingSmsSender(
ActivatorUtilities.CreateInstance<LoggingSmsSender>(sp),
ResolveSender(sp, inner),
sp.GetRequiredService<DevOtpStore>()));
return services;
}
private static ISmsSender ResolveSender(IServiceProvider sp, ServiceDescriptor descriptor) => descriptor switch
{
{ ImplementationInstance: ISmsSender instance } => instance,
{ ImplementationFactory: { } factory } => (ISmsSender)factory(sp),
{ ImplementationType: { } type } => (ISmsSender)ActivatorUtilities.CreateInstance(sp, type),
_ => throw new InvalidOperationException("The registered ISmsSender cannot be constructed for decoration."),
};
/// <summary>
/// Development/Testing-only re-registration of the real <see cref="MockPaymentCaptureSimulator"/> over the
/// production <see cref="DisabledPaymentCaptureSimulator"/> (refinement-phase-8, 6.4). The <c>bookings/convert</c>
@@ -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>
@@ -87,16 +87,31 @@ public static class ServiceCollectionExtension
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SeamOptions>>(),
sp.GetRequiredService<ILogger<KavenegarSmsSender>>()));
}
else if (Is(provider, SeamProviders.Telegram))
{
// Development-only relay (telegram-otp-bot/) — a manual-testing convenience, not a gateway. The
// per-request timeout is generous because the relay's own hop to Telegram goes through a proxy.
var telegram = seams.Sms.Telegram;
services.AddHttpClient(HttpClients.Telegram, c =>
{
c.BaseAddress = new Uri(BaseOrDefault(telegram.BaseUrl, "http://127.0.0.1:5010").TrimEnd('/') + "/");
c.Timeout = TimeSpan.FromSeconds(telegram.TimeoutSeconds > 0 ? telegram.TimeoutSeconds : 10);
});
services.AddSingleton<ISmsSender>(sp => new TelegramSmsSender(
Client(sp, HttpClients.Telegram),
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SeamOptions>>(),
sp.GetRequiredService<ILogger<TelegramSmsSender>>()));
}
else if (Is(provider, SeamProviders.SmsIr) || Is(provider, SeamProviders.Ghasedak))
{
throw new NotSupportedException(
$"SMS provider '{provider}' is not implemented — only 'kavenegar' has a real adapter (refinement-phase-8). " +
"Add its adapter behind ISmsSender, or use 'mock'/'kavenegar'.");
"Add its adapter behind ISmsSender, or use 'mock'/'kavenegar'/'telegram'.");
}
else
{
// b2 log-only mock; the Development OTP-capture decorator is layered on in Program.cs (Development only,
// and only while this mock is selected — a real SMS provider disables it so the OTP is never logged).
// and only for the capture-safe providers — a real SMS gateway disables it so the OTP is never logged).
services.AddSingleton<ISmsSender, LoggingSmsSender>();
}
}
@@ -278,6 +293,7 @@ public static class ServiceCollectionExtension
{
public const string ObjectStorage = "seam-object-storage";
public const string Sms = "seam-sms";
public const string Telegram = "seam-sms-telegram";
public const string Finnotech = "seam-finnotech";
public const string Geocoding = "seam-geocoding";
public const string Psp = "seam-psp";
@@ -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
@@ -0,0 +1,71 @@
using Baya.Application.Contracts.Common;
using Baya.Infrastructure.CrossCutting.Seams;
using Baya.Infrastructure.CrossCutting.Seams.Real;
using Baya.Infrastructure.CrossCutting.ServiceConfiguration;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace Baya.Test.Foundation.Seams;
/// <summary>
/// The Telegram OTP relay must be <b>invisible unless opted in</b>: an environment that configures no SMS
/// provider — or leaves the default <c>mock</c> — resolves the log-only sender exactly as it did before the
/// channel existed. Only <c>Seams:Sms:Provider = telegram</c> swaps it in.
/// </summary>
public class SmsProviderRegistrationTests
{
[Fact]
public void UnsetProvider_ResolvesTheLogOnlyMock()
=> Assert.IsType<LoggingSmsSender>(Resolve(null));
[Fact]
public void MockProvider_ResolvesTheLogOnlyMock()
=> Assert.IsType<LoggingSmsSender>(Resolve("mock"));
[Fact]
public void UnknownProvider_FallsClosedToTheLogOnlyMock()
=> Assert.IsType<LoggingSmsSender>(Resolve("telegramm"));
[Fact]
public void TelegramProvider_ResolvesTheRelayAdapter()
=> Assert.IsType<TelegramSmsSender>(Resolve("telegram"));
[Fact]
public async Task OtpCaptureBridge_DecoratesTheConfiguredSender_NotTheMock()
{
// The Development capture bridge runs alongside the relay, so it must wrap the *configured* sender —
// re-creating a LoggingSmsSender here would silently drop every Telegram delivery. Proven without a
// network call: only TelegramSmsSender refuses an unconfigured api key.
var services = Services("telegram", apiKey: null);
services.AddDevelopmentOtpCapture();
var provider = services.BuildServiceProvider();
var sender = provider.GetRequiredService<ISmsSender>();
Assert.IsType<DevCapturingSmsSender>(sender);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => sender.SendOtpAsync("09120000001", "135790"));
Assert.Contains("Seams:Sms:Telegram:ApiKey", ex.Message);
// …and the code was still captured for GET /dev/last_otp before the inner sender ran.
Assert.Equal("135790", provider.GetRequiredService<DevOtpStore>().GetLatest("09120000001"));
}
private static ISmsSender Resolve(string? provider)
=> Services(provider, "0123456789abcdef0123456789abcdef").BuildServiceProvider().GetRequiredService<ISmsSender>();
private static ServiceCollection Services(string? provider, string? apiKey)
{
var settings = new Dictionary<string, string?>();
if (provider is not null) settings["Seams:Sms:Provider"] = provider;
if (apiKey is not null) settings["Seams:Sms:Telegram:ApiKey"] = apiKey;
var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
var services = new ServiceCollection();
services.AddLogging();
services.AddCrossCuttingSeams(configuration);
return services;
}
}
@@ -0,0 +1,149 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Baya.Infrastructure.CrossCutting.Seams;
using Baya.Infrastructure.CrossCutting.Seams.Real;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Baya.Test.Foundation.Seams;
/// <summary>
/// The Development-only Telegram OTP relay adapter: it must speak the relay's contract exactly (routes, body
/// keys, <c>X-Api-Key</c>), treat an undelivered message as a failure rather than a silent success, and never
/// put the OTP code in a log line.
/// </summary>
public class TelegramSmsSenderTests
{
private const string ApiKey = "0123456789abcdef0123456789abcdef";
[Fact]
public async Task SendOtpAsync_PostsTheCodeToSendOtpWithTheApiKeyHeader()
{
var (sender, handler, _) = Build();
await sender.SendOtpAsync("09120000001", "135790");
var request = Assert.Single(handler.Requests);
Assert.Equal(HttpMethod.Post, request.Method);
Assert.Equal("http://127.0.0.1:5010/send_otp", request.Url);
Assert.Equal(ApiKey, request.ApiKey);
using var body = JsonDocument.Parse(request.Body);
Assert.Equal("09120000001", body.RootElement.GetProperty("phone").GetString());
Assert.Equal("135790", body.RootElement.GetProperty("code").GetString());
}
[Fact]
public async Task SendAsync_PostsTheMessageToSend()
{
var (sender, handler, _) = Build();
await sender.SendAsync("09120000001", "your booking is confirmed");
var request = Assert.Single(handler.Requests);
Assert.Equal("http://127.0.0.1:5010/send", request.Url);
Assert.Equal(ApiKey, request.ApiKey);
using var body = JsonDocument.Parse(request.Body);
Assert.Equal("your booking is confirmed", body.RootElement.GetProperty("message").GetString());
}
[Fact]
public async Task NoRecipientReceivedIt_Throws()
{
// 502 = the relay reached nobody. Swallowing it would report a login code that was never delivered.
var (sender, _, _) = Build(HttpStatusCode.BadGateway, """{"ok":false,"delivered":[],"failed":[]}""");
await Assert.ThrowsAsync<InvalidOperationException>(() => sender.SendOtpAsync("09120000001", "135790"));
}
[Fact]
public async Task SuccessStatusWithNotOkBody_Throws()
{
var (sender, _, _) = Build(HttpStatusCode.OK, """{"ok":false}""");
await Assert.ThrowsAsync<InvalidOperationException>(() => sender.SendOtpAsync("09120000001", "135790"));
}
[Fact]
public async Task UnconfiguredApiKey_ThrowsNamingTheConfigKey()
{
var (sender, handler, _) = Build(apiKey: "ab8984974bc1fe5ce514d0fd74f71c8738b3aed92a7e4d86");
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => sender.SendOtpAsync("09120000001", "135790"));
Assert.Contains("Seams:Sms:Telegram:ApiKey", ex.Message);
Assert.Empty(handler.Requests); // never sent unauthenticated
}
[Fact]
public async Task TheOtpCodeNeverReachesTheLog()
{
var (ok, _, okLog) = Build();
await ok.SendOtpAsync("09120000001", "135790");
var (failing, _, failLog) = Build(HttpStatusCode.BadGateway, """{"ok":false}""");
await Assert.ThrowsAsync<InvalidOperationException>(() => failing.SendOtpAsync("09120000001", "135790"));
Assert.NotEmpty(okLog.Lines);
Assert.NotEmpty(failLog.Lines);
Assert.DoesNotContain(okLog.Lines.Concat(failLog.Lines), line => line.Contains("135790"));
}
private static (TelegramSmsSender Sender, StubHandler Handler, CapturingLogger Logger) Build(
HttpStatusCode status = HttpStatusCode.OK,
string body = """{"ok":true,"delivered":["11111111"],"failed":[]}""",
string apiKey = ApiKey)
{
var handler = new StubHandler(status, body);
var client = new HttpClient(handler) { BaseAddress = new Uri("http://127.0.0.1:5010/") };
var options = Options.Create(new SeamOptions
{
Sms = new SmsOptions
{
Provider = SeamProviders.Telegram,
Telegram = new TelegramOptions { BaseUrl = "http://127.0.0.1:5010", ApiKey = apiKey },
},
});
var logger = new CapturingLogger();
return (new TelegramSmsSender(client, options, logger), handler, logger);
}
private sealed record CapturedRequest(HttpMethod Method, string Url, string? ApiKey, string Body);
/// <summary>Answers every call from memory — the adapter is exercised with no network at all.</summary>
private sealed class StubHandler(HttpStatusCode status, string body) : HttpMessageHandler
{
public List<CapturedRequest> Requests { get; } = [];
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
Requests.Add(new CapturedRequest(
request.Method,
request.RequestUri!.ToString(),
request.Headers.TryGetValues("X-Api-Key", out var values) ? values.FirstOrDefault() : null,
request.Content is null ? string.Empty : await request.Content.ReadAsStringAsync(cancellationToken)));
return new HttpResponseMessage(status)
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
};
}
}
private sealed class CapturingLogger : ILogger<TelegramSmsSender>
{
public List<string> Lines { get; } = [];
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
=> Lines.Add(formatter(state, exception));
}
}
+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
+18 -9
View File
@@ -1,19 +1,24 @@
# 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=
TELEGRAM_BOT_TOKEN=8968527151:AAFiCuNGkXjOiLZfT6urU8tkW8SCsWDM0ic
# Comma-separated Telegram chat ids that receive every OTP.
# Each of these users MUST have sent the bot at least one message first
# (Telegram forbids a bot from opening a conversation).
# Discover them with: GET http://localhost:5010/chat_ids (with the X-Api-Key header)
TELEGRAM_CHAT_IDS=
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).
API_KEY=
# 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
PORT=5010
@@ -23,7 +28,11 @@ HOST=127.0.0.1
# The code is still delivered over Telegram either way.
REDACT_CODE_IN_LOGS=false
# api.telegram.org is filtered in Iran. On Node 24+, these two make the built-in fetch
# use your local proxy; point HTTPS_PROXY at whatever your VPN/proxy client listens on.
# NODE_USE_ENV_PROXY=1
# HTTPS_PROXY=http://127.0.0.1:10809
# api.telegram.org is filtered in Iran. Point this at whatever your VPN / proxy client listens on and
# 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://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"]
+38 -16
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,14 +95,24 @@ 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. |
The API key is the only access control — there is no IP allow-list and no TLS. Keep `HOST` on
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
@@ -111,15 +123,25 @@ Values come from `.env` (git-ignored) or from real environment variables, which
| `token check FAILED` / `fetch failed` | Wrong/revoked token, or no outbound access to `api.telegram.org` — see below. |
| `401` on every call | The caller isn't sending `X-Api-Key`, or its value differs from `API_KEY`. |
### Reaching Telegram from Iran
### Reaching Telegram from Iran — the proxy option
`api.telegram.org` is filtered, so the machine running this needs a proxy. On **Node 24+** the built-in
`fetch` honours the standard proxy variables once opted in — uncomment these in `.env`:
`api.telegram.org` is filtered, so the machine running this usually needs a proxy for **its own** hop to
Telegram. (The API → relay hop is loopback/LAN and never proxied.) Set one URL in `.env`:
```
NODE_USE_ENV_PROXY=1
HTTPS_PROXY=http://127.0.0.1:10809
TELEGRAM_PROXY_URL=http://127.0.0.1:10809 # or socks5://127.0.0.1:10808
```
pointing `HTTPS_PROXY` at whatever your VPN/proxy client listens on. On older Node, run the process
under a system-wide/TUN-mode proxy instead — `fetch` there ignores the env vars.
- **Opt-in.** Unset ⇒ the relay connects directly, byte-for-byte as before. The boot banner prints which
it is (`proxy: socks5://127.0.0.1:10808` or `proxy: (none — direct to api.telegram.org)`).
- **Schemes:** `http`/`https` (an HTTP `CONNECT` tunnel) and `socks5`/`socks5h`. Credentials go in the URL
(`socks5://user:pass@host:1080`) and are never logged.
- **Local machine with a VPN client** → point it at that client's HTTP or SOCKS listener.
**VPS with a proxy client in a docker container** → put the relay and the proxy on the same docker
network and use the container name, e.g. `http://proxy:1080`; from the host, `http://127.0.0.1:<published-port>`.
- SOCKS5 requests are sent with the hostname (not a pre-resolved IP), so DNS resolves at the proxy —
local DNS is filtered too.
- `HTTPS_PROXY` / `ALL_PROXY` (either case) are used as a fallback when `TELEGRAM_PROXY_URL` is unset, so a
container that already sets them needs no extra config. `NODE_USE_ENV_PROXY` is not needed and not read —
the tunnel is handled in-process, so behaviour is the same on every Node ≥ 18.
- A malformed proxy URL is fatal **at boot**, not silently at the first OTP.
+23
View File
@@ -1,6 +1,7 @@
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseProxyUrl } from './proxy.js';
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
@@ -60,10 +61,32 @@ if (apiKey.length < 16) {
process.exit(1);
}
// Opt-in outbound proxy for this process's own hop to api.telegram.org (filtered in Iran). Absent ⇒ direct,
// exactly as before. TELEGRAM_PROXY_URL wins so the relay can be proxied without proxying anything else on the
// box; the standard variables are honoured as a fallback because that is where a container already puts it.
const proxyUrl = (
process.env.TELEGRAM_PROXY_URL ??
process.env.HTTPS_PROXY ?? process.env.https_proxy ??
process.env.ALL_PROXY ?? process.env.all_proxy ??
''
).trim();
let proxy = null;
if (proxyUrl) {
try {
proxy = parseProxyUrl(proxyUrl);
} catch (error) {
// Fail at boot: a misspelled proxy would otherwise surface as an unexplained delivery failure per OTP.
console.error(`FATAL: ${error.message}`);
process.exit(1);
}
}
export const config = {
botToken,
chatIds,
apiKey,
proxy,
host: (process.env.HOST ?? '127.0.0.1').trim(),
port: Number(process.env.PORT ?? 5010),
redactCodeInLogs: (process.env.REDACT_CODE_IN_LOGS ?? 'false').toLowerCase() === 'true',
+204
View File
@@ -0,0 +1,204 @@
import net from 'node:net';
import tls from 'node:tls';
import { Buffer } from 'node:buffer';
/**
* Opt-in outbound proxy for the one hop that is filtered in Iran: this process api.telegram.org.
*
* Node's built-in `fetch` only honours `HTTPS_PROXY` on Node 24+ and only behind `NODE_USE_ENV_PROXY`, which
* makes "does the proxy apply?" depend on the runtime. Tunnelling the socket here instead keeps the behaviour
* identical on every supported Node and needs no dependency: an HTTP `CONNECT` tunnel or a SOCKS5 handshake,
* both of which any local VPN/proxy client (or a proxy container on the VPS) exposes.
*
* With no proxy configured nothing in this file runs the request goes out directly, as before.
*/
const SOCKS_VERSION = 0x05;
const SOCKS_NO_AUTH = 0x00;
const SOCKS_USER_PASS = 0x02;
const SOCKS_CMD_CONNECT = 0x01;
const SOCKS_ATYP_DOMAIN = 0x03;
/** Parses a proxy URL into the shape the connectors need, or throws with a usable message. */
export function parseProxyUrl(raw) {
let url;
try {
url = new URL(raw);
} catch {
throw new Error(`invalid proxy URL "${raw}" — expected e.g. http://127.0.0.1:10809 or socks5://127.0.0.1:10808`);
}
const scheme = url.protocol.replace(':', '').toLowerCase();
if (!['http', 'https', 'socks', 'socks5', 'socks5h'].includes(scheme)) {
throw new Error(`unsupported proxy scheme "${scheme}" — use http, https, or socks5`);
}
const port = url.port ? Number(url.port) : scheme === 'https' ? 443 : scheme === 'http' ? 8080 : 1080;
return {
scheme,
host: url.hostname,
port,
username: url.username ? decodeURIComponent(url.username) : '',
password: url.password ? decodeURIComponent(url.password) : '',
// What to print in logs — never the credentials.
label: `${scheme}://${url.hostname}:${port}`,
};
}
/** Returns a socket already tunnelled to `host:port` through the proxy. The caller TLS-wraps it. */
export function connectThroughProxy(proxy, host, port, timeoutMs) {
const connect = proxy.scheme === 'http' || proxy.scheme === 'https' ? httpConnect : socks5Connect;
return withTimeout(connect(proxy, host, port), timeoutMs, `proxy ${proxy.label} did not connect`);
}
function openToProxy(proxy) {
return proxy.scheme === 'https'
? tls.connect({ host: proxy.host, port: proxy.port, servername: proxy.host })
: net.connect({ host: proxy.host, port: proxy.port });
}
/** RFC 7231 `CONNECT` tunnel — what an HTTP proxy exposes for TLS traffic. */
function httpConnect(proxy, host, port) {
return new Promise((resolve, reject) => {
const socket = openToProxy(proxy);
const fail = (error) => {
socket.destroy();
reject(error);
};
socket.once('error', fail);
socket.once(proxy.scheme === 'https' ? 'secureConnect' : 'connect', () => {
const lines = [`CONNECT ${host}:${port} HTTP/1.1`, `Host: ${host}:${port}`];
if (proxy.username) {
const credentials = Buffer.from(`${proxy.username}:${proxy.password}`).toString('base64');
lines.push(`Proxy-Authorization: Basic ${credentials}`);
}
socket.write(`${lines.join('\r\n')}\r\n\r\n`);
});
let buffer = Buffer.alloc(0);
const onData = (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
const headerEnd = buffer.indexOf('\r\n\r\n');
if (headerEnd === -1) return;
socket.removeListener('data', onData);
const statusLine = buffer.subarray(0, buffer.indexOf('\r\n')).toString('latin1');
if (!/^HTTP\/1\.[01] 200/.test(statusLine)) {
fail(new Error(`proxy refused CONNECT: ${statusLine}`));
return;
}
// A compliant proxy sends nothing after the blank line, but push anything it did back for the TLS layer.
const leftover = buffer.subarray(headerEnd + 4);
if (leftover.length) socket.unshift(leftover);
socket.removeListener('error', fail);
resolve(socket);
};
socket.on('data', onData);
});
}
/** RFC 1928 SOCKS5 (+ RFC 1929 username/password) — what most local proxy clients expose. */
async function socks5Connect(proxy, host, port) {
const socket = openToProxy(proxy);
try {
await once(socket, 'connect');
const { read, release } = reader(socket);
const methods = proxy.username ? [SOCKS_NO_AUTH, SOCKS_USER_PASS] : [SOCKS_NO_AUTH];
socket.write(Buffer.from([SOCKS_VERSION, methods.length, ...methods]));
const greeting = await read(2);
if (greeting[0] !== SOCKS_VERSION) throw new Error('proxy is not SOCKS5');
if (greeting[1] === SOCKS_USER_PASS) {
const user = Buffer.from(proxy.username);
const pass = Buffer.from(proxy.password);
socket.write(Buffer.concat([
Buffer.from([0x01, user.length]), user, Buffer.from([pass.length]), pass,
]));
const auth = await read(2);
if (auth[1] !== 0x00) throw new Error('SOCKS5 proxy rejected the credentials');
} else if (greeting[1] !== SOCKS_NO_AUTH) {
throw new Error('SOCKS5 proxy demands an unsupported authentication method');
}
// ATYP = domain, so the *proxy* resolves api.telegram.org — local DNS is filtered too.
const target = Buffer.from(host);
const request = Buffer.concat([
Buffer.from([SOCKS_VERSION, SOCKS_CMD_CONNECT, 0x00, SOCKS_ATYP_DOMAIN, target.length]),
target,
Buffer.from([(port >> 8) & 0xff, port & 0xff]),
]);
socket.write(request);
const reply = await read(4);
if (reply[1] !== 0x00) throw new Error(`SOCKS5 proxy refused CONNECT (code ${reply[1]})`);
// Drain the bound address so the stream starts at the tunnelled payload.
const boundLength = reply[3] === 0x01 ? 4 : reply[3] === 0x04 ? 16 : (await read(1))[0];
await read(boundLength + 2);
return release();
} catch (error) {
socket.destroy();
throw error;
}
}
/**
* Reads exactly N bytes at a time during the handshake. `release()` hands the socket back with any
* already-buffered bytes pushed in front, so the TLS layer sees an untouched stream.
*/
function reader(socket) {
let buffer = Buffer.alloc(0);
let pending = null;
const pump = () => {
if (!pending || buffer.length < pending.size) return;
const { size, resolve } = pending;
pending = null;
const chunk = buffer.subarray(0, size);
buffer = buffer.subarray(size);
resolve(chunk);
};
socket.on('data', (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
pump();
});
const read = (size) => new Promise((resolve, reject) => {
pending = { size, resolve };
socket.once('error', reject);
pump();
});
const release = () => {
socket.removeAllListeners('data');
socket.removeAllListeners('error');
if (buffer.length) socket.unshift(buffer);
return socket;
};
return { read, release };
}
function once(emitter, event) {
return new Promise((resolve, reject) => {
emitter.once(event, resolve);
emitter.once('error', reject);
});
}
function withTimeout(promise, timeoutMs, message) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`${message} within ${timeoutMs}ms`)), timeoutMs);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
+2
View File
@@ -96,6 +96,7 @@ const routes = {
if (!phone || !code) return json(res, 400, { ok: false, error: '`phone` and `code` are required' });
const shown = config.redactCodeInLogs ? '******' : code;
console.log(`sending OTP ${shown} for ${phone}${config.chatIds.length} recipients`);
return deliver(res, otpMessage(phone, code), `otp ${shown} for ${phone}`);
},
@@ -133,6 +134,7 @@ server.listen(config.port, config.host, async () => {
console.log(`balinyaar telegram-otp-bot listening on http://${config.host}:${config.port}`);
console.log(` recipients: ${config.chatIds.length ? config.chatIds.join(', ') : '(none — call GET /chat_ids to discover)'}`);
console.log(' auth: X-Api-Key required on every route except GET /health');
console.log(` proxy: ${config.proxy ? config.proxy.label : '(none — direct to api.telegram.org)'}`);
try {
const me = await getBotIdentity();
+48 -15
View File
@@ -1,31 +1,64 @@
import https from 'node:https';
import tls from 'node:tls';
import { Buffer } from 'node:buffer';
import { config } from './env.js';
import { connectThroughProxy } from './proxy.js';
const API_BASE = `https://api.telegram.org/bot${config.botToken}`;
const API_HOST = 'api.telegram.org';
async function callApi(method, payload, { timeoutMs = 10_000 } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const { status, text } = await postJson(`/bot${config.botToken}/${method}`, payload, timeoutMs);
let body = {};
try {
const response = await fetch(`${API_BASE}/${method}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
signal: controller.signal,
});
body = JSON.parse(text);
} catch {
// keep the empty body; the status check below reports it
}
const body = await response.json().catch(() => ({}));
if (!response.ok || body.ok !== true) {
if (status < 200 || status >= 300 || body.ok !== true) {
// Telegram carries the real failure in `description` (e.g. "chat not found" when the
// recipient never messaged the bot first) — surface it verbatim so the cause is obvious.
const reason = body.description ?? `HTTP ${response.status}`;
throw new Error(reason);
throw new Error(body.description ?? `HTTP ${status}`);
}
return body.result;
} finally {
clearTimeout(timer);
}
/**
* One HTTPS POST to Telegram. Built on `node:https` rather than `fetch` so the optional proxy applies
* identically on every supported Node version: with a proxy configured the request rides a tunnelled socket
* (see `proxy.js`), without one it takes the default direct route.
*/
async function postJson(path, payload, timeoutMs) {
const body = JSON.stringify(payload);
const tunnel = config.proxy
? await connectThroughProxy(config.proxy, API_HOST, 443, timeoutMs)
: null;
return new Promise((resolve, reject) => {
const request = https.request({
host: API_HOST,
port: 443,
path,
method: 'POST',
headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) },
// Only set when tunnelling: passing createConnection is what makes node skip the default agent.
...(tunnel ? { createConnection: () => tls.connect({ socket: tunnel, servername: API_HOST }) } : {}),
}, (response) => {
let text = '';
response.setEncoding('utf8');
response.on('data', (chunk) => { text += chunk; });
response.on('end', () => resolve({ status: response.statusCode, text }));
});
request.setTimeout(timeoutMs, () => request.destroy(new Error(`no response within ${timeoutMs}ms`)));
request.on('error', (error) => {
if (tunnel) tunnel.destroy();
reject(error);
});
request.end(body);
});
}
/** Sends one message to every configured chat id. Never rejects — per-recipient outcomes are returned. */