3a51305343
- six REST endpoints (auth/request_otp, verify_otp, refresh, logout, me, me/select_role) wrapping the existing JWE/TOTP/RBAC engine - usr.UserSessions with refresh-token rotation + stolen-token (replay) detection → revoke-all + 401; logout rotates the security stamp - users extended: gender, national_id (enc, NULL until KYC), shahkar_verified_at (auto-reset on phone change), phone_hash UNIQUE, is_active, deleted_at + soft-delete filter; phone/email/national_id encrypted at rest via IFieldEncryptor value converter - user_roles grant/revoke audit trail + global revoked filter; 7 roles seeded; admin sub-roles never self-assignable (403) - ISmsSender seam (mock logs the OTP code) replaces the TODO log lines - OperationResult/BaseController learned enveloped 401/403 - auth knobs as platform_configs rows (resend/attempts/session TTL) - migration IdentitySessionsAndUserExtensions applied to the dev DB - 24 new tests incl. Baya.Test.Api (WebApplicationFactory over SQLite); 47 total green, zero new build warnings; swagger snapshot + contract (identity-auth.md), handoff, report, mocks-registry updated Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
261 lines
16 KiB
Markdown
261 lines
16 KiB
Markdown
# Balinyaar Server — Claude Code Guidelines
|
|
|
|
The backend API of **Balinyaar**, a trust-first home-nursing marketplace in Iran.
|
|
|
|
- **Coding rules** (the full rule set you must follow) → [CONVENTIONS.md](CONVENTIONS.md). Read it
|
|
before writing any server code.
|
|
- Repo-wide context and the frontend → root [CLAUDE.md](../CLAUDE.md).
|
|
- Product/domain rules (business logic, schema, payments, escrow, verification) → [`product/`](../product/).
|
|
Read the relevant doc before designing an entity, feature, or endpoint — don't infer business rules
|
|
from code.
|
|
|
|
---
|
|
|
|
## Role
|
|
|
|
You are a **senior .NET software engineer** working on this codebase. That means:
|
|
|
|
- You write production-quality code, not demo code. Every file you touch should look like it was
|
|
written by someone who has shipped .NET APIs at scale.
|
|
- You understand the architecture and work _with_ it, not around it. Clean Architecture boundaries
|
|
are non-negotiable.
|
|
- You think before you write. If a task is ambiguous, reason through the design first. If it touches a
|
|
contract other layers depend on, think about downstream impact.
|
|
- You prefer simplicity and clarity over cleverness. The next engineer (or agent) should read your
|
|
code without a guide.
|
|
- You never leave the codebase in a worse state than you found it.
|
|
|
|
---
|
|
|
|
## Stack
|
|
|
|
- **ASP.NET Core / .NET 10** (`net10.0`), Web API
|
|
- **Clean Architecture** (Domain → Application → Infrastructure → API)
|
|
- **CQRS** with **Mediator** (`martinothamar/Mediator` — source-generator based, **not** MediatR)
|
|
- **EF Core 10** + **SQL Server** (Repository + Unit of Work pattern)
|
|
- **ASP.NET Core Identity** with **JWE** (signed + AES-128-encrypted JWT), OTP, and dynamic permission authorization
|
|
- **Mapster** for mapping, **FluentValidation** for validation, **Serilog** for structured logging
|
|
- **OpenTelemetry** + **prometheus-net** for observability, **NSwag** for OpenAPI, **Asp.Versioning** for versioning
|
|
- **xUnit** + **NSubstitute** for tests
|
|
- All NuGet versions are centrally pinned in `Directory.Packages.props`
|
|
|
|
> Note: some prose elsewhere may say "MediatR" — the actual dispatcher is `martinothamar/Mediator`.
|
|
> Use `ISender`/`ICommand`/`IQuery` from that package, not MediatR types.
|
|
|
|
---
|
|
|
|
## Commands (run from `server/`)
|
|
|
|
| Task | Command |
|
|
| ----------------- | ------- |
|
|
| Restore | `dotnet restore Baya.sln` |
|
|
| Build | `dotnet build Baya.sln` |
|
|
| Run API | `dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj` |
|
|
| Test | `dotnet test Baya.sln` |
|
|
| Add migration | `dotnet ef migrations add <Name> --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api` |
|
|
| Update DB | `dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api` |
|
|
|
|
**Default URL:** `https://localhost:5002` — Swagger at `/swagger`.
|
|
On boot, `Program.cs` calls `ApplyMigrationsAsync()` and `SeedDefaultUsersAsync()` — a reachable SQL
|
|
Server is required to start.
|
|
|
|
---
|
|
|
|
## Quality gates — run before declaring work done
|
|
|
|
1. `dotnet build Baya.sln` — zero new warnings introduced. Unused `using`s, locals, parameters,
|
|
private fields, or members count as failures — delete them, don't suppress them
|
|
([CONVENTIONS.md](CONVENTIONS.md) §2 "No unused code").
|
|
2. `dotnet test Baya.sln` — all tests pass.
|
|
3. Read your own diff as if reviewing a PR: would a senior engineer approve it without comment?
|
|
4. If the change alters the architecture, update the **Project map** below in the same change
|
|
(see "Keeping the Project map current").
|
|
|
|
---
|
|
|
|
## Project map
|
|
|
|
This tree is the **canonical description of the server's architecture** — the authoritative list of
|
|
projects/assemblies, Clean-Architecture layers, and cross-layer dependencies.
|
|
|
|
```
|
|
src/
|
|
├── Core/
|
|
│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker)
|
|
│ └── Baya.Application Features/ (Commands & Queries; + Identity/Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams + the platform-signal facade contracts), Models/, pipeline behaviors (Common/)
|
|
├── Infrastructure/
|
|
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service)
|
|
│ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/)
|
|
│ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender) + AddCrossCuttingSeams
|
|
│ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net
|
|
├── API/
|
|
│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications), appsettings*.json
|
|
│ ├── Baya.WebFramework BaseController (incl. 401/403 OperationResult mapping), Filters/, Middlewares/, Swagger/, Routing/, ServiceConfiguration/ (rate limiting)
|
|
│ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models (User only)
|
|
├── Shared/Baya.SharedKernel Extensions + validation base
|
|
└── Tests/
|
|
├── Baya.Tests.Setup Shared test infrastructure (SQLite, NSubstitute setup, TestFieldEncryptor)
|
|
├── Baya.Test.Infrastructure.Identity xUnit identity tests
|
|
├── Baya.Test.Foundation xUnit tests for cross-cutting plumbing + identity handler unit tests
|
|
└── Baya.Test.Api WebApplicationFactory integration tests (full HTTP pipeline over in-memory SQLite, env "Testing")
|
|
```
|
|
|
|
**Dependency direction points inward.** Domain has no dependencies. Application depends only on
|
|
Domain. Infrastructure and API implement/consume Application contracts. Never make Domain or
|
|
Application reference Infrastructure or the API — this is a hard rule.
|
|
|
|
**Cross-cutting seams.** Application defines mock-able external dependencies as interfaces in
|
|
`Contracts/Common/` (`IDateTimeProvider`, `IFieldEncryptor`, `ICacheService`, `IObjectStorage`,
|
|
`INotificationDispatcher`, plus `ICurrentUser`). Their in-memory/local mock implementations live in
|
|
`Baya.Infrastructure.CrossCutting/Seams/` and are registered by `AddCrossCuttingSeams(configuration)`
|
|
(config section `Seams`); `ICurrentUser` is registered in the Identity layer. Swapping a mock for a
|
|
real provider is a registration change — handlers depend only on the contract. Audit fields are
|
|
stamped by `AuditFieldInterceptor` (Persistence), not in handlers.
|
|
|
|
**Platform-signal facades (backend-phase-1).** The cross-cutting marketplace tables live in a dedicated
|
|
**`ops` schema** (mirroring how Identity uses `usr`): `PlatformConfigs`, `AuditLogs`, `SystemEvents`,
|
|
`IranianHolidays`, `Notifications`, `SupportAlerts`. Because they are DB-backed, their Application
|
|
contracts — `IPlatformConfig` (typed cached config), `IHolidayCalendar` (bank-closure calendar),
|
|
`IAnalyticsSink` (fire-and-forget `system_events`), `IAuditLogger` (explicit append-only writes +
|
|
trail), `INotificationService` (per-user notification reads/commands), `ISupportAlertService` (internal
|
|
worklist) — are implemented in **`Baya.Infrastructure.Persistence/Services/`** and registered by
|
|
`AddPersistenceServices`, *not* in CrossCutting. The real `INotificationDispatcher` (in-app
|
|
`notifications` write) also lives there and **supersedes** the b0 log stub. The
|
|
`NotificationRetentionHostedService` (the retention/`IJobScheduler` seam) is registered as a hosted
|
|
service there too. Other domains call these contracts; they never re-create the tables. The
|
|
`AuditFieldInterceptor` additionally writes an append-only `audit_logs` row for any `IAuditable` entity
|
|
(currently `PlatformConfig`) in the same transaction as the change.
|
|
|
|
**Keeping the Project map current.** When a change touches the architecture — adds, removes, or
|
|
renames a project/assembly, a Clean-Architecture layer, or a major folder, or changes a cross-layer
|
|
dependency — you **must** update this Project map (and the dependency rule above, if affected) in the
|
|
**same** change. This is the server-specific form of the root "Keep docs honest" rule: the map is
|
|
only canonical if it stays accurate.
|
|
|
|
---
|
|
|
|
## Startup wiring
|
|
|
|
Service registration is composed from per-layer extension methods (each project's `ServiceConfiguration/`):
|
|
|
|
```
|
|
ConfigureHealthChecks() · SetupOpenTelemetry()
|
|
AddApplicationServices() // Mediator + pipeline behaviors (Logging → Metrics → Validate)
|
|
RegisterIdentityServices(...) // Identity, JWT/JWE, authorization policies, ICurrentUser + IHttpContextAccessor
|
|
AddPersistenceServices(...) // DbContext (+ AuditFieldInterceptor), UnitOfWork, repositories
|
|
AddCrossCuttingSeams(config) // IDateTimeProvider, IFieldEncryptor, ICacheService, IObjectStorage, INotificationDispatcher (mocks)
|
|
AddWebFrameworkServices() // API versioning + snake_case routing
|
|
AddRateLimitingPolicies() // built-in rate limiter: per-IP global + named (otp/auth/sensitive)
|
|
AddSwagger("v1", "v1.1") · RegisterValidatorsAsServices() · AddMapster()
|
|
ConfigureGrpcPluginServices()
|
|
```
|
|
|
|
Pipeline order: exception handler → Swagger → routing → **rate limiter → authentication →
|
|
authorization** → controllers → metrics → health checks → gRPC. `UseRateLimiter()` is placed
|
|
**before** `UseAuthentication()` so over-limit auth/OTP attempts are rejected (`429`) before hitting
|
|
the auth stack.
|
|
|
|
When adding new infrastructure, expose it as an extension method and call it from `Program.cs` —
|
|
never inline registrations there directly.
|
|
|
|
---
|
|
|
|
## CQRS — how a feature is shaped
|
|
|
|
Features live under `Baya.Application/Features/<Area>/{Commands|Queries}/<Name>/`:
|
|
|
|
```
|
|
Features/<Area>/
|
|
├── Commands/<VerbNoun>Command/
|
|
│ ├── <VerbNoun>Command.cs record : IRequest<OperationResult<T>>
|
|
│ ├── <VerbNoun>Command.Handler.cs internal sealed class : IRequestHandler<...>
|
|
│ └── <VerbNoun>Command.Validator.cs
|
|
└── Queries/<VerbNoun>Query/
|
|
├── <VerbNoun>Query.cs
|
|
├── <VerbNoun>Query.Handler.cs
|
|
└── <VerbNoun>Query.Result.cs
|
|
```
|
|
|
|
A minimal live example shipped in backend-phase-0: `Features/System/Queries/Ping/` (query + handler +
|
|
result), surfaced by `Controllers/V1/PingController`.
|
|
|
|
Handlers are `internal sealed`. Requests are `record` types. Validators use FluentValidation and are
|
|
picked up automatically by the `ValidateCommandBehavior` pipeline behavior. Never throw for expected
|
|
failures — use `OperationResult` factory methods.
|
|
|
|
**To add a feature:** create the folder, implement request + handler + (optional) validator, add any
|
|
new contracts to `Application/Contracts/` and implement them in Infrastructure, then wire a controller
|
|
action to `sender.Send(...)`. Full conventions are in [CONVENTIONS.md](CONVENTIONS.md) §5.
|
|
|
|
---
|
|
|
|
## Persistence
|
|
|
|
- Access the DB through `IUnitOfWork` — not `ApplicationDbContext` directly outside Infrastructure.
|
|
- Commit once per command via `unitOfWork.CommitAsync()`.
|
|
- Use `AsNoTracking()` on all read-only queries.
|
|
- Always project to a DTO in queries — never return entity objects from handlers.
|
|
- Add entity config in `Persistence/Configuration/<Area>Config/` implementing `IEntityTypeConfiguration<T>`.
|
|
- Soft delete is enforced via a global query filter per entity (see [CONVENTIONS.md](CONVENTIONS.md) §6).
|
|
|
|
---
|
|
|
|
## Identity & auth
|
|
|
|
- JWT/JWE issued by `IJwtService` (`Baya.Infrastructure.Identity/Jwt/JwtService.cs`).
|
|
`GenerateAccessTokenAsync` mints an access token only (the REST flow); the legacy `GenerateAsync`
|
|
additionally writes a `UserRefreshTokens` row and still feeds the gRPC path.
|
|
- **Phone-OTP is the public login** (backend-phase-2): `Controllers/V1/AuthController`
|
|
(`request_otp`/`verify_otp`/`refresh`/`logout`) + `MeController` (`/me`, `select_role`) drive the
|
|
`Features/Identity/` slices. OTP delivery goes through the **`ISmsSender`** seam (mock
|
|
`LoggingSmsSender` in CrossCutting logs the code; registered in `AddCrossCuttingSeams`).
|
|
- **Sessions & rotation:** every login creates a revocable `usr.UserSessions` row storing only the
|
|
refresh token's `IFieldEncryptor.Hash`. Refresh rotates (old session revoked, new pair issued);
|
|
a replayed/revoked token revokes **all** the user's sessions and returns 401. Logout revokes the
|
|
session **and** rotates the security stamp so outstanding access tokens fail the JWE
|
|
`OnTokenValidated` stamp check.
|
|
- **Encrypted PII:** `users.PhoneNumber/Email/NationalId` are encrypted at rest via an EF value
|
|
converter over `IFieldEncryptor` (wired in `ApplicationDbContext`; the encryptor must stay a
|
|
process-wide singleton because EF caches the model). Equality lookups go through the deterministic
|
|
`PhoneHash` column (UNIQUE, synced on SaveChanges — which also resets `ShahkarVerifiedAt` when the
|
|
phone actually changes). Never query `PhoneNumber == x`.
|
|
- **Roles:** full vocabulary in `Domain/Entities/User/RoleNames` (seeded by `SeedDataBase`).
|
|
`customer`/`nurse` are self-selectable via `POST me/select_role` (audited
|
|
`granted_by`/`granted_at`, idempotent, both can be held); admin sub-roles are internal-only and
|
|
return 403 there. `user_roles.revoked_at` has a global query filter, so revoked grants disappear
|
|
from every role read automatically. Auth knobs (`auth_otp_resend_seconds`, `auth_otp_max_attempts`,
|
|
`auth_session_ttl_days`) are `platform_configs` rows read via `IPlatformConfig`.
|
|
- 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`.
|
|
- Auth and OTP endpoints must be rate-limited (CONVENTIONS.md §11) — `request_otp`/`verify_otp` use
|
|
the `otp` policy, `refresh` the `auth` policy; plus a per-phone resend window via `ICacheService`.
|
|
|
|
---
|
|
|
|
## Conventions — quick reference
|
|
|
|
Full rules in [CONVENTIONS.md](CONVENTIONS.md). The essentials:
|
|
|
|
- All URL segments are `snake_case` via `SnakeCaseParameterTransformer` — use `[controller]`/`[action]` tokens.
|
|
- Controllers are `sealed`, inherit `BaseController`, inject `ISender`, return `base.OperationResult(result)`.
|
|
Never call `Ok()` / `BadRequest()` / `NotFound()` directly.
|
|
- Handlers are `internal sealed`; never throw for expected failures — return `OperationResult`.
|
|
- `record` for requests/DTOs, `class` for entities (no public setters), `sealed class` for handlers/services.
|
|
- `async`/`await` all the way; pass `CancellationToken` through every async call; never `.Result`/`.Wait()`/`async void`.
|
|
- Mapster for mapping; FluentValidation for validation (validate at the boundary).
|
|
- Package versions live **only** in `Directory.Packages.props` — never `Version=` in a `.csproj`.
|
|
- No unused code (usings, locals, parameters, private fields/members) and no *what*-comments — explain *why*, prefer self-documenting names (§2).
|
|
- Architecture changes (a project/layer/major folder or a cross-layer dependency) must update the **Project map** in the same change.
|
|
- The `Baya.*` namespace is project naming — do not rename without explicit instruction.
|
|
|
|
---
|
|
|
|
## Known build warnings (pre-existing — do not fix unless tasked)
|
|
|
|
| Warning | Project | Note |
|
|
| ------- | ------- | ---- |
|
|
| `NU1510` on `Microsoft.Extensions.Logging.Debug` | `Baya.Web.Api` | Redundant transitive reference, harmless |
|
|
| `NETSDK1057` (preview SDK) | all | .NET 10 SDK is preview on this machine |
|