Skip to main content

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();
CommonOptionsDefault
OpenTelemetrySourceTagShape.CommonTelemetry source name
OtlpEndpoint—Where traces go
Meters—Extra meters to collect
OpenApiEnabledfalseServe /openapi/v1.json
OpenApiTitlev1
OpenApiJwtAuthOptionfalseAdd a JWT auth option to the OpenAPI UI
DiscoverableServices(empty)Aspire service names to register clients for
IncludeGenericHttpClienttrue
IncludeRecordAuthorsfalseWire up IRecordAuthors; wants Account:ApiUrl
IncludeCreditMeteringfalseEnable billable endpoints — see Credits; wants Account:ApiUrl

UseCSMCommonWebTop also maps /healthz/startup, /healthz/liveness and /healthz/readiness.

Authentication​

Type
PermissionThe enum. The single source of truth for what exists.
PermissionInfoAttributeCategory, description, global flag and dependencies, per permission
PermissionExtensionsHasPermissions, CanGrant, WithDependencies, UngrantablePermissions, IsGlobal
IJwtPayload / JwtPayloadReads the token per request into UserId, OrgId, Email, Name, Permissions
JwtRequestExtensionsWhere a token and a key are read from — x-tagshape-auth, x-tagshape-api
AuthenticationProviderEntra 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.

Credits​

Metering a call for credits is shared so every service meters the same way. A service sets IncludeCreditMetering in its Program.cs, which registers:

Type
ICreditBalanceReaderReads the organization's { usedCredits, totalCredits } from GET /organizations/me/credits on the account API, forwarding the caller's JWT
ICreditsSpentPublisherPublishes a CreditsSpentMessage (MessageId, OrganizationId, Amount, Reason, OccurredAtUtc) to the credits-spent queue. Best-effort: a failure is logged and the request still succeeds. With no servicebus connection, spend is discarded with a warning
CreditCostOptionsWhat each call costs, bound from CreditCosts — see Configuration

An endpoint opts in with .RequireCredits(c => c.VariableGetCost) (CreditMeteringExtensions). The CreditMeteringFilter behind it reads the cost per request, refuses with 429 if the balance cannot cover it, runs the handler, and publishes the spend only if the response was a 2xx. A cost of 0 skips metering entirely. It is an endpoint filter, so it runs after PermissionsMiddleware — an unauthenticated or forbidden request is neither checked nor charged. If the balance cannot be read the request fails rather than going through free.

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:

ProjectHolds
*.DomainModels, DTOs, mappers, and pure rules (SensitiveValueRules, ApiKeyExpiryRules, GroupPermissionRules, AuditVisibilityRules)
*.DataThe DbContext
*.MigrationsEF Core migrations, in their own assembly
*.LogicHandlers with dependencies (VariableConsumptionHandler, LibraryImportPlanner)
*.WebEndpoints 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>.