- C# 92.7%
- HTML 2.5%
- CSS 2.2%
- JavaScript 1.9%
- Dockerfile 0.4%
- Other 0.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Task 221. One visual language for "are you sure?" rather than two — presentation consolidation,
not a redesign, with every site keeping its own wording, destructive styling,
disabled-while-in-flight states, focus behaviour and Escape semantics.
PlaceFormPanel's delete, GroupFormPanel's delete and PendingInvitationRow's decline all move to
the shared ConfirmationModal. GroupFormPanel's cascading place-count wording is preserved
deliberately: group deletion cascades, so that text is load-bearing rather than decoration.
ConfirmationModal grew a Busy parameter, disabling Confirm and Cancel while a mutation is in
flight. ADR-0136's own Alternatives anticipated and deferred exactly that, which review confirmed
by reading the record rather than taking it on trust, so the shared component grew instead of a
second variant being forked and no new ADR was needed.
The decline site was structurally the hardest and not a markup swap. Nesting a confirmation
inside a row that itself goes inert would have inertified the confirmation — precisely the bug
ADR-0136's SuppressedByStackedOverlay episode already found in a real browser while every bUnit
assertion passed. The confirmation and its state moved up to Account, which is now also the first
ConfirmationModal stacked over ordinary page content rather than another ModalOverlay-hosted
panel, so Account's own page content carries a new inert binding.
Four sites are excluded, each with its reason recorded in code rather than left unremarked.
PlaceForm's move confirmation stays a real form submit, because a button in a separate stacked
modal sits outside the form and reopens the second-entry-point problem ADR-0066 forbids — the
exception the ticket itself predicted. GroupMemberRow stays inline because ADR-0065 chose that
deliberately for the list-scoped case and ADR-0136 never named it among this ticket's sites;
converting it would have silently reopened a decision nobody revisited. GroupMembersPanel has no
confirm markup of its own. And Account deletion has no UI at all — only an API client method —
so the ticket's premise that it was a site to migrate was simply wrong.
Each migrated site was watched to fail on its own, as the ticket required rather than as a
representative sample: wiring the destructive action to fire without confirmation reddened
PlaceFormPanel's permanence-naming test on an unexpected DELETE, GroupFormPanel's place-count
test the same way, and Account's cancel test by making the confirmation vanish before Cancel
could be clicked.
DeclineInvitationJourneyTests is added as the one genuinely new browser-only path, since Account
is the first non-panel ConfirmationModal host and bUnit is blind to inert. It caught a real
locator bug on its first run. GroupFormPanel's delete still has no E2E coverage — a pre-existing
gap, named rather than deepened, and worth a follow-up given stacking bugs here are invisible
below a real browser.
Review found no defects, checking behaviour preservation, the Account inert stacking, Busy, and
all four exclusions against the source and the cited ADRs. It could not re-derive a per-site
mutation itself — the sandbox blocks file edits in the review worktree — and said so plainly
rather than implying otherwise.
Run #892 at
|
||
| .config | ||
| .forgejo/workflows | ||
| docs | ||
| scripts | ||
| src | ||
| tests | ||
| .dockerignore | ||
| .editorconfig | ||
| .env.example | ||
| .gitignore | ||
| CLAUDE.md | ||
| CONTRIBUTING.md | ||
| coverage-baseline.json | ||
| Directory.Build.props | ||
| docker-compose.yml | ||
| global.json | ||
| LICENSE | ||
| logo.png | ||
| PlaceMark.slnx | ||
| README.md | ||
| user-secrets.example.json | ||
PlaceMark
PlaceMark is a web app for bookmarking geographical places — a set of coordinates with a name and description. Places are organised into groups, and groups can be shared with other users, each member holding a role within the group (Owner, Editor or Viewer). Every place belongs to exactly one group; each user gets a personal default group when their account is created.
The app is currently being built from a groomed backlog. Not all of the structure below is populated yet.
Architecture
Three deployables — a Blazor WebAssembly front end, an ASP.NET Core Minimal API, and PostgreSQL — live in a single solution:
| Project | Kind | Responsibility |
|---|---|---|
PlaceMark.Domain |
Class library | Entities and domain rules. References nothing. |
PlaceMark.Infrastructure |
Class library | Data access: Dapper over Npgsql, hand-written SQL, snake_case schema. |
PlaceMark.Database |
Console command and library | The schema itself: .sql scripts embedded as resources, applied by DbUp either by running the project or by calling it, plus the provision command that creates the database roles and the seed command that fills a development database. References nothing. |
PlaceMark.Contracts |
Class library | Request and response DTOs shared by the API and the front end. No project or package references. |
PlaceMark.Api |
ASP.NET Core Minimal API | HTTP endpoints, authentication, authorisation. |
PlaceMark.WebUI |
Blazor WebAssembly | User interface; maps via Leaflet + OpenStreetMap. |
Runtime topology
A different question from the dependency diagram below, which is about what references what in the
solution — this is about what talks to what once the three deployables are actually running, and it
is the same shape whether that is dotnet run on the host or the full Compose profile's
containers (ADR-0106):
┌────────────────────────┐
│ Browser │
└────────────┬───────────┘
│ 1. GET / — static files: HTML, JS, the .wasm runtime, wwwroot/appsettings.json
▼
┌────────────────────────┐
│ PlaceMark.WebUI │
└────────────────────────┘
┌────────────────────────┐
│ Browser │
└────────────┬───────────┘
│ 2. Every call the running app makes from here on, including auth — straight
│ from the browser, cross-origin, gated by the API's own CORS allowlist
▼
┌────────────────────────┐
│ PlaceMark.Api │
└────────────┬───────────┘
│ 3. Dapper over Npgsql
▼
┌────────────────────────┐
│ PostgreSQL │
└────────────────────────┘
PlaceMark.WebUI is a static file server — the Blazor dev server locally, nginx in the full
Compose profile — that hands the browser a Blazor WebAssembly app in step 1 and then takes no further
part: there is no server-side process relaying anything to the API on the browser's behalf, which is
why step 2 is a second, separate connection the browser makes for itself. The browser is the one
caller of PlaceMark.Api. That is exactly why the API's
CORS allowlist has to name the WebUI's own origin rather
than trusting a same-process caller, and why the WebUI's own Api:BaseAddress has
to be an address the browser can reach rather than a Docker-network hostname the two containers
share.
Dependency direction
PlaceMark.Api → PlaceMark.Infrastructure → PlaceMark.Domain
PlaceMark.Api → PlaceMark.Contracts ← PlaceMark.WebUI
Project references enforce the direction itself: PlaceMark.Domain and PlaceMark.Contracts have
no project references at all, so anything pointing back at them from the layer above closes a cycle
and fails the build.
That is the only part the compiler enforces. Five further rules matter just as much and are
invisible to it — an edge between two leaves is acyclic whichever way it points, no package
reference is ever a build error, and returning an entity from an endpoint compiles without a
warning. They are enforced instead by tests/PlaceMark.Architecture.Tests. Four of the five are
read from the .csproj files themselves rather than the compiled assemblies, for the reason
ADR-0030 measures: a reference
nothing has used yet is dropped from the assembly it was compiled into, and a package reference
is not in one at all. The fifth needed a different mechanism — see below.
PlaceMark.DomainandPlaceMark.Contractsnever reference each other. Both are leaves, so an edge either way is perfectly acyclic and would compile — the build has nothing to object to. The domain models the problem; contracts model the wire. They change for different reasons and must be free to differ.PlaceMark.WebUIreachesPlaceMark.Contractsand nothing else, transitively as well as directly. Everything it references is compiled into the payload a browser downloads, soWebUI → PlaceMark.Infrastructure— which builds green — puts Npgsql there.- Infrastructure concerns stay out of the domain. Adding a NuGet package such as Dapper or
Npgsql to
PlaceMark.Domaincompiles perfectly happily, and nothing in a package's name marks it as infrastructure, so the enforced rule is that the project depends on nothing at all — a package is only the usual way in. AFrameworkReferenceputs the whole of ASP.NET Core in a class library without one, and so does building it withMicrosoft.NET.Sdk.Web, so both leaves are pinned to the bareMicrosoft.NET.Sdkand may not import a build file of their own. For the same reason the repository holds exactly one MSBuild build file —Directory.Build.propsat the root, which reaches every project and so may declare nothing any of them could not — and a second.props,.targets,.rspor.useranywhere in the tree fails the same test. What counts as being in the repository is git's answer, so a file your IDE writes and.gitignoreexcludes is not your problem; a committed one is. PlaceMark.Contractsdepends on nothing either, for the same reason stated the other way up: what is added there is imposed on the server and on the browser payload at once.
The fifth rule needed a different mechanism, because it is a question about compiled type
signatures rather than about the project graph, and reading a .csproj cannot answer it:
- Domain entities are never exposed over the wire. Endpoints accept and return
PlaceMark.Contractstypes and map to and from entities explicitly.PlaceMark.ApireferencesPlaceMark.Contractsdirectly and reachesPlaceMark.Domaintransitively throughPlaceMark.Infrastructure, so both are in scope and returning an entity straight from an endpoint would build without complaint.EndpointHandlersfinds every mapped handler by parsingPlaceMark.Apiwith Roslyn (Microsoft.CodeAnalysis.CSharp) for which method aMap(Get|Post|Put|Delete|Patch)call names, then reflects on that method's compiled signature — the one place in this project aProjectReferencetoPlaceMark.Api, and a dependency beyond the test framework and Shouldly, exist at all, both confined to this rule (ADR-0070, which also records why an earlier, hand-rolled version of this reader was replaced). It is not complete: anIResult-typed handler, anobjectordynamicreturn, and an anonymous type carry nothing for reflection to unwrap, so a violation behind any of those shapes would pass — named in full inEntitiesOffTheWireTests's own remarks and in ADR-0070's Consequences.
PlaceMark.WebUI is a separate deployable: it talks to PlaceMark.Api over HTTP through a single
typed HTTP client, sharing only PlaceMark.Contracts with it so that both sides compile against
the same request and response shapes rather than against two copies that can drift apart.
A sixth check, not one of ADR-0009's five, guards a narrower thing: which assemblies a project
grants InternalsVisibleTo. PlaceMark.Api grants PlaceMark.Api.Tests and
DynamicProxyGenAssembly2 (NSubstitute proxying an internal interface, NS2003) and PlaceMark.WebUI
grants PlaceMark.WebUI.Tests; InternalsVisibleToTests pins both sets exactly and asserts every
other project in the solution grants none, so widening a grant or adding one elsewhere fails until
the change is deliberate. Unlike every check above, this one reads each project's compiled assembly
manifest rather than its .csproj — the item form, a hand-written [assembly: InternalsVisibleTo(...)] attribute, an AssemblyAttribute item, and a grant inherited from
Directory.Build.props all compile to the same manifest entry, and a .csproj-only reading missed
two of those four in review. ADR-0074
records the choice, and why it is not a contradiction of ADR-0030's own "read the project files"
rule: that rule's objection was that an unused ProjectReference never reaches the compiled output,
so the manifest under-reports the project graph; an InternalsVisibleTo grant always reaches the
manifest, because it has no effect otherwise, so for this one question the manifest is the complete
account and the .csproj is the partial one.
Adding an API endpoint
Program.cs composes the API; it does not describe it. Every route lives in a feature folder under
src/PlaceMark.Api/, holding one static class with one extension method on IEndpointRouteBuilder
that opens a MapGroup for the feature's prefix and maps its routes inside it.
Health/HealthEndpoints.cs is the worked example — copy its shape:
namespace PlaceMark.Api.Places;
internal static class PlacesEndpoints
{
public static IEndpointRouteBuilder MapPlacesEndpoints(this IEndpointRouteBuilder endpoints)
{
var places = endpoints.MapGroup("/places").WithTags("Places");
// Combined with the group's prefix, this is GET /places/{id}.
places.MapGet("/{id:guid}", GetPlace);
return endpoints;
}
/// <summary>Returns the place with the given id.</summary>
/// <param name="id">The place's id.</param>
/// <response code="200">The place.</response>
/// <response code="404">No place has that id, or it is in a group you cannot see.</response>
internal static async Task<Ok<PlaceResponse>> GetPlace(Guid id, CancellationToken cancellationToken) => …
}
Then add one line — app.MapPlacesEndpoints(); — beside the others in Program.cs. The group is
the point: a policy, filter or rate limit declared on it is inherited by every route added to the
feature afterwards, so it cannot be forgotten. Anything that applies to the API as a whole rather
than to one feature belongs in Program.cs, so the feature files stay ignorant of it.
The endpoint returns PlaceMark.Contracts types, never entities — see the dependency rules above.
Documenting an endpoint
The API describes itself at /openapi/v1.json, with Swagger UI over it at /swagger. Both are
served in every environment except Production, where neither is registered and both addresses
404. Nothing has to be added to Program.cs for a new endpoint to appear in either.
The prose comes from XML doc comments, read at build time and published as written. Four rules follow from that, and three of them fail silently when broken — ADR-0029 has the measurements:
- A handler is a named method, at least
internal. A lambda has nowhere to put a doc comment, and aprivatehandler is skipped by the generator without a diagnostic — the operation is published with no summary and nothing says so. <summary>becomes the operation summary;<response code="404">becomes that status code's description. Both are what a reader of the UI sees against the endpoint.<remarks>becomes the operation's description, so it is read by every caller of the API. A note addressed to the next person editing the file goes in an ordinary//comment, outside the doc comment.- The group declares a tag —
.WithTags("Places")— which is how the document groups a feature's operations. Left off, the document names the class the handler happens to sit in.
Request and response types carry their own doc comments in PlaceMark.Contracts, which become the
schema descriptions. That project generates a documentation file, so an undocumented public member is
a build error: every type on the wire is described.
The UI's Authorize button holds a bearer token, which it sends as Authorization: Bearer <token>
on every request made from the page. The document declares the scheme, and an endpoint that calls
.RequireAuthorization() gets the matching security requirement on its own operation automatically —
nothing to add beside the call itself. See ADR-0034.
An endpoint that also calls .RequireGroupCapability(...) or .RequireGroupCapabilityForPlace(...)
(ADR-0043) gets a sentence stating which group role it needs appended to its own description
automatically, the same way — nothing to add beside that call either. The bearer requirement above
only ever says a token is needed; this is the part of an endpoint's authentication requirements that
comes from an endpoint filter rather than IAuthorizeData, so it needed its own transformer
(GroupCapabilityDescriptionTransformer) to reach the document at all.
A snapshot of the document is also committed at docs/openapi/v1.json,
regenerated by hand, for anyone who cannot reach a Development-environment instance — which
includes the full local stack's own API container (docker compose --profile full up), since neither
it nor a real deployment sets ASPNETCORE_ENVIRONMENT, and the framework's own default is
Production. That is deliberate, not accidental: the deployed API stays closed in every environment
ADR-0111
names, and the committed snapshot is the supported answer for a reader who cannot reach a
Development-environment host. OpenApiSnapshotTests.CommittedSnapshot_MatchesTheLiveDocument
(PlaceMark.Api.Tests) checks the two agree on every run — see
ADR-0108 for why the file
exists and ADR-0111
for the check.
Requiring authentication
An endpoint that must be signed in calls .RequireAuthorization(), on its route or on its group —
the framework's default policy, "authenticated, nothing further":
var places = endpoints.MapGroup("/places").RequireAuthorization();
A caller with no token, an expired one, one signed with the wrong key, or one that fails validation
for any other reason is refused before the endpoint runs, with the same RFC 9457 Problem Details
body every other refusal carries — 401, a fixed detail that never says which defect the token
had, and, unusually for this API, a WWW-Authenticate: Bearer header, which RFC 7235 requires of a
401 and which none of this API's other refusals had reason to add.
The token carries only who the caller is — their id, read in a handler with
User.FindFirst("sub") — never a role or a group membership
(ADR-0032). An endpoint that needs
to know what the caller may do resolves that from group_memberships, through a named authorisation
policy ADR-0003 centralises. No such policy exists yet — building
the first one is task 73 onwards'. See
ADR-0034 for the rest of what was
decided validating a token: why a rejected one is answered this way rather than through
ApiExceptionHandler, and why the OpenAPI requirement above needs no separate declaration.
Validating a request
Rules live on the contract, as System.ComponentModel.DataAnnotations attributes:
public sealed record CreatePlaceRequest(
[property: Required][property: StringLength(120)] string Name,
[property: Range(-90d, 90d)] double Latitude,
[property: Range(-180d, 180d)] double Longitude);
A group whose routes accept a body declares the filter that enforces them, once, and every route added to the group afterwards inherits it:
var places = endpoints.MapGroup("/places").AddEndpointFilter<RequestValidationFilter>();
That is the whole wiring. A request that breaks a rule is refused before the endpoint runs, with the
400 described below carrying an errors member naming each field that failed. Nothing checks that a
group remembers the line, so this is one of the rules review has to hold —
ADR-0025 explains why it is a group-level
convention rather than something the framework applies for us, and what that choice cost.
A rule no attribute can state — one that needs another row, or two fields compared — is checked in
the endpoint, which throws ValidationFailedException itself for the same response.
Failing a request
An endpoint that must refuse a request throws, and the pipeline turns the exception into an
RFC 9457 Problem Details response. Three exceptions in src/PlaceMark.Api/Errors/ name the status
they want:
throw new NotFoundException("No group with that id exists."); // 404
throw new ValidationFailedException("Latitude must be between -90 and 90."); // 400
throw new ConflictException("That user is already a member of the group."); // 409
Their message is sent to the caller, as the detail of the response body — that is what they
are for, and it makes the message part of the API's public surface. Never put an internal
identifier, a connection string or a database error in one.
ValidationFailedException takes an optional dictionary of field names and messages as well, which
the response carries as an errors member alongside the detail. The validation filter above fills
it in, keyed by the name each field travels under on the wire — [JsonPropertyName] and all — so a
client can match a message to the input it came from. An endpoint refusing a request for a reason
that belongs to no particular field leaves the dictionary out. Either way the body is the same
shape.
A body the framework cannot bind at all — {"latitude":"nope"} — reaches neither the filter nor the
endpoint, and needs nothing written for it: Program.cs sets ThrowOnBadRequest, so it arrives at
the same handler and is answered with the same 400. The detail says only that the request could
not be read. What went wrong goes to the log instead, and only ever as names: the parameter, and
the JSON path that failed to bind. The parser's own message quotes the payload text it choked on, so
it is deliberately not logged.
A Content-Type naming a character set that does not exist is refused earlier still, by a
middleware before the endpoints, because that one has to be caught on the way in: an endpoint
taking no body never looks at the header, so it would otherwise fault on its own and there would be
no way to tell that fault from the caller's bad header. ADR-0025 records the measurement.
The refusals still answered with a bare status code and no body are an unsupported Content-Type
(415) and a body over Kestrel's size limit (413), because the framework refuses both without
throwing — see ADR-0025 and ticket #138.
Every other exception becomes a 500 whose body carries a status, a type, a title and the
request's traceId, and nothing drawn from the exception itself. This is the same in
Development, and there is no developer exception page — the handler answers first, deliberately,
so that one failure shape is exercised everywhere. What the page would have shown is in the console
instead: the exception, with its stack trace, logged at Error under the same traceId the
response body quotes, so pasting that id into the log finds the record.
ADR-0022 records why, what it costs, and
why the handler sits immediately inside UseHttpLogging in Program.cs.
Repository layout
PlaceMark.slnx
src/
PlaceMark.Domain/
PlaceMark.Infrastructure/
PlaceMark.Database/
Scripts/
PlaceMark.Contracts/
PlaceMark.Api/
PlaceMark.WebUI/
tests/
PlaceMark.Domain.Tests/
PlaceMark.Infrastructure.Tests/
PlaceMark.Api.Tests/
PlaceMark.WebUI.Tests/
PlaceMark.Architecture.Tests/
Architectural decision records live in docs/adr/. The database schema —
tables, columns, constraints and indexes, with the reasoning behind each — is
docs/data-model.md.
Naming conventions
- Projects are named
PlaceMark.<Area>, where<Area>is the layer or deployable (Domain,Infrastructure,Contracts,Api,WebUI). The project name, its folder name and its root namespace are always identical. - Production projects live under
src/; test projects live undertests/. - Every production project that contains behaviour has exactly one corresponding test project,
named
<Project>.Tests— for examplePlaceMark.Domainis tested byPlaceMark.Domain.Tests. There are two exceptions.PlaceMark.Contractsdeclares the wire format and holds no behaviour, so there is nothing to assert about it in isolation; it acquiresPlaceMark.Contracts.Testson the day it acquires logic, and not before.PlaceMark.Databaseis tested fromPlaceMark.Infrastructure.Tests, because what its scripts produce is only observable through a real PostgreSQL — and that project already owns the container fixture, which applies those same scripts for its own tests. A second test project would mean a second fixture and a second container to assert against the same schema. PlaceMark.Architecture.Testsis the exception in the other direction: a test project with no production project of its own, and the only one that references nothing. Its subject is the solution's shape rather than any assembly's behaviour, and it reads the.csprojfiles to judge it (ADR-0030). Referencing the projects it judges would add edges to the graph under test and would show it nothing extra.- Test naming, and which project a test belongs in, are covered in
CLAUDE.md's## Testssection rather than repeated here. - Folders within a project are named for the feature or concern they hold, not the technical
pattern — API endpoints are grouped per feature via
MapGroupextension methods rather than a monolithicProgram.cs. - British English spellings are used throughout identifiers and prose (
authorisation,initialise,colour). Third-party and framework symbols keep their own spelling. - Database objects use
snake_case, written that way in the schema scripts and bound to PascalCase members by Dapper'sMatchNamesWithUnderscores. - Architectural decisions are recorded as ADRs in
docs/adr/; see ADR-0001 for what warrants one.
Getting started
Requires the exact .NET SDK version named in global.json, not merely "a .NET 10 SDK".
rollForward is disable (ADR-0036):
a newer SDK already on PATH does not satisfy the pin, deliberately, because CI installs the
pinned version fresh on every run and the two disagreeing on an analyser rule is exactly the bug
that made this required. Install it and put it ahead of any other dotnet on PATH:
curl -fsSL https://dot.net/v1/dotnet-install.sh -o dotnet-install.sh
bash dotnet-install.sh --jsonfile global.json # installs to ~/.dotnet by default
export PATH="$HOME/.dotnet:$PATH" # put this in your shell profile too
That last line matters on any machine with a distribution-packaged dotnet already on PATH —
Linux Mint's /usr/bin/dotnet among them — because the SDK a build actually uses is whichever
dotnet resolves first, not merely whichever versions happen to be installed somewhere. Skipping
either step doesn't build against the wrong SDK: dotnet refuses to run at all, naming the
version global.json asked for, the file it read it from, and every SDK it did find, for example:
A compatible .NET SDK was not found.
Requested SDK version: 10.0.100
global.json file: /path/to/PlaceMark/global.json
Installed SDKs:
10.0.110 [/usr/lib/dotnet/sdk]
Building needs nothing else. dotnet test now needs Docker, and so does the
database: the integration tests start a throwaway PostgreSQL through
Testcontainers, on the same image docker-compose.yml
pins. They fail rather than skip when no Docker daemon is reachable — a database test that
quietly passes without a database is worth less than no test.
# Restore and build everything
dotnet build
# Run the whole test suite
dotnet test
# Run the API (from the repository root; needs a connection string — see Database, below).
# --launch-profile https matters: the front end's own committed dev config expects the API on
# https://localhost:7117, and the bare default profile only serves http://localhost:5017, which
# leaves every request from the front end failing with a network error. Both ports are served
# either way — http://localhost:5017/swagger works the same regardless — so this changes nothing
# about how you reach the API directly.
dotnet run --project src/PlaceMark.Api --launch-profile https
# Run the front end. Browse it at http://localhost:5169 — dotnet run with no profile argument
# picks the http one.
dotnet run --project src/PlaceMark.WebUI
Database
PostgreSQL runs locally under Docker Compose v2 — no database needs installing on the host.
docker compose up -d --wait # start, and block until it accepts connections
docker compose down # stop, keeping the data
docker compose down -v # stop and destroy the data
The data lives in the named volume placemark_postgres-data, so it survives docker compose down
and reboots; only -v throws it away. Nothing starts the container for you, so run up again after
a reboot — deliberate, so that the repository does not hold port 5432 on days you are not using it.
It listens on 127.0.0.1 only, so it is not reachable from other machines. The database, username
and password all default to placemark, on port 5432 — and that account is the cluster's
superuser, which nothing but provisioning ever connects as:
Host=127.0.0.1;Port=5432;Database=placemark;Username=placemark;Password=placemark
Provision the roles
Do this once, on a freshly created database, before applying the schema. It creates the three login roles everything else connects as, each holding only what its own job needs (ADR-0031):
| Role | Used by | Holds |
|---|---|---|
placemark_app |
the API | SELECT, INSERT, UPDATE, DELETE on the tables — no CREATE, no ownership |
placemark_upgrade |
the schema upgrade command | the right to create and own everything in the database |
placemark_seed |
the development seed command | SELECT, INSERT and MAINTAIN — enough to take the seed guard's lock, not enough to change or remove a row (ADR-0031 records what MAINTAIN does reach) |
ConnectionStrings__PlaceMarkProvisioningTarget='Host=127.0.0.1;Port=5432;Database=placemark;Username=placemark;Password=placemark' \
PLACEMARK_APP_ROLE_PASSWORD='placemark_app' \
PLACEMARK_UPGRADE_ROLE_PASSWORD='placemark_upgrade' \
PLACEMARK_SEED_ROLE_PASSWORD='placemark_seed' \
dotnet run --project src/PlaceMark.Database -- provision
The application password above is the one user-secrets.example.json carries, and the other two are
the ones the commands below use. All three are as public as the Compose credentials and are only
useful against a database bound to 127.0.0.1 on your own machine. Choose your own if you would
rather, and change the connection strings that use them to match.
PLACEMARK_SEED_ROLE_PASSWORD is optional, and leaving it out is how a production provisioning
creates no seed role at all — and how any provisioning drops one the database already had, which
the command says out loud. Absence means the same thing on every run rather than on the first only.
The connection string variable is a third one, distinct from both the schema command's and the
seed's, for the reason those two differ from each other: this credential can create roles, so nothing
already pointed at a database can reach it.
Passwords must be printable ASCII, and are refused before anything is opened otherwise: the command sends PostgreSQL a SCRAM verifier rather than the password itself, so that a failed run cannot leave a password in the server log, and the normalisation the server would apply to anything outside that range is not reproduced here.
Running it again is a password rotation rather than a failure. It does not create the database —
Compose does that, as a deployment's own provisioning would. Pointed at a database from before this
existed it will grant the application role its four privileges on the tables that are already there,
but not on the ones a later script adds, and the journal stays in public where the application can
reach it — so such a database is still best thrown away with docker compose down -v.
The API's connection string
Host=127.0.0.1;Port=5432;Database=placemark;Username=placemark_app;Password=placemark_app
That string reaches the API through user secrets, and the API now refuses to
start without it — so a fresh clone that has not applied user-secrets.example.json fails at
start-up with an instruction rather than on the first request that needs a row. Nothing connects at
start-up, though: the data source is built lazily and opens a connection when something asks for
one.
Nothing wraps a retry policy or a circuit breaker around Npgsql, deliberately. At
ADR-0007's one instance and one small database
the realistic failure is an outage or a wrong credential, which a retry turns from a fast error into
a slow one; the full argument, and what would have to change to reverse it, is in the comment beside
the registration in src/PlaceMark.Infrastructure/Database/DatabaseServiceCollectionExtensions.cs.
The schema — tables, columns, constraints, indexes and the reasoning behind each — is designed in
docs/data-model.md, created by DbUp from the .sql scripts in
src/PlaceMark.Database/Scripts/ (ADR-0021) and queried
with Dapper (ADR-0020).
The database above starts empty, so apply the scripts to it once after docker compose up and
after provisioning the roles — PlaceMark.Database is a command as well as a library, and it is the
same command CI and any deployment run
(ADR-0023):
# As placemark_upgrade: the only role that may create anything, and a credential the API never
# holds. Change the port, password or database name here too if .env changes them.
ConnectionStrings__PlaceMark='Host=127.0.0.1;Port=5432;Database=placemark;Username=placemark_upgrade;Password=placemark_upgrade' \
dotnet run --project src/PlaceMark.Database
Against the local Compose database with its default credentials, the upgrade launch profile sets
that same variable for you, so the command above shortens to:
dotnet run --project src/PlaceMark.Database --launch-profile upgrade
Only when named. dotnet run auto-selects the first profile and applies its variables over
whatever the caller already set, so the first profile in that file is deliberately empty: the bare
command still reads the caller's own environment and nothing else, exactly as it did before the file
existed. A single profile setting the connection string would have silently redirected every bare
invocation — including this repository's own CI step, which sets the variable explicitly — and did
so when this file first shipped.
The profile sets a real environment variable rather than adding a configuration source, so the rule
below still holds exactly. It cannot see .env: override POSTGRES_PORT, POSTGRES_DB or
PLACEMARK_UPGRADE_ROLE_PASSWORD there and the profile needs the same change, or use the explicit
command above instead.
That variable is the only place this command looks. It reads no appsettings.json and no user
secrets, so ADR-0014's layering — which is the API's —
does not apply to it, and it is spelt out as a decision in
ADR-0023 rather than left as an oversight.
The reason is that this command alters a database: a layered lookup would let it run with nothing
set and silently upgrade whichever database some other layer named, which is a bad way to find out
which one you were pointed at. Unset, it does nothing and says which variable to set.
It prints the full resource name of each script it applied, or says the database is already up to
date, and exits non-zero naming the failing script if one fails. Run it again after pulling a
change that adds a script; running it when there is nothing to do is a no-op, because DbUp
journals what it has applied in schema_history.schemaversions — a schema of its own, which the
application role cannot reach at all, because a credential able to delete a journal row can make the
next upgrade re-run every script (ADR-0031). To open a
session as the superuser:
docker compose exec postgres psql -U placemark -d placemark
Seed data for development
The same project has a second command, which fills a development database with something to look at: three users, a personal group each, one group they share carrying every role and an unanswered invitation, and eight places spread across both hemispheres (ADR-0026).
ConnectionStrings__PlaceMarkSeedTarget='Host=127.0.0.1;Port=5432;Database=placemark;Username=placemark_seed;Password=placemark_seed' \
dotnet run --project src/PlaceMark.Database -- seed
That is a different variable from the one above, deliberately. Nothing a host or a deployment already sets points this command anywhere, so seeding a database is an act of its own rather than one extra word typed in a shell that still holds production's credential. It is also the guard: the command refuses a database holding any account it did not create, so a database with real users in it is left alone.
It runs as placemark_seed, which can read and insert and little else: a seed pointed at the wrong
database cannot edit or remove a row there, and there is no such role in production at all. It does
hold MAINTAIN, which is what lets it take the lock the guard above is decided under — without that
one grant the guard would fail rather than refuse
(ADR-0031).
Apply the schema first — the seed says so rather than reporting an undefined table if you have not.
Running it twice is safe and changes nothing: every identifier is a constant, so a second run
inserts nothing and reports that it did. It tops up rather than resets, though, so a seeded row
you have edited stays edited, and one you have replaced — a personal group deleted and recreated
under a new identifier, say — makes the next run fail on a unique index without writing anything;
docker compose down -v is how you start again. Note that the seeded accounts carry no password
hash — nothing hashes one yet — so they cannot sign in.
Nothing applies the schema when the API starts, deliberately — a database is upgraded by somebody running that command, not by a process restarting.
To change any of it — a port clash with a Postgres already on 5432, or a password you would rather
not run with — copy .env.example to .env and uncomment what you need; the file explains each
key. .env is gitignored, and the defaults live in docker-compose.yml, so a fresh clone works
without one. Postgres reads the credentials only when it first initialises the volume, so
changing them afterwards means docker compose down -v first, which destroys the existing data.
Full local stack
docker compose up above starts only Postgres, exactly as it always has — API and WebUI containers
run only if you ask for the full profile (ADR-0106):
docker compose --profile full up -d --build --wait
This is for exercising the whole application through a real browser without running either project
from source — the same containers task 108
built, wired together. It is not what dotnet test uses (Testcontainers starts its own
throwaway Postgres) and it is not required for day-to-day development, where dotnet run against
the plain Postgres-only stack above is faster to iterate on.
Provisioning and the schema are both applied automatically here, unlike the plain Postgres
workflow above
(task 205): two more full-profile
services, provision then schema-upgrade, run ahead of api and gate it —
docker compose --profile full up --wait genuinely does not return until both have finished, and a
failed one stops api starting at all rather than leaving it to fail every request. Nothing to run
by hand, and a fresh volume works from a clean clone with no .env. Re-running the command above is
a no-op: provision resets the roles' passwords rather than failing on a role that already exists,
and schema-upgrade reports nothing pending.
No seed role is created by default, even here — the same default provision always has
(above), and CI's own provisioning step shares: supplying no PLACEMARK_SEED_ROLE_PASSWORD creates
none. Set it in .env if you also use the seed command against this
same Postgres and want the role to survive this running again — leaving it unset does not skip
creating the role, it drops one that already exists, on every run
(ProvisionCommand's own documented behaviour). The seed command itself still has to be run by
hand, exactly as it does against the plain Postgres workflow — nothing here writes seed data.
Then browse to http://localhost:8080 — not http://localhost:5169, the dotnet run dev
port. /health/ready on the API container (http://localhost:8081/health/ready) answers 200 as
soon as the container is up: the schema is already applied by the time it starts.
Two things about this that are easy to get wrong, and are exactly why ADR-0106 exists:
- The WebUI's own API address has to be one your browser can reach, not a Docker-network
hostname.
Api__BaseAddressis fetched by the browser, not resolved inside the Compose network (ADR-0104), sodocker-compose.ymlsets it to the API's own published host port (http://localhost:8081by default) — neverhttp://api:8080, which resolves only between containers and would fail in the browser with a network error. - The API's CORS allowlist has to name the WebUI's own published origin.
Cors__AllowedOrigins__0is computed from the sameWEBUI_PORTthe WebUI container is published on, so overriding that port cannot silently reopen the gap. This is the same class of failure Rob hit once already from a related port mismatch betweendotnet run's own two projects (ADR-0104's own Context) — worth knowing before assuming a failed request here is a CORS bug in the API itself.
Override API_PORT/WEBUI_PORT and the rest of this profile's own configuration in .env, the
same file and mechanism the plain Postgres workflow already uses — .env.example lists every key.
docker compose --profile full down # stop, keeping postgres-data
docker compose --profile full down -v # stop and destroy it
Configuration
appsettings.json and appsettings.Development.json are committed, so they hold only values that
are safe to publish. Every secret — the connection string, the JWT signing key, and the OIDC client
secret — is supplied from outside the working tree. The reasoning, and what was rejected, is in
ADR-0014.
The JWT signing key (Jwt:SigningKey) has no default anywhere, ever — not even a generated one — so
the API refuses to start without it. It must be base64 and decode to at least 32 bytes; generate a
real one the same way user-secrets.example.json's throwaway development key was generated:
openssl rand -base64 32
See ADR-0032 for why a shorter or merely base64-shaped value is refused, and what that check can and cannot actually verify.
External sign-in (Oidc:Authority, Oidc:ClientId, Oidc:ClientSecret, Oidc:RedirectUri,
Oidc:WebUiRedirectUri) is optional to configure
(ADR-0154): with the whole Oidc section
absent — no key at all under it — the API starts, local accounts (register/login) work exactly as
normal, and the OIDC endpoints simply are not mapped. user-secrets.example.json omits the section
entirely, so a fresh clone starts with external sign-in off, no placeholder values to invent or
remove. The moment the section carries even one key, though, it is judged a deliberate attempt to
configure the flow, and every value is validated exactly as before: a missing value, or an authority,
redirect URI or WebUI redirect URI that is not a well-formed absolute http/https URL, stops the
API before it serves a request — see
ADR-0032's flow and
ADR-0057 for the one part of it left to this
ticket. Configure all five together to point this at a real provider — no provider is named anywhere
in code.
Locally that means user secrets, which live in your profile — ~/.microsoft/usersecrets/ on Linux
and macOS, %APPDATA%\Microsoft\UserSecrets\ on Windows — and not in the repository, so they
cannot be committed at all.
user-secrets.example.json, in the repository root, lists what the API expects, already filled in
with the defaults the database above serves. Apply it unedited — do not copy it into
the project directory, for the reason in the warning below:
dotnet user-secrets set --project src/PlaceMark.Api < user-secrets.example.json
dotnet user-secrets list --project src/PlaceMark.Api # what is currently set
dotnet user-secrets remove "ConnectionStrings:PlaceMark" --project src/PlaceMark.Api
PowerShell has no < redirection, so on Windows pipe it instead:
Get-Content user-secrets.example.json | dotnet user-secrets set --project src/PlaceMark.Api
If you changed the credentials or port in .env, set the one value rather than editing the
template, so that your real connection string never exists inside the working tree:
dotnet user-secrets set "ConnectionStrings:PlaceMark" "Host=..." --project src/PlaceMark.Api
Where a value comes from
Configuration sources are read in this order, and later sources win:
| # | Source | Use it for |
|---|---|---|
| 1 | appsettings.json |
values that are the same everywhere and safe to publish |
| 2 | appsettings.{Environment}.json |
values that differ by environment and are safe to publish |
| 3 | User secrets — Development only | anything sensitive, on your own machine |
| 4 | Environment variables | anything sensitive, in a deployed environment |
| 5 | Command-line arguments | overriding something for a single run |
So an environment variable beats both files and your user secrets, and a command-line argument beats everything. Nest keys with a double underscore in an environment variable, and with a colon on the command line:
ConnectionStrings__PlaceMark='Host=...' dotnet run --project src/PlaceMark.Api
dotnet run --project src/PlaceMark.Api -- --ConnectionStrings:PlaceMark='Host=...'
Two things about that order are worth knowing before they surprise you:
- User secrets are only read when the environment is Development. Running locally with
ASPNETCORE_ENVIRONMENT=Productiondoes not merely change whichappsettingsfile applies — it drops the entire user secrets store, silently, and every secret reads back as null. That is also what makes it safe to keep a working password in the store: a deployed instance ignores it. - The five rows are the sources worth setting a value in, not the whole chain. A debug view also
shows
PlaceMark.Api.settings[.{Environment}].jsonprobed between rows 2 and 3, which PlaceMark does not use, and the host's own configuration chained in at the very end, which re-supplies theASPNETCORE_- andDOTNET_-prefixed variables and the command line that started the process. Neither changes where any key above comes from.
Deployed environments get their configuration from environment variables — the one mechanism
every host offers, and the reason nothing needs a committed file. A managed secret store that is
not exposed as environment variables would need its provider registering explicitly in
Program.cs; none is registered today.
Three ways to leak a secret
Nothing in the framework or the build will stop you doing any of these. They are conventions, enforced by review, and a credential that reaches the history is there permanently — it must be treated as compromised and rotated, not merely deleted:
- Putting a secret in
appsettings.jsonorappsettings.Development.json. It simply works — no warning, no error — and if your user secrets also set the key, your machine keeps using the secret and never shows you that the committed value is being used anywhere else. - Creating
src/PlaceMark.Api/secrets.json. User secrets belong in your profile, but this is not merely the wrong place for them: on a fresh clone it is a working one. The configuration system loads it, so the application runs, and the file is inside the repository — the.gitignoreentry forsecrets.jsonis what stops it being committed. The behaviour disappears as soon as you have run anydotnet user-secretscommand, so nobody who has used the tool can reproduce it or will catch it in review; ADR-0014 records exactly when it applies. Applyuser-secrets.example.jsonwith the command above rather than copying it anywhere. - Putting a secret in
PlaceMark.WebUI. The front end is WebAssembly: everything it ships, including anything underwwwroot, is downloaded to the browser and readable by the user. Configuration for the front end is therefore public by definition — an API base URL is fine, a key of any kind never is. Anything that must stay private stays inPlaceMark.Api.
Which browser origins may call the API
PlaceMark.WebUI is served from a different origin to PlaceMark.Api, so every call it makes is
cross-origin and the browser makes it only if the API has said that origin may. The allowlist is
configuration, under Cors:AllowedOrigins, and there is no wildcard in any environment:
| Environment | Where the origins come from |
|---|---|
| Development | src/PlaceMark.Api/appsettings.Development.json, already listing both ports the WebUI's launch profiles use |
| Deployed | Cors__AllowedOrigins__0, Cors__AllowedOrigins__1, … as environment variables |
An origin is a scheme, a host and a non-default port, and nothing else — https://placemark.uk,
not https://placemark.uk/. The policy compares it as text, so a trailing slash or a path matches
nothing; the API refuses to start on one rather than letting it present as a browser-only failure
weeks later. Configuring no origins at all is not an error: it means no browser may call the API,
and every other caller is unaffected.
Alongside the origins, the API permits four request headers — Authorization, Content-Type,
traceparent and tracestate — and no credentials, since authentication is a bearer token rather
than a cookie. ADR-0024 explains each of
those, and why the trace context headers matter to the logging below.
Logging
Both applications log through the built-in Microsoft.Extensions.Logging, to the console and
nowhere else. There is no file, no log server and no OpenTelemetry collector — the reasoning, and
what would justify adding one, is in
ADR-0015.
The API logs one record per request, with the method, path, status code and duration:
info: Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware[9]
=> SpanId:ec4ced0dfbc0f6a8, TraceId:5f287f0daad95f34ce00346c7ee240d0, ParentId:0000000000000000 => ...
Request and Response:
Method: GET
Path: /places/42
StatusCode: 404
Duration: 2.0788
Deployed environments get the same records as one JSON object per line instead, because
appsettings.json sets Logging:Console:FormatterName to json and
appsettings.Development.json overrides it to simple. That is the whole of the difference, and
neither is expressed in code.
TraceId is the correlation id. It comes from the W3C Trace Context traceparent header:
send one and the API adopts it, so every record it writes about that request — including the stack
trace of anything that fails — carries the caller's id. Nothing else needs to be threaded through,
and there is deliberately no X-Correlation-Id.
# -k: the dev cert is self-signed. Plain http://localhost:5017 works too, but 307-redirects to
# this address when the API is running its https profile (see Getting started, above), which
# hides the response behind a redirect unless curl is also told to follow it.
curl -k -H 'traceparent: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01' \
https://localhost:7117/places/42
The front end does not send one yet, so a click in the browser cannot currently be traced to the request it causes. It is now allowed to: the header is on the API's CORS allowlist, which ADR-0015 named as one of the two things in the way. The other is the typed HTTP client that has still to be written, which is where the header would be attached.
Levels come from the Logging:LogLevel section, so they follow the same precedence as everything
else above — an environment variable beats the settings files:
Logging__LogLevel__Default=Debug dotnet run --project src/PlaceMark.Api
Writing a log statement
logger.LogInformation("…") will not compile. CA1848 is an error here
(ADR-0013), so every log statement goes
through the [LoggerMessage] source generator, which builds the delegate once instead of
formatting the message on every call:
using Microsoft.Extensions.Logging;
namespace PlaceMark.Api.Places;
internal static partial class PlaceLog
{
[LoggerMessage(Level = LogLevel.Information, Message = "Place {PlaceId} added to group {GroupId}")]
public static partial void PlaceAdded(ILogger logger, Guid placeId, Guid groupId);
}
PlaceLog.PlaceAdded(logger, place.Id, group.Id);
Both the class and the method must be partial, and the method must be static and return
void; the generator writes the body. The named placeholders become the record's structured
fields, so PlaceId is queryable in the JSON output rather than buried in a sentence — which is
the point of the message template, not merely its syntax.
Two things that will cost you an afternoon
Microsoft.AspNetCoreis set toWarning, which would swallow the request log, since the middleware writes it atInformation.appsettings.jsontherefore namesMicrosoft.AspNetCore.HttpLoggingexplicitly. Delete that line and request logging stops, with no error and nothing in the output to say why.- Silencing
Microsoft.AspNetCore.Hostingsilences the correlation id, everywhere, including on the exception records. ASP.NET Core only starts a requestActivitywhen something is listening for it, and that logger being enabled is one of the things it counts — so setting the category toNoneleaves the request log intact but stripsTraceIdfrom every line, even when the caller supplied atraceparent. A test covers it.
And one for the front end: it reads wwwroot/appsettings.json, which is downloaded by the
browser. Log levels are all it may ever hold — see
ADR-0014. A deployed client defaults to Warning so
it stays quiet in a console the user can read; wwwroot/appsettings.Development.json turns it up,
and is fetched only when the .NET dev server tells the client it is running in Development.
Continuous integration
Every pull request to main, and every merge into it, runs
.forgejo/workflows/ci.yml on a Forgejo Actions runner: restore,
build in Release, test, provision the roles on a throwaway PostgreSQL, apply the schema scripts to
it as the schema-upgrade role, then verify formatting — six steps in a single job, in that order.
Any of them failing fails the run.
Branch protection on main names CI / * as a required status check, so an ordinary merge cannot
land while the run is red. No merge this repository actually makes is ordinary, though:
CLAUDE.md's own git workflow section documents that main also requires one approving review
Forgejo will not accept on a self-authored pull request, so every merge here goes through the admin
override (merge_pull_request with force_merge: true) — documented as bypassing a failing check
too, not only the missing approval. What actually keeps a red run off main in practice is the
reviewed-PR loop's own discipline, applied before the override is ever reached for real — nothing in
this repository's own merge process runs through branch protection un-overridden.
The last is the one that catches people out, because it fails on things the compiler is perfectly happy with: layout the formatting rules disagree with, or a file saved with a UTF-8 byte order mark. It is worth running before you push:
dotnet format --verify-no-changes
What the schema step does and does not prove
It starts a PostgreSQL service container, creates the three roles on it with the provision
command, applies the scripts as placemark_upgrade through the same command a deployment runs,
and then runs that command a second time. Running it as the least-privilege role rather than as the
container's superuser is deliberate: it is what a deployment does, and it leaves the tables owned by
the role that owns them everywhere else
(ADR-0031). The first run must apply at least one
script under the journal key prefix PlaceMark.Database.Scripts. — a rename of the project, its
root namespace or the Scripts folder re-runs every script against a database that already has
them (ADR-0021), and this is the second thing to catch it,
after the unit test that pins the exact resource name — and the second run must find nothing
pending.
So a script that does not apply cleanly from empty fails on the pull request that introduces it. What the step does not do is deploy: there is no test environment and no production for it to reach, so nothing here proves a script applies to a database that already holds a previous schema and real data. ADR-0023 records why that half of the ticket was left unbuilt rather than written against environments that do not exist.
Why the workflow looks the way it does
It is written entirely out of run: steps, checking the repository out with git by hand and
installing the SDK with Microsoft's dotnet-install.sh. That is not a stylistic preference. The
runner on this instance is act_runner v6.4.0, which cannot execute Forgejo Actions actions at
all — it clones the action, then fails the step with no error message, and the post step reports
the action's own dist/index.js missing from the container. Five runs eliminated the node runtime
version, the host, the cache directory and the uses: syntax in turn; the header comment in the
workflow records what was ruled out, so nobody has to repeat it.
Two costs follow, and neither is small:
- There is no NuGet cache. This is not a shortcut taken for simplicity — the runner's cache
server is reachable only through the
actions/cacheprotocol, so with actions unusable the cache is unreachable by any means. Every run therefore pulls roughly 240 MB of packages, most of it the Blazor WebAssembly runtime packs. - The SDK is downloaded and installed on every run, because there is no tool cache in the job
container and no
setup-dotnetto populate one.
Together these make a run take minutes where it should take seconds. Both are recovered by a
single revert — back to actions/checkout, actions/setup-dotnet and actions/cache — the day
the runner is upgraded past this defect. Until then the pipeline is correct but slow, which is the
right way round.
Why the install step checks its own SDK version
global.json pins the SDK with rollForward: disable
(ADR-0036), so
dotnet-install.sh --jsonfile global.json either fetches SDK 10.0.100 exactly or exits non-zero
— there is no version it could silently substitute. The install step compares the version it just
installed against global.json's anyway, and fails the run if they differ. That is deliberately
redundant with dotnet-install.sh's own exit code today: it is there for the day this step gains
a fallback, a mirror or a cached copy that stops honouring the pin while still exiting zero, which
is the same silent-substitution failure this ADR responds to, arriving from the other direction —
CI running an unpinned SDK rather than a developer's machine doing so.
Contributing
See CONTRIBUTING.md. main is protected: all changes arrive through a reviewed
pull request.
Licence
Released under the MIT Licence — see LICENSE.