API Reference — Blazor Blueprint
Login Register

Service Architecture

  • Web Service: Blazor Server UI with authentication (Port 8080)
  • Application Service: REST API with JWT authentication (Port 8081)
  • MongoDB: Document database (Port 27017)
  • Redis: Cache and messaging (Port 6379)

The Application Service is a separate ASP.NET Core WebAPI process that handles data-oriented requests. The web application communicates with it via HTTP using JWT authentication, automatically forwarding the current organisation context.

API Documentation

  • Health Checks: System Health Status
  • Infrastructure: the Infrastructure Monitor at /Platform/Infrastructure, for platform administrators

Scoped Controller Pattern

API controllers are categorised into two scopes using attributes and base classes:

Organisation-Scoped

Inherit from OrganisationApiController and are marked with [ControllerOrganisationScoped]. These require an X-Organisation-Id header. The ApiTenantResolutionMiddleware resolves the organisation, sets the tenant context, and all IRepository<T> queries are automatically scoped to that org's database.

public class MyController : OrganisationApiController
{
// CurrentOrganisationName, CurrentOrganisationId available
// IRepository<T> queries scoped to org's database
}

Platform-Scoped

Inherit from PlatformApiController and are marked with [ControllerPlatformScoped]. No organisation context is needed. Used for authentication endpoints and platform-level operations.

[AllowAnonymous]
public class TokenController : PlatformApiController
{
// No tenant resolution, uses IPlatformRepository<T>
}

Authentication

Service-to-Service (Web → API)

The web application authenticates using client credentials. The JwtAuthenticationHandler handles this automatically on every request: it requests a token from IJwtTokenApiClient.GetServiceTokenAsync — which caches one token per organisation — and stamps the matching X-Organisation-Id header.

Each issued JWT is bound to a specific organisation via an org_id claim. The API rejects any request where the bearer token's org_id does not match the X-Organisation-Id header — a leaked token can only act on the organisation it was minted for. Tokens minted without an OrganisationId are usable only against [ControllerPlatformScoped] endpoints.

POST /api/token
{
  "ClientId": "blazorblueprint-web",
  "ClientSecret": "...",
  "OrganisationId": "<org-guid>"  // optional; required for [ControllerOrganisationScoped] calls
}

# Subsequent requests:
Authorization: Bearer <jwt-with-org_id-claim>
X-Organisation-Id: <same-org-guid>

Configuration: Client credentials are stored in appsettings.json under Services:InternalApi. Both base controllers use [Authorize(Policy = "ServiceClient")], so any future co-tenant of the JWT secret can't reach the API without the ServiceClient role.

Writing your own controller — two rules that fail silently

Both base classes above carry their authorization already. The rules below bite when you add a controller outside them — a host controller in the Web app, or anything not inheriting the pair — which is the normal thing to do for a public or partner endpoint.

⚠️ 1. A controller with no authorization attribute is anonymous by default, not by decision. There is no fallback authorization policy in the Web host, so omitting the attribute does not fail the build, the boot, or the first request — it publishes the endpoint. Say which you meant: an [Authorize…] attribute, or an explicit [AllowAnonymous]. ControllerAuthorizationCoverageTests enforces exactly that for plugin controllers, so "I forgot" and "I meant it" look different in the diff.

⚠️ 2. The membership wall does not cover /api. OrgAccessMiddleware — the gate that keeps a signed-in non-member out of an organisation — is a denylist of page root segments (/admin, /apps, /plugin, /notifications), and /api is deliberately not among them. So a host controller that serves tenant data gets no membership check from the pipeline at all: being signed in is not being a member, and the claim on the request is the only thing standing there. Put [AuthorizeOrganisationMember] on it.

MEMBER is a real tenancy check rather than "signed in": it is minted from the caller's membership of the organisation this request resolved to, and only from an Active one — Suspended and Invited fall through to the non-member branch. It admits PLATFORMADMIN as well, unlike [AuthorizeOrganisationAdmin], and the split is about what the surface does rather than who is asking: reading a tenant's file while supporting them is the point of Features:PlatformAdminOrgImpersonation; changing that tenant's settings, members or payment credentials is not.

💡 Never compose the role list by hand. [Authorize(Roles = MEMBER + "," + PLATFORMADMIN)] produces the identical result today and is a copy of a rule that lives somewhere else, so it does not move when the rule does. A source-level guard fails the build on one — reflection genuinely cannot tell the two apart, since both yield the same Roles string at run time.

SignalR hubs are a third case, and middleware cannot help them

