Development
How the code is organised, how to build and test it, and the conventions a change is held to. CONTRIBUTING.md is the short version for pull requests.
Setup
dotnet restore
npm --prefix frontend ci
cp Nelsar.Axia.Api/appsettings.json.example Nelsar.Axia.Api/appsettings.json
cp Nelsar.Axia.Api/appsettings.Development.json.example Nelsar.Axia.Api/appsettings.Development.json
docker compose -f deploy/dev/docker-compose.ldap.yml up -d --wait
Then configure the API for the local directory as in Installation, path 3, and run:
dotnet run --project Nelsar.Axia.Api --launch-profile http # API on :5063, Swagger at /swagger
npm --prefix frontend run dev # console on :5500
.claude/launch.json describes the console’s dev server for tools that read it.
Architecture
Api ──► Application ──► Domain
│ ▲
└──► Infrastructure ──┘ (all use Contracts)
| Project | Holds | May depend on |
|---|---|---|
Nelsar.Axia.Domain | Entities (RoleMapping, Provider, …), value objects (DistinguishedName, Email, EncryptedSecret), the permission catalogue, pure services (UserPatternGenerator) | nothing |
Nelsar.Axia.Contracts | DTOs, request/response types, module interfaces (IUsersModule, …), exceptions that cross layers | nothing |
Nelsar.Axia.Application | Use cases as MediatR commands and queries, validators, the pipeline behaviours, permission and role resolution, PrivilegeGuard | Domain, Contracts |
Nelsar.Axia.Infrastructure | LDAP providers, DefaultLdapProviderFactory, EF Core AppDbContext and AxiaDatabase, AES-GCM encryption, JWT issuing, the bulk job worker | Application, Domain, Contracts |
Nelsar.Axia.Migrations.PostgreSql, .Sqlite | Each database’s migrations; see Database migrations | Infrastructure |
Nelsar.Axia.Api | Controllers, middleware (correlation id, provider header, exception handler), CORS, rate limiting, Serilog setup, startup | all |
LayerBoundaryTests enforces these.
A request’s path
HTTP ─► CorrelationIdMiddleware ─► CORS ─► rate limiter ─► JWT auth ─► ProviderHeaderMiddleware
─► controller ([Authorize(Policy = permission)]) ─► module (IUsersModule …) ─► MediatR
─► LoggingBehaviour ─► AuditBehaviour ─► AuthorizationBehaviour ─► ValidationBehaviour
─► handler ─► PrivilegeGuard (for writes to accounts/groups) ─► ILdapService ─► provider ─► directory
- Authorization is checked twice on purpose: the controller policy, and
AuthorizationBehaviourfrom the request’sIRequirePermission.RequiredPermission. The second is the one that counts; it also covers requests not coming through HTTP (queued bulk jobs, run as the person who queued them). - Audit sits outside authorization, so refused attempts are recorded. Commands opt in with
IAuditedCommand; a test requires every command to. - Errors are domain exceptions (
DomainValidationException→ 400,DomainNotFoundException→ 404,DomainConflictException→ 409,PermissionDeniedException→ 403,DirectoryUnavailableException→ 502,FeatureNotImplementedException→ 501), mapped centrally byGlobalExceptionHandler. Controllers do not catch.
Directory access
ILdapService (Application) → ILdapProviderFactory picks the provider for the request (header, session, cache) → StandardLdapProvider (Novell) or ActiveDirectoryProvider (System.DirectoryServices). Everything directory-specific lives in the providers: filters, escaping, attribute names, password write mode, paging.
Frontend
React 19 + TypeScript (strict) + Vite. Logic in hooks, markup in presentational components, wiring in containers. DTOs in src/api/dto.ts mirror Nelsar.Axia.Contracts by hand. Details: frontend/README.md.
Building and testing
What CI runs:
dotnet build Nelsar.Axia.sln -warnaserror
dotnet test
npm --prefix frontend run build # typecheck + production build
The build must be warning-free. CI also runs the Docker quick start end to end, CodeQL, and fails on dependencies with known high-severity advisories.
Tests (xUnit, ~780) cover, beyond units:
| Area | Examples |
|---|---|
| Architecture | LayerBoundaryTests |
| Authorization of every use case | PermissionAuthorizationTests, UseCaseAuthorizationTests, BulkPermissionTests, PrivilegeGuardTests |
| Directory isolation | MultiDomainIntegrationTests, SelectableProvidersTests, DefaultLdapProviderFactoryTests |
| Security settings | DeploymentHardeningTests, CorsPolicyTests, ReverseProxyOptionsTests, DirectoryTlsTests, JwtSettingsTests |
| What reaches logs | LogMaskingTests, ObservabilityTests, SecretAndFilterTests |
| HTTP behaviour | *ControllerIntegrationTests (via ApiTestFactory, environment Testing) |
| Translations | ResourceMessageTests |
| Databases: the provider setting, each one’s migrations, moving between them | DatabaseTests (SQLite), PostgreSqlTests (a real server; skipped unless AXIA_TEST_POSTGRES names one, which CI does) |
To run the PostgreSQL tests locally, against the server in deploy/dev or any other the account may create databases on:
AXIA_TEST_POSTGRES="Host=localhost;Username=nelsar_axia;Password=nelsar-axia-dev;Database=nelsar_axia" dotnet test --filter PostgreSqlTests
The integration tests read Nelsar.Axia.Api/appsettings.json if present. One test expects Ldap:Server to be unset; see Troubleshooting → Tests.
There are no tests against a live directory; LDAP is faked at the provider boundary. The local test directory is for manual and exploratory testing.
Database migrations
The model — AppDbContext and the configurations under Persistence/Configurations — is one, and does not know which database it is on. The migrations are per database, because a migration is SQL for one engine: Nelsar.Axia.Migrations.PostgreSql/Migrations and Nelsar.Axia.Migrations.Sqlite/Migrations. AxiaDatabase picks the set that Database:Provider names, and the API applies it at startup.
A change to the model needs a migration in each, under the same name. Each project is its own startup project, with a DesignTimeContextFactory that knows its database, so nothing needs to be running:
dotnet tool install -g dotnet-ef # once
dotnet ef migrations add <Name> --project Nelsar.Axia.Migrations.PostgreSql --startup-project Nelsar.Axia.Migrations.PostgreSql
dotnet ef migrations add <Name> --project Nelsar.Axia.Migrations.Sqlite --startup-project Nelsar.Axia.Migrations.Sqlite
EachDatabasesMigrations_DescribeTheModel fails when either set is behind the model. Read what was generated: the two will differ where the engines do. Data migrations written as SQL have to be written for each.
A migration that changes behaviour for existing databases (as RevokeAccidentalDefaultGrants and AddProviderSecurity did) needs an upgrade note in the README.
Another database
DatabaseProvider lists the databases. To add one: its EF Core provider package in Infrastructure, a value in the enum and a case in AxiaDatabase.UseAxiaDatabase, a Nelsar.Axia.Migrations.<Name> project made like the other two with its own DesignTimeContextFactory, referenced from the API and copied in the Dockerfile, and its initial migration. Anything one engine needs and another must not have goes in AppDbContext.OnModelCreating, behind Database.Is…(), as the SQLite audit timestamp and PostgreSQL’s UTC conversion do. copy-database moves an existing deployment onto it.
Adding things
An endpoint
- A request/response type in
Contractsif it crosses the boundary. - A command or query in
Application/Features/<Area>,internal, implementingIRequirePermission(andIAuditedCommandfor a change), with a FluentValidation validator. - A module method in
Application/Modules, and a controller action with[Authorize(Policy = Permissions.X)]. - Authorization tests (refused when signed out, refused without the permission, the refusal does not name it), and behaviour tests.
- User-facing text in the
.resxresources, all languages. - The DTO in
frontend/src/api/dto.tsand a method infrontend/src/api/index.ts. - This documentation: api.md and features.md.
A permission
Add it to Domain/Authorization/Permissions.cs (and All), decide its default grants in DbInitializer.DefaultGrants, and remember that existing databases do not get new default grants automatically: it needs a migration or an upgrade note. Update access-control.md.
A setting
An options class with a SectionName, validation (IValidateOptions, ValidateOnStart) that names the key and the fix, the .example file, and configuration.md.
A language
One frontend/src/lib/i18n/messages.<code>.ts (typed against English, so a missing key fails tsc) and three .resx files (ApplicationMessages, ApiMessages, DomainMessages).
Conventions
The ones a test enforces are in CONTRIBUTING.md. The rest, briefly:
- No user-facing text in code; no logic that compares translated text.
- Comments and commit messages in English; commit messages say why.
- No real host names, domains, logins or passwords anywhere in the repository, tests or comments. Use
example.com/example.org. - CSS: logical properties only (
margin-inline-start, notmargin-left), for right-to-left languages. - A behaviour change comes with a test that fails without it.