# RB-15 — Swagger and the OpenAPI document behind `IsDevelopment()` Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-015 · `99-backlog.md` RB-15 ## What was wrong `Program.cs:145-146` (pre-change) ran `app.UseSwagger(); app.UseSwaggerUI();` unconditionally — the full OpenAPI document (every route, every request/response shape) and SwaggerUI's interactive "Try it out" were reachable in every environment, including a real deployment, with no `app.Environment.IsDevelopment()` guard. BIO-015's own evidence notes this is one line of genuine attack-surface reduction with no POC cost. ## What changed | File | Change | | --------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | `Program.cs` | `app.UseSwagger(); app.UseSwaggerUI();` now run only inside `if (app.Environment.IsDevelopment()) { … }` | | `tests/BigRegister.Tests/SwaggerGateTests.cs` | **new** — asserts `/swagger/v1/swagger.json` is served in Development and 404s outside it | `builder.Services.AddSwaggerGen(...)` and `AddEndpointsApiExplorer()` were left unconditional — they only register DI services (the swagger-generation machinery), expose nothing over HTTP by themselves, and (see below) are exactly what `npm run gen:api` depends on staying registered in every environment it might run against. ## The hazard, checked rather than assumed RB-09 made a non-Development environment throw during `builder.Build()` (no `IIdentityProvider` registered for a bare/unset environment, which defaults to Production), which crashed `dotnet swagger tofile` until `package.json`'s `gen:api` script was pinned to `ASPNETCORE_ENVIRONMENT=Development` for that one invocation (`docs/.../implementation/rb-09.md`). This ticket's change sits in exactly the same pipeline, so it needed the same empirical check, not an assumption. **Mechanism, confirmed by reading Swashbuckle's CLI behaviour and then proving it:** `dotnet swagger tofile` (`Swashbuckle.AspNetCore.Cli`) loads the built DLL through .NET's design-time `HostFactoryResolver`, builds the host, and then resolves `ISwaggerProvider` **directly out of the DI container** to produce `swagger.json` — it never issues an HTTP request through the ASP.NET Core middleware pipeline this ticket's `if (app.Environment.IsDevelopment())` guard lives in. Gating `UseSwagger()`/ `UseSwaggerUI()` therefore cannot affect it, in any environment, by construction — those are pipeline middleware; the CLI tool bypasses the pipeline entirely. **Verified, not assumed:** ran `npm run gen:api` for real. It exited 0, printed "Swagger JSON/YAML successfully written to …/backend/swagger.json", and regenerated the NSwag client. `git status`/`git diff` on both `backend/swagger.json` and `libs/shared/src/infrastructure/api-client.ts` showed **zero changes** — the regenerated files are byte-identical to what's already committed, confirming the gate has no effect on the generated contract at all. ## Judgement calls - **The guard wraps both `UseSwagger()` and `UseSwaggerUI()` together**, not just one — the ticket's own wording lists both, and gating only the document while leaving the UI reachable (or vice versa) would be a strange half-measure: SwaggerUI without the document 404s on load anyway, and the document without the UI still leaks the same route/shape enumeration BIO-015 is about. - **`AddSwaggerGen`/`AddEndpointsApiExplorer` were left unconditional.** They're DI-registration-time calls with no HTTP surface, and — now confirmed rather than assumed — `dotnet swagger tofile` needs `ISwaggerProvider` registered in whatever environment it runs the host under (pinned to Development by `gen:api`'s own script, but nothing stops a future non-Development invocation), so conditioning those registrations on `IsDevelopment()` would risk breaking the CLI tool for no attack-surface benefit — nobody can reach a DI-registered-but-never-routed service over HTTP. - **New tests build the "non-Development" case on a third environment name ("Staging"), not `"Production"`.** RB-09 already made Production fail at startup entirely (no real `IIdentityProvider` exists yet) — a stronger guarantee than "no Swagger in Production," but one that means a `UseEnvironment("Production")` host never reaches this middleware to prove the gate itself works; it only proves RB-09's unrelated startup throw, which already has its own test. A `"Staging"` environment satisfies neither `IsDevelopment()` nor `IsProduction()`, so `Program.cs` registers no `IIdentityProvider` for it — the test supplies one via `ConfigureTestServices` (`StubIdentityProvider`, the same one Development uses) so the host actually boots, and the test exercises this ticket's real gate rather than a different ticket's. - **The Staging host is built via `factory.WithWebHostBuilder(...)`** (layering on the shared `TestWebApplicationFactory` fixture), not a bare `new WebApplicationFactory()` — RB-12's implementation note already records the "table already exists" collision a bare factory hits by sharing the mutable static `Db.ConnectionString` instead of the fixture's own per-class isolated temp path; layering avoids repeating that mistake here. ## Verification - **Reverted the guard only** (unwrapped `UseSwagger()`/`UseSwaggerUI()` back to unconditional, via Edit, tests left in place) and ran `SwaggerGateTests`: `Swagger_document_is_not_served_outside_development` failed red (`Expected: NotFound, Actual: OK`). Restored the fix (via Edit) and re-ran: both green. - **`npm run gen:api`, run for real**: exit 0; `backend/swagger.json` and `libs/shared/src/infrastructure/api-client.ts` both unchanged (`git status` clean on both) — see "The hazard" above. - `dotnet build` (both projects): clean, 0 warnings. - `dotnet format BigRegister.slnx --verify-no-changes`: clean. - `dotnet test --filter "Category!=Integration"`: **259 passed, 0 failed** (257 + 2 new).