A hub invocation is a frame on a socket opened once, so the HTTP pipeline runs for the handshake and never again. That is why OrgAccessMiddleware, the consent gate and the private-workspace gate all skip every declared hub path — declaring a hub path removes walls. A hub class therefore states its own tenancy, and a global hub filter enforces it per connection:

[HubOrganisationScoped] // one organisation, members only
[HubOrganisationScoped(AllowAnonymous = true)] // one organisation, public widget
[HubPlatformScoped] // deployment-wide, no tenant

The filter resolves the organisation once per connection, verifies active membership, re-checks the user's security stamp, and refuses on any failure — then sets the tenant on every invocation scope, which is what makes IRepository<T> work inside a hub method at all. A plugin hub declaring neither attribute is refused at connect. Do not take an organisationId argument from the client and check it by hand; that is the pattern this replaced, and the version of it that forgot the check subscribed callers to other tenants' data.

Current Endpoints

Method Route Scope Description
POST /api/token Platform Client credentials token exchange
GET /api/organisations/current Organisation Trimmed view of the organisation the JWT is bound to

That is the whole shipped surface, on purpose. The Application Service is a worked example of the pattern — token exchange, org-bound JWT, scoped controllers, tenant-resolved repositories — not a product API. Its own endpoints are a worked example; delete them and add your own controllers inheriting the two base classes, which live in BlazorBlueprint.Api.Shared so that a plugin — or a second API service — can inherit them too. The example plugin ships one endpoint of its own that way, the cached /api/plugin/weather/api-cache, served by this host under Full and by the Web host under Lite. A weather endpoint used to sit in the table above, as a host endpoint, until the example moved into a plugin — which is the same migration a buyer makes with their own.

Errors & Status Codes

Status When
401 Missing, expired or invalid bearer token; bad client credentials at /api/token.
403 Valid token without the ServiceClient policy, or a token whose org_id does not match the X-Organisation-Id header.
400 An [ControllerOrganisationScoped] request with no X-Organisation-Id header, or an unresolvable organisation.
404 The entity does not exist in this tenant — indistinguishable, deliberately, from existing in another one.

⚠️ Keep tenant-mismatch failures indistinguishable from ordinary auth failures. A response that says "wrong organisation" rather than "unauthorised" confirms that an organisation id exists, which is an enumeration oracle. The same rule already governs the webhook endpoint, where every auth-related failure collapses to 401.

Error bodies are ASP.NET Core problem details. Keep them that way in your own controllers, and keep exception detail out of them — an internal message reaching a client is both an information leak and a support burden. Log server-side; return something a caller can act on.

Versioning & Rate Limiting

Versioning does not ship, and that is a deliberate omission rather than an oversight. The service is internal — the web application is the only authorised caller — so there is no third-party contract to version. Adding one before you need it would be shipping you machinery to maintain.

Rate limiting is a different matter, and some of it already ships. /api/token is limited to 10 requests per minute, partitioned on client IP and the requested organisation id together. That endpoint mints a token from Security:WebApp:ClientSecret on an unauthenticated request, so it is the one surface here a brute-forcer can reach; the composite key is what stops an attacker minting a fresh budget per invented organisation id. Nothing else in this service is throttled, because nothing else is reachable without a token.

You need versioning, and rate limiting on more than the token endpoint, the moment you expose this service to anyone else. When you do:

  • Versioning — the conventional route prefix (/api/v1/…) is the least surprising choice; both base controllers are unaffected by it.
  • Rate limiting — use the shared rate-limit store rather than an in-memory counter. A per-process counter lets N instances allow N× the requests, and your load balancer will spread an attacker's traffic across them for free. The store is Redis-backed when Redis is present, in-memory otherwise, and it is what the existing anonymous-surface guards already use.
  • Authentication — the client-credentials flow already issues per-organisation tokens, so a third-party integration gets its own client rather than sharing the web application's.

Outbound calls are the opposite story: every provider call already goes through a shared policy stack with timeout, retry, circuit breaker, per-provider rate limiting and an audit row. That is configured, not hand-rolled per call site.

Ready to Build?

Build against the service architecture and its APIs. The complete source is free to use — including commercially — while your business earns under £100k/year. Past that, a one-time commercial licence (from £499, excl. VAT) applies. Every tier ships the same product; nothing is feature-gated.

🔓 Source-available • Free under £100k/yr revenue • Commercial licence (from £499) above that • Full source code included

An unhandled error has occurred. Reload

Connection lost

Trying to reconnect to the server…

Attempt 1 of 30, retrying in 0s

Could not reconnect

Check your internet connection, then try again.

Session expired

The server could not resume your session. Reload the page to carry on.