Compare commits
2 Commits
e6a8f93a1e
...
5885280b49
| Author | SHA1 | Date | |
|---|---|---|---|
| 5885280b49 | |||
| 630c7907ec |
+13
-6
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -47,24 +47,29 @@ Give it ~20–30s on first start (`docker compose ps` shows `healthy`).
|
||||
|
||||
> Already have a SQL Server? Skip this and point the connection string in step 3 at it instead.
|
||||
|
||||
### 3. Point the API at the local database (via user-secrets — never a committed file)
|
||||
### 3. Point the API at a database
|
||||
|
||||
The committed `appsettings*.json` carry a **placeholder** connection string on purpose. Supply the real
|
||||
local one through `dotnet user-secrets` so no working credential ever lands in git. From the API project:
|
||||
**`dotnet user-secrets` is no longer used** — the `<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. |
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"SqlServer": "Server=87.107.152.16,1433;Database=Baya;User Id=hamid_root_un_sa;Password=N8@s5Taw1zWeh@#Hm;TrustServerCertificate=True;Encrypt=False;",
|
||||
"logDb":"Server=87.107.152.16,1433;Database=Baya_Logs;User Id=hamid_root_un_sa;Password=N8@s5Taw1zWeh@#Hm;TrustServerCertificate=True;Encrypt=False;"
|
||||
"logDb": "Server=87.107.152.16,1433;Database=Baya_Logs;User Id=hamid_root_un_sa;Password=N8@s5Taw1zWeh@#Hm;TrustServerCertificate=True;Encrypt=False;"
|
||||
},
|
||||
"IdentitySettings": {
|
||||
"SecretKey": "SET_VIA_USER_SECRETS_OR_ENV",
|
||||
"Encryptkey": "SET_VIA_USER_SECRETS_OR_ENV",
|
||||
"SecretKey": "dev-only-jwe-signing-key-not-for-production-0123456789abcdef",
|
||||
"Encryptkey": "dev-only-16bytes",
|
||||
"Issuer": "Balinyaar",
|
||||
"Audience": "BalinyaarClient",
|
||||
"NotBeforeMinutes": "0",
|
||||
@@ -13,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": {
|
||||
|
||||
+96
@@ -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>
|
||||
|
||||
+25
-10
@@ -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>
|
||||
|
||||
+19
-3
@@ -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";
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user