Shared libraries
TagShape.Common
Referenced by every service. It holds the things that must not be allowed to differ between them.
Wiring
Two extension methods do almost all of a service's setup:
builder.AddCSMCommonWeb(options =>
{
options.OpenTelemetrySource = "TagShape.Api.LibrarySet.Web";
options.OpenApiEnabled = true;
options.OpenApiJwtAuthOption = true;
options.IncludeRecordAuthors = true;
options.DiscoverableServices = ["api-audit", "api-account"];
});
// …
app.UseCSMCommonWebTop(options => { options.OpenApiEnabled = true; options.OpenApiJwtAuthOption = true; });
app.UseCSMCommonWebBottom();
CommonOptions | Default | |
|---|---|---|
OpenTelemetrySource | TagShape.Common | Telemetry source name |
OtlpEndpoint | — | Where traces go |
Meters | — | Extra meters to collect |
OpenApiEnabled | false | Serve /openapi/v1.json |
OpenApiTitle | v1 | |
OpenApiJwtAuthOption | false | Add a JWT auth option to the OpenAPI UI |
DiscoverableServices | (empty) | Aspire service names to register clients for |
IncludeGenericHttpClient | true | |
IncludeRecordAuthors | false | Wire up IRecordAuthors; wants Account:ApiUrl |
UseCSMCommonWebTop also maps /healthz/startup, /healthz/liveness and /healthz/readiness.
Authentication
| Type | |
|---|---|
Permission | The enum. The single source of truth for what exists. |
PermissionInfoAttribute | Category, description, global flag and dependencies, per permission |
PermissionExtensions | HasPermissions, CanGrant, WithDependencies, UngrantablePermissions, IsGlobal |
IJwtPayload / JwtPayload | Reads the token per request into UserId, OrgId, Email, Name, Permissions |
JwtRequestExtensions | Where a token and a key are read from — x-tagshape-auth, x-tagshape-api |
AuthenticationProvider | Entra and Google, serialised by name |
The permission info and dependency closure are computed once at type initialisation, not per request — the descriptions never change at runtime, and every permission check walks the graph.
Middleware
PermissionsMiddleware enforces [RequiresPermissions] wherever it appears in endpoint metadata, which covers
minimal API handlers and routable Blazor components alike. Requests carrying no requirement — static files, health
checks, anonymous endpoints — short-circuit before the token is read, so they do not pay for it.
Audit
IAuditHandler is how every service writes to the audit API. Configured with Audit:AuditApiUrl, which is
required.
Record authors
IRecordAuthors puts names to the user IDs stamped on a service's records, by calling
POST /users/names/lookup on the account API. Off by default — most services either hold the users themselves or
never show who touched anything. Turning it on wants Account:ApiUrl, and it degrades to a missing name rather
than a failed request when that is not set.
Models and list helpers
ListDataSetInt<T>, ListDataSetGuid<T> and ListDataSetDictionary<K,V> are the { total, items } wrappers.
ListFilter is one condition on a list request. Extension methods provide ApplyFilters, ApplySort and
ApplyPaging over an IQueryable, so paging and filtering behave identically wherever they are offered.
Per-service libraries
Each API splits the same way:
| Project | Holds |
|---|---|
*.Domain | Models, DTOs, mappers, and pure rules (SensitiveValueRules, ApiKeyExpiryRules, GroupPermissionRules, AuditVisibilityRules) |
*.Data | The DbContext |
*.Migrations | EF Core migrations, in their own assembly |
*.Logic | Handlers with dependencies (VariableConsumptionHandler, LibraryImportPlanner) |
*.Web | Endpoints and composition |
Rules live in Domain precisely because they are pure — they can be tested without a database, a request or a
container, which is what makes test-driven development practical here.
TagShape.Api.Identity.Jwk and .Jwt
Signing key generation and token minting, kept out of the web project so both are testable on their own.
JwtGenerator mints from a login, a refresh and an API key through one private MintToken, so the three cannot
drift into producing subtly different sessions.
Test harnesses
TagShape.Common.TestHarness.API and TagShape.Common.TestHarness.Web supply the shared test scaffolding. Every
class has a corresponding *Tests class, and test names follow
<Function>_<ExpectedOutcome>_<Condition>.