Files
baya-monorepo/server/CLAUDE.md
T
hamid 3a51305343 backend phase 2: identity — phone-OTP auth, sessions & roles (REST)
- 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>
2026-07-02 02:34:11 +03:30

16 KiB

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. Read it before writing any server code.
  • Repo-wide context and the frontend → root CLAUDE.md.
  • Product/domain rules (business logic, schema, payments, escrow, verification) → 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 usings, locals, parameters, private fields, or members count as failures — delete them, don't suppress them (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 §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 §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.jsonIdentitySettings.
  • 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. 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