8.6 KiB
Integration — the client↔server seam
The two projects have no shared build. Everything that crosses between them is described here, as one thing. This page is the whole seam; the four files below are the detail.
Last verified: 2026-07-30 against commit
d3ec723,swagger.v1.json(2026-07-29, 178 paths / 186 operations) and the code inclient/src/lib/api/,client/src/services/*/,server/src/API/.
| File | Covers |
|---|---|
| api-contract.md | Envelope, casing, pagination, errors, idempotency, auth, money, rate limits |
| domains/ | 22 files, one per client services/ domain — every endpoint, verdicted against the live swagger |
| openapi/ | The machine contract + how to regenerate it |
| config-matrix.md | Every env var and appsettings key: client, server, docker, the bot |
| topology.md | The runtime dependency graph — 3 containers, Caddy, remote SQL, the OTP relay |
Transport
HTTP/JSON. The client reads one base URL — NEXT_PUBLIC_API_URL — and prefixes every path with
/api/v1/. Locally that is http://localhost:5002 (plain HTTP; launchSettings.json binds no TLS);
deployed it is https://api.balinyaar.ir, which Caddy terminates and forwards to balinyaar-api:8080.
gRPC exists and the client does not use it. Baya.Web.Plugins.Grpc serves exactly one service (User)
over HTTP/2 on the same port. Nothing in client/ speaks it. Treat it as an internal affordance.
Two OpenAPI documents are served, v1 and v1.1. All 55 controllers declare [ApiVersion("1")], and
ApiVersionDocumentProcessor keeps only paths whose URL contains the document's version segment — so
v1.1 is served but contains zero paths. v1 is the contract.
The envelope
Every response — success and failure — is ApiResult. The payload is always under data.
{ "isSuccess": true, "statusCode": 200, "message": "Success",
"requestId": "0af7651916cd43dd8448eb211c80319c", "code": null, "data": { } }
requestId is the W3C trace id of the request (Activity.Current.TraceId), the same id
OpenTelemetry traces on — so a support ticket maps 1:1 to a trace. code is an optional stable
machine-readable error code (otp_locked, …), omitted from the wire when null.
The client never unwraps centrally: clientFetch/serverFetch return the raw envelope, and each
service calls unwrap() from client/src/lib/api/types.ts.
Casing — camelCase bodies, snake_case URLs
Verified mechanically against the live swagger: of 427 distinct property names, 0 contain an underscore
and 0 are PascalCase. JSON bodies are camelCase. URL segments are snake_case, produced by the server's
SnakeCaseParameterTransformer from [controller]/[action] tokens (SetPrimary → /set_primary).
Three routes break the snake_case rule with hardcoded hyphens — admin/partner-centers, admin/tickets,
admin/reviews — see domains/index.md.
Pagination
{ items, total, page, pageSize } (total/page/pageSize are int32). 29 operations are paginated.
The declared query-param names are not uniform — 25 declare Page/PageSize, 3 declare
page/pageSize, and GET /search/nurses declares page/page_size. Model binding is
case-insensitive so the first two are interchangeable; page_size is a different name and is not.
Full table in api-contract.md.
Errors
200 · 400 validation (field errors under data) · 401 unauthenticated · 403 forbidden ·
404 not found (also returned for a tenancy mismatch, deliberately, so a 403 never confirms a row
exists) · 409 state-machine/idempotency conflict · 422 · 424 · 429 rate-limited · 5xx.
clientFetch behaviour: 401 → one silent refresh + retry, then clear cookies, toast, redirect to
login (no throw); 403 and 5xx → toast + throw ApiError; other 4xx → throw without toasting
(the calling hook owns the message); network failure → toast + ApiError(0).
Auth
Bearer header, not cookie auth. The JWE access token is stored in a cookie the client reads itself
and sent as Authorization: Bearer <token>. The server's CORS policy therefore does not allow
credentials, and the client sends no credentials: 'include'.
The token is opaque to the client (signed + AES-encrypted). Role and identity come from GET /api/v1/me;
a multi-role user picks one with POST /api/v1/me/select_role. On a 401 the client runs one
single-flight silent refresh (POST /api/v1/auth/refresh) — the server rotates the pair and detects
reuse, so a replayed refresh token kills the session. /auth/refresh, /auth/request_otp and
/auth/verify_otp are excluded from the retry.
20 of 186 operations are anonymous — the OTP pair, catalog + geo reference reads, the public nurse
search/profile/reviews/trust-badge reads, ping, the three webhooks, and Development's
GET /api/v1/dev/last_otp/{phone}. Full list in api-contract.md.
Idempotency
Idempotency-Key is a request header. The server reads it on exactly two endpoints:
| Endpoint | Key scope |
|---|---|
POST /api/v1/bookings/{bookingRequestId}/payments |
one key per payment attempt, reused across retries of that attempt |
POST /api/v1/checkout_bnpl/initiate |
same, per BNPL attempt |
The client also sends it on admin_payouts process/retry, where the server does not read it — see
domains/payouts.md. Webhooks do not use the header; they dedupe on the provider's
external_event_id. The header is allowed through CORS but is not declared in swagger (it is read
from Request.Headers, not bound as a parameter).
Money
IRR Rials, integer, no floats, anywhere. On the wire the direction matters, and it is consistent:
- Outbound (DTOs/results): a digit string. All 68 money properties on read models are
type: string. Parse with the@/utilsBigInt helpers — neverNumber(). - Inbound (commands):
integer/int64. All 3 money properties on command bodies.
Toman is display-only and is converted only inside a provider adapter at its boundary
(ICurrencyNormalizer, Seams:Currency:TomanToIrrMultiplier). gross = commission + payout always,
and VAT is on Balinyaar's commission only. See docs/rules/server/money.md
and docs/rules/client/services.md.
Enums
Swagger declares no string enums — 1 of 339 schemas has an enum, and it is the integer
ApiResultStatusCode. Every status/code field is a bare string on the wire. The vocabulary therefore
lives in the domain files here, cross-checked against the server's Baya.Domain code sets and the
client's string-literal unions. All 18 shared vocabularies match on both sides as of this stamp; the
one exception is noted in domains/tickets.md.
What the server owes the client
- The
ApiResultenvelope on every response, withrequestIdpopulated andcodeset on any failure the client must branch on. - camelCase bodies; snake_case URL segments;
{ items, total, page, pageSize }on every list. 404, never403, for a row the caller does not own.- Money as a digit string outbound; the three-amount split guaranteed server-side.
- A masked address and only unencrypted
customerNotesbefore a booking is confirmed; full care instructions only after, only to the assigned nurse and admin. is_internalfiltered at the query layer — a non-staff caller can never read or set one.- Idempotent behaviour on the two keyed endpoints, and
409(not500) on a converged replay. - Reachability:
/healthz/live,/healthz/ready, and CORS origins that list the client's real origin.
What the client owes the server
Authorization: Bearer <token>on every authenticated call — and nothing else for auth. No cookies cross the wire.Accept-Language(fadefault) on every call, from the active locale segment.Content-Type: application/json— exceptFormDatabodies, where the browser sets the multipart boundary itself.- One stable
Idempotency-Keyper payment/BNPL attempt; a new attempt gets a new key. page/pageSizewithin the server's cap; never an unbounded list request.- IRR integers inbound; Toman conversion only at the UI boundary.
- Exactly one silent refresh per 401, single-flighted, and never on the refresh/OTP endpoints.
- No derived money. The client never computes commission, VAT, refund amounts or payout dates.