ABOUTME: Comprehensive API architecture reference covering middleware pipeline, rate limiting, caching, HATEOAS, specification pattern, and all runtime behavior. ABOUTME: Authoritative source for Explore.API patterns — middleware order, request protection, content negotiation, filtering, and error handling.
Authenticated GET /api/operator-identity-metadata dispatches native
GetOperatorIdentityFormOptionsQuery and assembles a HAL resource through the
standard registered assembler/policy. The exact route is tenant-independent so
an unpublished or absent directory cannot block its static vocabulary; this does
not bypass authentication or grant document access. Responses are private/no-store.
The immutable DTO snapshots its collections. Operator kinds come from
TenantDirectoryOperatorKinds.All; country choices project the runtime
CultureInfo/RegionInfo data, deduplicated and ordered by alpha-2 code. These are
display choices, not a new jurisdictional compliance registry: domain validation
still owns the alpha-2 shape. Empty or invalid region data returns
countryState: Unavailable and operator_identity_countries_unavailable rather
than a partial mandatory selector. Country display names use runtime localization;
kind and field label/help identifiers are stable client localization/association
keys, not translated prose supplied by this endpoint.
Shared legal-identity field lengths reuse Domain constants. Disclosure and paid
commerce requirements are separate; saving a draft still permits missing fields.
Registration identifiers are optional, while authority choices are explicitly
empty with registrationAuthorityState: NotSupported. Instance-specific identity
and readiness rules remain on the value-bearing document contract.
No identity service, tenant repository, credential provider or logger participates
in the metadata query. HAL supplies authenticated self and refresh, omitting
unresolved links. Normal authenticated instance identity documents expose the
form-options lookup relation; the setup-secret scheme does not gain access.
Metadata never supplies an edit link. Consumers must retain the independently
authorized identity document's mutation links and revisions.
GET/PUT /api/instance-operator-identity return separate publicDisclosure and
paidCommerce assessments, each containing isReady, failureCode, and bounded
reasonCodes. The former top-level readiness fields are removed. Authorized
administrators may save syntactically valid incomplete drafts after setup as well
as during setup; saving never grants a protected capability. Disclosure does not
require commercial terms, but paid commerce does. Missing or corrupt documents
fail both assessments closed. Routes, setup/admin authority, and revision conflicts
are unchanged.
Authenticated tenant administration exposes:
| Route | Operation | Contract |
|---|---|---|
GET /api/tenant/settings/documents/directory-operator-identity |
GetTenantDirectoryOperatorIdentityDocument |
HAL document with grouped payload, capability readiness, concurrency revision, and authorized links |
PATCH /api/tenant/settings/documents/directory-operator-identity |
PatchTenantDirectoryOperatorIdentityDocument |
Presence-aware legal/contact/link groups plus expected concurrency revision |
The PATCH binds tenant scope from the authenticated context, validates the draft
in the Application handler, records actor audit, invalidates public caches, and
returns 409 for stale revisions. HAL _links.edit is the client affordance
authority.
Anonymous public settings and shell responses contain separate
directoryOperator and instanceOperator objects. If the exact tenant identity
is unavailable for PublicDisclosure, both endpoints return non-cacheable RFC
7807 503 with stable code: tenant_identity_unavailable; no partial shell or
branding fallback is cached.
Paid checkout composition contains structured directoryOperator.
PaidOrderAcceptanceDisclosureDto contains grouped organizerMerchant,
tenantDirectoryOperator, and instanceOperator evidence plus a versioned
acceptance template. Removed breaking fields include the former
branding-derived directory prose and the prior flattened operator/provider
acceptance properties. The checked-in OpenAPI document and generated NSwag
client are the only browser contract source.
Audience: Integrators | Contributors | AI agents Status: Implemented Owner: API Last Verified: 2026-08-26 Source Anchors:
Explore.API/Program.cs,Explore.API/Controllers/,Explore.API/Middleware/,Explore.API/Hateoas/,Explore.API/Authentication/,Explore.API/Extensions/,Explore.API/OpenApi/,Explore.API/Explore.API.csproj,Explore.Blazor.Client/Explore.Blazor.Client.csproj,Event.API.IntegrationTests/Features/ContractInvariantsTests.cs,Event.API.IntegrationTests/Features/OpenApiParityTests.cs
This document describes the full API behavior in Explore.API: the middleware pipeline, rate limiting, request timeouts, caching strategy, HATEOAS implementation, specification pattern, error handling, content negotiation, and client-generation flow.
Controllers using EventControllerBase.TryParseConcurrencyStamp require one
strong quoted If-Match value containing a non-empty, hyphenated GUID. Bare
GUIDs, weak tags, wildcard/list values, unmatched or repeated quotes, and empty
GUIDs return validation errors before dispatch. Surrounding header whitespace
is permitted; the parsed stamp reaches the handler for the existing stale-write
check. No client timestamp or new concurrency authority is introduced.
For task-first integration guidance, use API_COOKBOOK.md. Generated OpenAPI output remains the endpoint and DTO reference; Scalar is a development/testing UI over that contract.
The profile's internal publicUrl preserves scheme, port and path base. The
InteractiveServer setup component supplies it from the effective BFF request, with
no URL form field. Persistence still requires authorized setup. PUBLIC_BASE_URL
overrides automatic resolution and participates in the journey generation. There
is no generic address launch gate; subdomain routing requires an explicit base
domain when that capability is enabled. Response-only ownership flags are absent
from the writable profile contract.
GET /api/instanceonboarding/journey requires active setup authority or instance
administrator authority and returns a private, no-store HAL snapshot. Declarative
[Authorize] admits the exact setup-secret authentication scheme or a normal
authenticated principal; the action still requires active setup or persisted
instance-administrator authority. On this exact GET, an Authorization header
selects normal bearer validation even when a setup header is present. The BFF
forwards its protected setup authority alongside that bearer; the action validates
setup independently. A pre-administrator bearer alone remains insufficient, and a
stale setup cookie cannot shadow a persisted administrator's ordinary session.
Missing authentication returns 401; an
authenticated non-administrator without setup authority receives 403. The native
GetInstanceOnboardingJourneyQuery composes bootstrap status, selected deployment,
existing provider configuration contracts, persisted profile, operator identity,
and existing preflight checks. Two sequential projections must agree; changing,
unavailable or contradictory sources return Failed with a bounded reason and
refresh, without mutation affordances. This is a read projection, not a database
transaction or a new workflow engine.
Provider states are Ready, ActionRequired, DeploymentRestartRequired,
Unavailable, and Failed. Deployment ownership alone never implies readiness.
Checks carry requirement category, remediation authority, restart requirement,
reason code and action relation; only actual HAL links authorize UI actions.
Generation is an opaque content fingerprint, not completion authority. Both
interactive completion POSTs require an Available snapshot, the exact current
generation, and every projected blocking check to pass, including authorization
provider readiness. Failure returns 409 ProblemDetails with a journey refresh
relation before Local credential reservation or external administrator creation.
Missing HAL links are never the server-side enforcement mechanism.
Interactive completion reprojects readiness and generation inside each owning transaction under the bootstrap mutation fence. Profile save uses that same fence and rechecks durable completion, so a profile committed after HTTP admission cannot be overwritten by stale completion, and an admitted profile cannot write after completion. The fence covers an absent bootstrap row as well as an existing one. Local reservation validates before credential creation and final convergence validates again; a stale final attempt leaves the existing reservation recoverable with refreshed setup state. Serializable retries repeat admission. Reserving a Local operation does not change the journey generation or repository readiness.
The duplicate GET /api/system/onboarding-preflight and its generated method are
removed with no alias. Clients refresh the journey, and save profile through the
existing setup-only PATCH operation before refreshing readiness. Persisted profile
projection includes the stored authoritative host; it does not invent a URL scheme or
persist the non-persisted purpose/time-zone fields.
The private Keycloak operator surface is rooted at /api/instance/keycloak.
Every request evaluates active setup authority or persisted instance-administrator
authority at request time; a route attribute or a receipt ID never grants that
authority. All seven responses are private, no-store. None participates in
generic idempotency response storage, so a historical response cannot replay
current provider or administrator authority.
| Method and route | Operation | Contract |
|---|---|---|
GET /api/instance/keycloak/connection |
GetInstanceKeycloakConnection |
Returns sanitized effective binding and available HAL actions; it never returns credentials. |
POST /api/instance/keycloak/inspect |
InspectInstanceKeycloak |
Performs foreground, read-only discovery or privileged inspection. Advanced-form administrator credentials are accepted only for this request. |
POST /api/instance/keycloak/plans |
PlanInstanceKeycloak |
Stores a server-authored, credential-free approved-operation receipt after validating the current target and authority. |
GET /api/instance/keycloak/operations/{id} |
GetInstanceKeycloakOperation |
Returns a private receipt only to a currently authorized requester that satisfies its creator or setup-generation binding. |
POST /api/instance/keycloak/operations/{id}/apply |
ApplyInstanceKeycloakOperation |
Applies only the receipt's reviewed scope and requires fresh advanced-form administrator credentials when provider privilege is needed. |
POST /api/instance/keycloak/operations/{id}/reconcile |
ReconcileInstanceKeycloakOperation |
Reads provider state and updates the local outcome; reconciliation never writes to Keycloak. |
POST /api/instance/keycloak/operations/{id}/cancel |
CancelInstanceKeycloakOperation |
Prevents remaining unsent work where possible; it never rolls back a submitted provider request. |
A proposal receipt expires 15 minutes after creation. Setup-created receipts are bound to their server-derived setup generation; administrator-created receipts are bound to their verified creator. Apply, read, reconcile, and cancel recheck that binding as well as current setup-or-instance-administrator authority. The receipt contains only allowlisted nonsecret target, approval, state, and outcome data: it contains no administrator username, password, token, runtime credential, or provider response body. An uncertain or partial provider result remains visible until an operator performs inspect-only reconciliation. The service neither retries mutating provider calls automatically nor rolls them back.
The request schemas are KeycloakInspectionCredentialsDto,
KeycloakOperationPlanInputDto, and KeycloakOperationCredentialsDto. These
Application records retain write-only credential fields and sealed redacted
ToString() diagnostics, including through derived records and enclosing native
requests. Planning intent is the catalogued string enum KeycloakOperationIntent
(RepairClient, CreateClients, CreateRealm), not an integer schema. The four
mutating native ports and handlers reside in their Requests.Commands and
Handlers.Commands namespaces. Authorization inventory dispositions name only
those four commands and their existing handler-owned authority checks; they do
not add a pipeline bypass or require an already-existing administrator during setup.
Public GetEvents now returns EventDiscoveryTraversalResource.
cursor replaces public pageNumber; pageSize is 1..100.
The response carries snapshotCount, truncated, expiresAt, hasMore,
_embedded.items and _links.self/_links.next, without exhaustive totals or
random-page relations. Follow next with the same filters; changing criteria
invalidates the authenticated continuation. Invalid/expired/changed continuations
return no-store 400/410/409 ProblemDetails, and unavailable authority/capacity
returns 503. See the API changelog and
the authority ADR.
The OpenAPI document defines wire shape; repository generation policy defines the checked-in C# shape. Pinned NSwag first emits POCO syntax, then eng/tools/Explore.GeneratedContracts converts structurally eligible response/value schemas into nominal records without changing JSON names, requiredness, nullability, HAL relations, operation methods, or wire payloads. Protocol inputs, nested request graphs, HAL resources, inherited schemas, clients, exceptions, file wrappers, and explicitly mutable UI/service contracts remain classes. Generated record properties are init-only except [JsonExtensionData] AdditionalProperties, which stays settable for System.Text.Json AOT compatibility.
This is a source-level breaking change for consumers of the generated C# client: use object initializers or with copies instead of post-construction mutation. It is not a wire-format compatibility layer, and the pre-v1 repository carries no legacy generated-client variant.
Build Explore.API in Release to export the schema, then build Explore.Blazor.Client to regenerate the client. Generation alone uses dotnet msbuild src/Explore.Blazor.Client/Explore.Blazor.Client.csproj -t:GenerateApiClient -p:Configuration=Release, not a direct NSwag invocation: the target includes repository repairs, validation, and publication. API export and client preparation write private intermediates; publication replaces each primary file through a closed temporary sibling and same-filesystem rename. Failed preparation leaves the published file unchanged, and identical bytes do not change its timestamp.
Within a solution build, client builds wait for the API's normal build/export target before preparing or compiling the client. The standalone architecture-project entry point establishes the same order before resolving its references. These dependencies preserve ordinary global-property identities; verification does not request another API/client variant or propagate a schema-path override. Standalone client builds remain authoritative-schema-driven, and the generation-only command above operates on the selected schema without compiling either application. Incremental preparation compares selected schema bytes with the captured schema, so switching to an older-timestamp input cannot reuse another contract's completion stamp.
The client compiles its captured intermediate source. Architecture builds embed that completed schema, client source, and mutable policy into the test assembly. Generated-contract checks read those embedded inputs, including during --no-build runs, rather than live source-tree artifacts. Missing captures fail collection instead of falling back to primary file; normal generation restores missing outputs. When selected, the existing OpenAPI CI workflow regenerates and compares authoritative schema/client artifacts. Its pre-existing path filter does not cover changes limited to nswag.pertag.json or EventApiTagClients.g.cs, so contributors must also run the generation command and inspect the generated diff locally. Concurrent external builds sharing the same project bin/obj are not supported; ordinary consumers within one solution build share their producer identity. Test serialization is not the publication mechanism.
See RECORD_CONTRACTS.md for exact eligibility, privacy-safe diagnostics, generation steps, and focused tests.
- API:
https://localhost:7039 - Swagger UI:
https://localhost:7039/swagger - Scalar API reference: mapped by
MapScalarApiReference()in Development and Testing. - OpenAPI document:
https://localhost:7039/openapi/islamu-event.json
- API:
http://localhost:7039 - Compose runs the API with
ASPNETCORE_ENVIRONMENT=Production, so Swagger UI, Scalar, and/openapi/islamu-event.jsonare not exposed there unless the environment is intentionally changed.
/alive, /health, and /metrics are runtime operational endpoints, not generated OpenAPI controller operations. Storage local-first readiness is reported through the storage health check, and the dry-run-first reconciliation worker posture is reported through storage-reconciliation. These health payloads use bounded status/failure fields and must not expose filesystem paths, object keys, bucket names, endpoints, access keys, or secrets.
DELETE /api/user requires normal login authorization plus a UUIDv7
Idempotency-Key header. On first acceptance it returns 202 Accepted,
Location: /api/privacy-erasure/status, Retry-After: 5, and a short-lived
receipt in the body. The receipt is revealed exactly once; repeat submissions
for the same intent return the accepted status without minting a second
receipt.
GET /api/privacy-erasure/status is the receipt-authenticated status route.
Send Authorization: ErasureReceipt <receipt>; OpenAPI documents the
PrivacyErasureReceipt apiKey scheme on the Authorization header, not
bearer auth. Responses are private, no-store and only expose bounded
fenced, provider_pending, or completed status plus aggregate provider-work
counts and settlement timestamps. Missing, invalid, wrong, and expired receipts
all return 401 without revealing whether the subject or receipt exists.
The status DTO is intentionally bounded: it does not expose provider locators, payloads, or raw failure text. Provider settlement and replay are worker-owned concerns; this route only reports the current fence state.
The optional MCP adapter is not an OpenAPI controller group. It is mapped at the startup Mcp:EndpointPath only when Mcp:Enabled=true, then gated at runtime by hierarchical mcp.enabled settings after tenant/auth resolution. The endpoint is mapped anonymously so MCP SDK authorization filters can expose anonymous-safe registry discovery and public event list/detail/program/session read tools, while protected reads such as list_my_events, get_event_creation_context, get_event_publish_readiness, the event_management_context resource template, program/custom-property/registration/team/template/sync context tools, and other scoped tools/resources/prompts still require a valid bearer or API-key principal. API keys need mcp:read plus event read-equivalent scope authority for those protected event-management reads. Runtime MCP governance never changes endpoint path or stateless transport mode.
The middleware pipeline in Program.cs is ordered precisely. Changing order will break behavior:
- API Exception Handling —
UseApiExceptionHandling(). ProblemDetails-based chainedIExceptionHandler(Validation → Global). - Forwarded Headers —
UseForwardedHeaders(). Applies trustedX-Forwarded-*values before host-derived tenant resolution. - Security Headers —
UseSecurityHeaders(). Adds defensive headers to every response. - Correlation ID —
UseCorrelationId(). ReadsX-Correlation-IDorX-Request-ID, usesHttpContext.TraceIdentifierwhen absent, pushes to SerilogLogContext. - Request Logging —
UseRequestLogging(). Structured Serilog logging: method, path, status, duration, userId, tenantId, correlationId. - Response Compression —
UseResponseCompression(). Brotli + Gzip atCompressionLevel.Fastest. Enabled for HTTPS. Additional MIME types:application/json,application/hal+json. - HTTPS Redirection —
UseHttpsRedirection(). - HATEOAS Prefer Header —
UseHateoas(). RFC 7240Preferheader processing (return=minimalstrips_links). - Routing —
UseRouting(). - Tenant Resolution (pre-auth) —
UseMiddleware<ApiTenantResolutionMiddleware>(). ResolvesX-Tenant-Slugand normalized host hints for/apiand/mcprequests; API-key requests may defer binding until after authentication. - Request Timeouts —
UseRequestTimeouts(). Three configurable tiers (see below). - Auth Conflict Guard —
UseMiddleware<ApiAuthenticationConflictMiddleware>(). Rejects conflicting auth inputs before standard authentication runs. - Authentication —
UseAuthentication(). JWT Bearer via Keycloak. - Tenant Resolution (post-auth) —
UseMiddleware<ApiTenantPostAuthenticationMiddleware>(). Finalizes API-key tenant binding, mismatch handling, and fail-closed auth behavior. - MCP Runtime Gate —
UseMiddleware<McpRuntimeGateMiddleware>(). Applies only to the configured MCP path after tenant/auth context exists. Returns404when startup mapping is enabled but runtimemcp.enabledresolves false. - Request Localization —
UseRequestLocalization(). - Idempotency —
UseMiddleware<IdempotencyMiddleware>(). ImplementsIdempotency-Keyheader for write operations (POST/PUT/PATCH/DELETE). Caches responses by (Key, TenantId) and replays on duplicate requests within 24-hour window. - Rate Limiter —
UseRateLimiter(). Eight tiered policies (see below). - Authorization —
UseAuthorization(). - Support Access Audit —
SupportAccessAuditMiddleware. For active BFF/server-forwarded support-access sessions, records bounded request evidence after authorization without changing the response if audit persistence fails. - Output Cache —
UseOutputCache(). Eight cache policies (see below). - ETag —
UseETag(). SHA256-based weak ETags, 304 Not Modified support.
Three-reader non-URL versioning — clients may use any of the following; all three are read simultaneously via ApiVersionReader.Combine:
- Media-type strategy:
Accept: application/json;v=0.1orapplication/hal+json;v=0.1. - Query-string strategy:
?api-version=0.1appended to the request URL. - Custom-header strategy:
X-Api-Version: 0.1request header. - Default API version is
0.1when unspecified (AssumeDefaultVersionWhenUnspecified = true). - Version is reported in response headers via
Asp.Versioningmiddleware (ReportApiVersions = true). - URL-segment versioning is intentionally NOT supported — every endpoint has exactly one primary path (
/api/controller). This invariant is enforced by theNoUrlSegmentVersioningcontract test so thatoperationId,RouteNames, and HAL link generation stay stable across versions.
- Controllers are thin: receive request → invoke native CQS command/query handler → assemble HATEOAS response → return HTTP result.
- Business logic belongs in handlers/services, never controllers.
- Every endpoint has named routes (via
RouteNamesconstants) for HATEOAS link generation. - Endpoints include
[ProducesResponseType]and XML doc summaries for OpenAPI quality. - Published record collections are defensive snapshots. Controllers replace immutable extension data with
SetItem/record copies rather than mutating indexers, and convertReadOnlyMemory<byte>to an MVC byte array only at the file-result boundary. - No Generic CRUD or Lookup Base Controllers:
Controllers are concrete HTTP transport adapters, not business entities. Never introduce generic base classes (such as
CrudControllerBase<...>orLookupControllerBase<...>) to abstract endpoint actions. Generic action inheritance creates an inflexible "framework inside a framework", obscures route names and compile-time uniqueness, degrades OpenAPI metadata ([EndpointSummary],[EndpointDescription], operation IDs), and restricts customization when backlog features diverge (custom validation, lifecycle state transitions, Cerbos authorization policies, multipart uploads, sub-resources). - Approved Base Classes & Composition:
- Root Base (
EventControllerBase): Exposes request-scoped principal identity (CurrentUserId,RequiredUserId) and strong ETag/concurrency parsing (TryParseConcurrencyStamp). - Domain-Family Base Classes: Permitted only when two or more split controllers share an exact, multi-step domain protocol or security check (e.g.
RegistrationOrderControllerBasefor guest vs. authenticated checkout;InstanceSettingsControllerBasefor setup-secret vs. admin). - Composition Over Inheritance: Shared mechanics belong in
CommandFailurePolicy,IResourceAssembler, CQS commands/queries, and extension methods (ToCommandValidationProblem,ToNotFoundProblem), leaving controller actions explicit, declarative, and independent.
- Root Base (
DeleteEventCommandHandler returns BaseCommandResponse<Guid>: missing event,
denied owner/organization/instance authority, and paid-evidence conflict carry
distinct failure codes. EventLifecycleController.Delete maps those codes
through CommandFailurePolicy to 404, 403, and 409; only a committed deletion
returns 204. The native AuthorizeResource check remains in front of the
handler. Event detail's delete HAL link requires event:delete.
FallbackAuthorizationService maps registration-form delete to
event_registration:manage only for the registration-form resource kind;
an EventManager or RegistrationManager assignment cannot borrow that
permission to delete the event aggregate.
GetEventDetailsRequestHandler partitions its HybridCache projection by the
ambient ITenantContext.TenantId and event ID. The projection reads through
tenant-filtered repositories, so a missing projection from a wrong-tenant
request must not cache a 404 for the tenant that owns the event. Public
eligibility is rechecked against the current tenant after each cache read;
event, aspect, session, ticketing and registration writers invalidate
CacheTags.Event(eventId) to evict every tenant-partitioned projection. Removing
the former unqualified event:detail:{eventId} key leaves stale details behind.
EventManagementReadController.GetProgramSummary and GetManagedProgramSummary
inject separate closed IQueryHandler<..., EventProgramSummaryDto?> ports. Both
ports are implemented by GetEventProgramSummaryRequestHandler and discovered
under one scoped owner, with authorization outside performance instrumentation.
These are pure reads: no transaction, notification, outbox, or mutation is added.
The managed request retains Event:ViewManagement; public eligibility and
public session/group/agenda repositories remain distinct from managed reads.
Public location fields come only through PublicEventLocationProjection and
IEventLocationDisclosureService. Managed summaries do not project physical
location details or public location envelopes.
Null still maps to the existing 404 ProblemDetails; authorization may reject a
missing or inaccessible management target before the query runs. Managed HTTP
responses retain private/no-store. EventManagementMcpTools uses the public
native summary port after its public-event gate, then applies the AI disclosure
ceiling and existing bounded descriptor mapping (including the 100-item budget).
Its grouped management context still uses separate managed session/group/agenda
queries and the native managed-day port, not the managed summary query.
Grouping, local dates, timezone fallback and warning paths are unchanged. Tokens
flow through existing token-bearing reads and disclosure calls; inherited
GetEventWithDetails and managed GetSessionsByEvent remain tokenless. Full
in-flight cancellation of those database reads is not claimed. The focused
NativeEventProgramHttpTests exercise the real HTTP and MCP adapters, protected
ports and repositories on PostgreSQL and SQLite, including positive approved
venue fields and retained public/managed authority and suppression boundaries.
EventLocationConfiguration restores DateTimeKind.Utc when materializing only
CreatedAt and nullable RevealFullDetailsFromUtc. These columns already store
UTC instants: the domain factory normalizes creation time, and the policy audit
rejects non-UTC explicit reveal inputs. The EF converters leave writes and ticks
unchanged and preserve null reveal times; they do not reinterpret domain input.
Without this read-side restoration, SQLite materializes DateTime as
Unspecified, causing the unchanged strict disclosure evaluator to suppress even
approved venue names. The evaluator still rejects invalid UTC facts, and pending
privacy review still suppresses fields after the fix. Real SQLite round-trip and
HTTP tests cover both null and explicit reveal dates. No global/base-entity time
convention, policy relaxation, store-type change or migration is introduced.
The task-local agenda ordering prerequisite is repaired in
EventAgendaItemRepository.GetByEventAsync and GetPublicByEventAsync.
Both methods materialize their existing event-filtered, no-tracking lists with
the supplied cancellation token before sorting by SortOrder, then absolute
DateTimeOffset start instant. Tenant, soft-delete and public-eligibility filters
remain in SQL. These methods already returned the complete event-scoped list;
client ordering fetches no additional rows and adds no provider-name branch,
UTC SQL conversion, pagination or equal-key tie-breaker contract. SQLite therefore
no longer rejects the summary's agenda query with an unsupported ORDER BY.
Repository tests cover conflicting local-clock/instant order, sort priority,
unpublished days, deleted items and event/tenant isolation. The original failure
was a direct summary-path prerequisite, not unrelated suite rot.
No route, schema, generated-client, configuration or migration change is required.
The seven Features/EventAgendaItems operations use closed native command/query
ports, discovered under authorization -> performance -> handler composition.
EventAgendaItemController injects all seven; EventManagementMcpTools injects
only the managed agenda-list port alongside its existing native Day/Program
ports. The separate Features/Agenda projection now uses its own native public
query port and reads repositories directly, as described below. DTO assemblers,
routes, response types and generated contracts are unchanged.
EventAgendaItemAuthorizationContextEnricher is explicitly registered for the
three writes. It loads the agenda item's persisted source parent (or create's
target parent), rejects missing/deleted/foreign-tenant rows, and supplies
EventScopedAuthorizationFacts to the existing AgendaItem create/update/delete
capabilities. This fixes legitimate owner writes denied for missing event
context without adding grants. A reparenting update separately checks the
persisted destination's Event:update authority before attachment or mutation;
submitted destination authority never replaces source authority.
Public list/detail retain authoritative eligibility and policy-filtered location
disclosure; managed list/detail retain Event:ViewManagement, parent binding
and private/no-store HTTP responses. Managed detail alone retains exact location
IDs. MCP management descriptors omit physical location information. Nullable
detail results still map to 404. PATCH preserves required strong If-Match,
409 stale-write responses and explicit field-operation semantics.
Handlers retain manual validators, UTC rescheduling, destination timezone/day reprojection, transaction-owned location attachment/detachment and post-commit update cache invalidation. The existing unit of work rolls back placement and agenda changes together and translates optimistic conflicts. Real SQLite tests exercise seven registered ports, HTTP and MCP transport, denied/forged/foreign access, positive moves, venue review suppression, storage failure rollback and two previously authorized snapshots with one durable winner. The concurrency fixture gates actual provider decisions with signals; it never grants authority. Inherited tokenless repository methods remain tokenless; full in-flight cancellation of those reads is not claimed. No provider matrix or performance claim accompanies this slice.
GetEventAgendaProjectionRequest implements only IQuery<EventAgendaProjectionDto?>;
its handler exposes Task-based QueryAsync. EventAgendaItemController injects
that exact closed port alongside its seven native agenda-item ports, removing
its last mediator dependency. Automatic native registration retains authorization
-> performance -> handler composition. This is a public query, not a management
grant: the handler checks authoritative persisted public eligibility before reading
published days, public sessions and public agenda items. It has no nested sender.
The existing merge omits sessions missing required schedule projections, groups
entries by local date, orders entries by local start minute then sort order, and
orders day groups by day sort order then date. Published empty days remain;
entry dates without a published day receive an unlabelled group. Timezone remains
EventTimeZoneId ?? Timezone. Physical location and room IDs are redacted;
public location envelopes come only from the existing batched disclosure service,
including approved venue fields and renewed privacy-review suppression. The
portable agenda ordering and UTC venue materialization repairs remain unchanged.
Null retains the controller's existing 404 ProblemDetails mapping. Real SQLite
HTTP tests observe that error body with application/json; this migration does
not change the shared response policy. NativeAgendaProjectionHttpTests proves
the registered port, complete HTTP/native JSON parity, meaningful merged dates
and order, approved venue disclosure, private/draft/foreign/deleted/missing denial
even for an owner, fresh actor-suspension denial and privacy-review tightening.
In-memory AgendaProjectionTests covers input permutation, incomplete sessions,
published/implicit/empty day behavior and timezone fallback without extracting
the handler's existing algorithm. Tokens still flow through token-bearing reads
and disclosure; inherited parent GetById remains tokenless. The SQLite command
interceptor observes real read tokens and cancels at a read boundary without
sleeping or replacing repositories. No full in-flight cancellation, provider
matrix, performance, route, schema, client or configuration change is claimed.
GetEventSessionStatusDetailsQuery and its native handler/closed controller port
return EventSessionStatusDto?: absence is a query result, not a fabricated DTO
or a mapping exception. EventSessionStatusController.GetById maps null through
ApiNotFoundProblemDescriptor and ToNotFoundProblem, producing the existing
404 ProblemDetails convention (resource_not_found, request instance and tracing
extensions). Present rows still return the unchanged 200 DTO.
This repairs the former Ok(null) -> 204 mismatch without changing the declared
OpenAPI paths, response schemas, operation IDs, or generated client. Both reads
remain anonymous global lookups; list contents (IDs 1-10) and LookupData/DetailData
cache policies are unchanged. The supplied cancellation token continues through
the native decorators to the handler; IEventSessionStatusRepository.GetById(int)
and GetAll() have no token parameter, so database-read interruption is not
claimed or introduced by this repair.
CreateEventSessionLanguageCommand retains session authorization and manual input validation. The repository insert remains arbitrated by the existing unique (TenantId, EventSessionId, LanguageId) index, including concurrent requests; there is no check-then-insert substitute for that constraint. EventSessionLanguageRepository translates only that exact model-derived index violation through the existing provider-aware constraint classifier into EventSessionLanguageAlreadyAssignedException, detaching the rejected assignment. The command maps that exception to its established validation result, and the controller returns HTTP 400 ValidationProblemDetails (validation_failed, errors.program). Unrelated primary-key, foreign-key and other database failures are not classified as duplicate assignments.
This replaces provider-dependent duplicate-create 500 responses without changing routes, successful 201 payloads, OpenAPI/client shapes or the unique index. No migration or configuration change is required. The inherited repository create signature remains tokenless; this repair does not claim database-write cancellation support. NativeEventSessionLanguageHttpTests covers sequential duplicates, two real inserts synchronized before persistence, durable uniqueness, and unrelated constraint failures on authoritative SQLite; other provider execution remains part of the workstream matrix.
The 25 actions under api/tenants/{tenantId:guid}/events/{eventId:guid}/registration-providers
are partitioned into RegistrationProviderConnectionsController (connections and approved
origins), RegistrationProviderBindingsController (external schema import, bindings,
publication, and mappings), RegistrationProviderChannelsController (channels and launch
descriptors), and RegistrationProviderOperationsController (health, queue, and reconciliation).
External schema import retains its connections/{connectionId:guid}/external-imports route.
Each concrete controller declares the same route prefix, API version 0.1, authenticated
classification, authorization, JSON/HAL media types, and explicit
Tags("RegistrationProviderManagement"). Named routes, action contracts, rate limits,
timeouts, private/no-store behavior, and HAL assembly remain unchanged.
Each controller uses EventControllerBase and keeps its small validation descriptor and
result mapping local, using the existing ToCommandValidationProblem extension.
Controllers still dispatch through IMediator and inject only their own HAL assemblers;
this partition does not change Application request or handler execution.
User profile media at PATCH /api/user/{id} is a replacement group:
omitting profileImage preserves the image, {"profileImage":{}} clears it,
and a supplied group selects either profilePictureId (a managed storage UUID)
or externalProfilePictureUri (an absolute HTTP(S) source). Both together,
empty UUIDs, relative URLs and credential-bearing URLs are invalid. Existing
route-user authorization and If-Match concurrency checks still apply.
The native Actor update command retains OptionalUpdate<T> presence semantics
and accepts exactly one managed-ID or external-URI operation in its image group.
Actor and current-user responses distinguish profilePictureStorageObjectId
from externalProfilePictureUri; only one may be populated. profilePictureUri
(Actor) and profileImageUri (User) are display projections, not stored ownership.
The obsolete User profileImageKey is removed. Managed projections use the
stable public-image route only after current publication/tenant checks; external
sources remain external even when their paths resemble Event routes.
Tag, Tenant metadata, tenant navigation links, footer link groups, footer links, control-plane tenant-plan drafts, current-user appearance localization, user appearance profiles, UI themes, EventLocation disclosure, EventSession agenda items, EventSession groups, EventSession speaker assignments, EventTemplate, EventSessionTemplate, and shared/Event/EventSession custom-property definitions use route-ID or current-resource PATCH. Their bodies contain only nullable logical groups; omitted groups preserve persisted values, and identity comes from the route plus trusted tenant context rather than body-owned IDs. Template PATCH uses metadata and definitions groups: supplied definitions atomically replace definitions and nested options, while omission preserves the existing set. Template detail reads expose the required concurrency stamp, and sync diff/apply/history remain dedicated operations. Custom-property definition PATCH uses metadata, validation, and options groups; the shared definition additionally exposes its entity-type relation group. Supplying options atomically replaces the option set, while omitting options leaves it untouched. Template and custom-property definition updates require the observed concurrency stamp through strong If-Match; Event and EventSession projection refresh remains inside the write transaction. Session-group and speaker updates also require strong If-Match; group list/detail reads expose that stamp. Islamic and Tech aspects use separate POST create operations and grouped PATCH update operations. Appearance active-profile selection, current theme mode, profile archive, Tenant lifecycle, navigation reorder, footer reorder, and tenant-plan publish/archive/clone remain dedicated actions rather than generic property groups. UI-theme PATCH keeps the observed row version at the wrapper level and validates the merged metadata/state/palette candidate before one transactional update.
OptionalUpdate<T> preserves three distinct JSON states: an omitted member leaves the persisted value unchanged, an explicitly supplied null clears a nullable value, and a supplied non-null value replaces it. Record request bodies keep constructor/member names and types aligned for ASP.NET Core and System.Text.Json; validation metadata remains on the constructor parameter or bound member used by the public contract. Do not add compatibility readers or aliases for removed authority fields.
Current caller/tenant authority never comes from a request body. Controllers derive it from EventControllerBase, ITenantContext, an authoritative route/persisted resource, or a purpose-bound trusted adapter, then place it on the Application request only when authorization or business intent needs it. A body UserId/TenantId is valid only as an explicit target that is independently authorized and tenant-checked.
Tenant navigation and footer-link URLs accept relative paths or HTTPS URLs by default. The instance-only security.require_https_external_urls setting defaults to true; setting it to false permits HTTP only for deployments that explicitly trust an HTTP-only private network.
The guest routes under api/events/{eventId:guid}/registration-orders have five
concrete owners: GuestRegistrationOrderController for start, read and lifecycle;
GuestRegistrationOrderRequirementsController for native/provider requirements;
GuestRegistrationOrderParticipantsController for participants and ticket assignments;
GuestRegistrationOrderPromotionsController for promotions; and
GuestRegistrationOrderClaimController for authenticated account claim.
All use the existing RegistrationOrderControllerBase protocol helpers and retain
the GuestRegistrationOrder tag. Only the lifecycle owner needs TimeProvider.
Capability headers, challenge admission, idempotency/replay protections, per-action
authorization, rate limits and HAL mapping retain their original contracts.
The participant helper still constructs one concrete guest mutation command;
Application operation migration remains owned by its corresponding cohort.
Event reads expose typed provenance plus reviewed Active public actions. External destinations are stored as EventPublicAction records rather than caller-supplied redirect URLs.
- Anonymous
GET /api/events/{eventId}/public-actionsandGET /api/events/{eventId}/public-actions/{actionId}return only active reviewed actions for a published public event. - Anonymous
GET /api/events/{eventId}/public-actions/{actionId}/redirectresolves the stored action byeventIdandactionId, returns302, and isno-store. It never accepts a destination or return URL from the request. - Public-action DTOs instruct clients to open external destinations in a new tab with
rel="noopener noreferrer"; these values are fixed by the server. - Authenticated
POST /api/events/{eventId}/public-actions,PUT /api/events/{eventId}/public-actions/{actionId}, andDELETE /api/events/{eventId}/public-actions/{actionId}manage actions through event authorization. Updating a destination returns it to pending review; deletion requires the current concurrency stamp inIf-Match. - Organizer-claim list/detail, submit, withdraw, and review operations live under
/api/events/{eventId}/organizer-claims; claimant-scoped reads useGET /api/actors/{claimantActorId}/organizer-claims. All claim reads are authenticated andno-store; withdraw requiresIf-Match. - Event-bound claim requests and HAL checks authorize as
islamuevent_event_organizer_claimwhile carrying server-only parent-event and claim metadata. Withdrawal useswithdraw-organizer-claim; before provider evaluation, the server loads the persisted claim and claimant actor and supplies claimant user, organization, or group ownership as non-serialized authorization attributes. Request route/body ownership is never trusted. Public action, claim, correction, and unsafe-link affordances require a published public event; withdrawal and review candidates require a pending or evidence-required claim. Clients must use_links, not provenance, actor ids, status inference, or role claims, to decide which controls to render.
Venue disclosure is decided per event, not per venue. The same physical Location can be fully public for one event and withheld for another, so every read is a separate purpose-specific operation and the response shape differs by purpose. All of them live under /api/events/{eventId:guid}/locations and address venues by EventLocationId; the physical LocationId appears only on the management contract.
- Anonymous
GET /api/events/{eventId}/locationsreturnsEventLocationPublicDtofor a published public event. It never varies by authentication cookie and carries no exact street, postcode, or coordinates. - Authenticated
GET /api/events/{eventId}/locations/my-accessreturnsEventLocationAttendeeDtofor the caller's own registration coverage. Exact fields appear only when policy, instance/tenant governance, entitlement, and the server-side reveal time all allow it. - Authenticated
GET /api/events/{eventId}/locations/{eventLocationId}/managementreturnsHalResource<EventLocationManagementDto>afterevent:view-managementauthorization, and appends a PII-free exact-read audit record before returning. - Authenticated
GET /api/events/{eventId}/locations/managementreturns every EventLocation on the event as a HAL collection;GET /api/events/{eventId}/locations/reviewis the same read filtered toNeedsPrivacyReview. PATCH /api/events/{eventId}/locations/{eventLocationId}/disclosureupdates the seven visibility flags, the full-details audience, and the reveal instant. It requires bothExpectedPolicyVersionandExpectedConcurrencyStampin the body.POST /api/events/{eventId}/locations/{eventLocationId}/remediation/confirmclears a privacy review, and only for a usable active physical venue or an explicit to-be-announced association.- Attendee, management, review, and both write routes are
Cache-Control: private, no-store. The anonymous public read is the only cacheable one. stateis a stable snake_case string (hidden,to_be_announced,available,private_venue,unavailable,needs_privacy_review) on every purpose contract.- Clients must gate Edit and remediation controls on
_links["edit"]and_links["remediate-location"], never on local roles or claims. Each row in a collection carries its own links.
A private home is somebody's household, so ownership is claimed by the incoming owner with explicit versioned consent — never assigned to a third party.
POST /api/location/{id}/private-homeclassifies a location as a private home and records the authenticated actor as its consenting owner.POST /api/location/{id}/private-home/ownershiptransfers ownership to the authenticated actor. The domain requires the consenting user and the new owner to be the same person.- Both require the current concurrency stamp in
If-Matchand a body containingconsentAcknowledged: trueplus aconsentVersion. A missing or false acknowledgement is a refusal, not a default, and is rejected before the location is loaded. CreateLocationDto,UpdateLocationDto, and nested event creation'sCreateEventLocationDtoaccept manual address fields but no rawlatitudeorlongitude. Manual address transitions clear any previously derived coordinates; clients must remove coordinate controls and assignments rather than send aliases. Authorized reads are unchanged:LocationDtoand purpose-specific attendee/management/disclosure field contracts retain their policy-controlled coordinate values.- The generic geographic browse routes
GET /api/location/by-city/{city}andGET /api/location/by-country/{country}are removed. They enumerated exact venue addresses, including private homes, without any disclosure evaluation.
POST /api/geocoding/address-suggestionsaccepts onlysearchText,limit, and an optional organization target. Tenant, actor, provider, credentials, coordinates, source, and visibility are server-owned.- The operation is authenticated, bounded by the dedicated
AddressSuggestionsrate-limit policy, and always returnsCache-Control: private, no-store, including validation, authorization, and throttling errors. Request bodies and search text are not logged. - Provider
Nonestill returns eligible local rows. The Application handler derives tenant and user context, and Persistence enforces active tenant/user membership plus creator, organization, or tenant-approved visibility in one query. - Results are a HAL collection containing source, visibility, concurrency, and links. Clients invoke tenant approval only when
_links["approve-tenant-address"]is present; the namedPOST /api/location/{id}/address-approvaloperation requires the current concurrency stamp inIf-Match. - Browser calls use the existing
/api/*BFF proxy. Unsafe requests require antiforgery; browser-supplied bearer, API-key, and tenant headers are removed before forwarding.
- Event sync routes live under
/api/events/{eventId:guid}/template-sync/{diff|apply|history}. - Event-session sync routes live under
/api/event-sessions/{sessionId:guid}/template-sync/{diff|apply|history}. diffreturns the operator-visible template delta for a requested target version.applyaccepts an explicit sync plan plusBaseProvenanceVersionand uses theComplextimeout policy.- Stale-base and concurrent-update conflicts return
409 Conflictwith ProblemDetails types/problems/stale_sync_baseand/problems/concurrent_update.
Support-access routes live under /api/support-access and require authentication. They model support access as a persisted session for the real actor rather than as impersonation claims.
GET /api/support-access/currentreturns the authenticated actor's current active support-access session when the API can validate one for the current request or recover one for BFF status refresh.POST /api/support-access/sessionsstarts a short-lived support-access session for a target tenant. The handler enforces governance settings, mode caps, ticket/reference requirements, one-active-session constraints, and authorization through the support-access resource kind.POST /api/support-access/sessions/{sessionId}/stopstops the authenticated actor's active session.POST /api/support-access/sessions/{sessionId}/force-stopforce-stops a session for emergency revocation through the higher-privilege support-access action.GET /api/support-access/tenants/{targetTenantId}/sessionsreturns bounded session history.GET /api/support-access/tenants/{targetTenantId}/sessions/{sessionId}/audit-eventsreturns bounded support-access audit evidence.
All session and audit responses are HAL resources or HAL collections. Link policies decide start/stop/force-stop/audit affordances; clients must not recreate those decisions from roles, claims, or cached support state.
Storage object metadata and general download routes are authenticated, resource-protected contracts because metadata can include provider object keys, storage provider labels, lifecycle state, and tenant-owned file details.
GET /api/storageobjectandGET /api/storageobject/{id}require authentication plusislamuevent_storage_object:view.GET /api/storageobject/{id}/contentrequires authentication plusislamuevent_storage_object:download; safe raster content is served inline, while SVG, HTML, documents, and other general content use a sanitized attachment disposition. The content reader enforces lifecycle and visibility before opening the server-owned provider key.GET /api/storageobject/{id}/presigned-urlrequires authentication plusislamuevent_storage_object:presigned_download, returns no provider object key, forces an attachment disposition at the provider, and is marked no-store. Browser image presentation does not use presigned URLs.GET /api/storageobject/{id}/publicis the only anonymous storage content route. It serves only active safe public raster images by storage object ID and never accepts provider object keys, filesystem paths, or arbitrary URLs from the browser.- Public writes use provider-neutral upload sessions:
POST /api/storageobject/upload-sessions,PUT /api/storageobject/upload-sessions/{uploadSessionId}/content, andDELETE /api/storageobject/upload-sessions/{uploadSessionId}. Browser clients reach this flow only through the authenticated same-origin BFF session/proxy contract. - The legacy direct-upload
POST /api/storageobject/generate-upload-urloperation and caller-authoredPOST /api/storageobjectmetadata creation are removed. There is no compatibility route, DTO, generated-client method, or fallback path before v1.0. - Clients must discover
content,presigned-download, andpublic-imageaffordances from HAL_links; local role/claim checks are not authoritative.
SafeRasterContentPolicy in Application is the single metadata and container authority for storage finalization, image references, AI image ingress, public delivery, and ATProto thumbnail validation. Browser and AI inputs accept exact JPEG, PNG, GIF, and WebP; server and ATProto paths additionally accept AVIF. All five server formats require matching MIME/extension metadata and a structurally complete container through exact EOF, so truncated content and bytes appended after the active container are rejected. This policy does not claim pixel decoding, sanitization, full codec validity, content moderation, or malware scanning.
EmailDispatch admin routes live under /api/admin/email-dispatch and are authenticated operator APIs for Basic Dispatch Mode. They expose tenant-scoped delivery state and controls without exposing recipient email, subject, body, provider message ids, or raw provider errors.
GET /api/admin/email-dispatch/statusrequires a tenant id query value and authorizesislamuevent_email_dispatch:view. Itslimitdefaults to 50 and accepts 1 through 200.EmailDispatchAdminController.GetStatuspasses cancellation to the native query and awaitsIResourceAssembler.ToCollectionResourcebefore constructingOk, including asynchronous link authorization. The success body is the declaredHalCollectionResource<EmailDispatchStatusDto>: root_linksand_embedded.items, with sanitized rows and permission-filtered item links. It never serializes a Task or aresultenvelope. This repairs runtime conformance to the existing OpenAPI response; route names, schemas and generated clients are unchanged.PUT /api/admin/email-dispatch/tenants/{tenantId}/pauseandDELETE /api/admin/email-dispatch/tenants/{tenantId}/pauseauthorizeislamuevent_email_dispatch:manage_tenant.PUT /api/admin/email-dispatch/tenants/{tenantId}/outbox/{outboxId}/parkauthorizesislamuevent_email_dispatch:park.POST /api/admin/email-dispatch/tenants/{tenantId}/outbox/{outboxId}/replayauthorizesislamuevent_email_dispatch:replay.POST /api/admin/email-dispatch/tenants/{tenantId}/outbox/{outboxId}/resolve-without-replay?reason=...authorizesislamuevent_email_dispatch:resolveand transitions onlyDeadLettered,Parked, orUnknownrows to terminalSkippedstate.POST /api/admin/email-dispatch/tenants/{tenantId}/outbox/{outboxId}/reconcile?outcome=Delivered|NotDelivered&reason=...&providerMessageId=...authorizesislamuevent_email_dispatch:reconcileand atomically aligns anUnknownoutbox, latest attempt, receipt, and notification delivery.Deliveredsettles the graph as delivered;NotDeliveredqueues it safely. Generic replay does not acceptUnknown.GET /api/admin/email-dispatch/controlreturns sanitized global processor state.PUT|DELETE /api/admin/email-dispatch/control/pausepauses/resumes admission, andPUT|DELETE /api/admin/email-dispatch/control/rate-limitsets/clears the bounded global SMTP-per-minute override. These routes authorize as the instance settingemail-dispatch.processor, so tenant administrators cannot use them.- HAL item links for
replay,park,resolve-without-replay, andreconcile, plus globalpause/resumeand rate links, use the same resource/action metadata; clients must render controls only when the server includes the link. Skippedis terminal. Sent, skipped, andContentRedactedAtrows are not replayable or parkable; redacted rows permanently omit all delivery-control affordances.
Notification preference routes are authenticated private preference endpoints. They return a HAL NotificationPreferenceMatrixDto and expose mutation affordances only through _links.
GET /api/notification/preferences/me,PATCH /api/notification/preferences/me, andPUT /api/notification/preferences/me/mutemanage the current user's matrix.GET|PATCH /api/organization/{id}/notification-preferencesandPUT /api/organization/{id}/notification-preferences/mutemanage organization-scoped defaults/overrides through organization resource authorization.GET|PATCH /api/group/{id}/notification-preferencesandPUT /api/group/{id}/notification-preferences/mutemanage group-scoped defaults/overrides through group resource authorization.- Response
_links.self,_links.save, and_links.set-muteare the only UI authority for rendering save and mute controls. Clients must not infer preference editability from roles or claims. - Matrix PATCH bodies contain an optional
cellsgroup. Omitted cells preserve stored choices; an absent or empty group fails validation. Command handlers validate every supplied cell before opening the write transaction, so required or broader-locked cells reject the whole request without partial writes. PATCH /api/actor-subscriptions/actors/{targetActorId}/notification-leveltakes actor identity only from the route. Its body containsexpectedConcurrencyStampand an optionalnotificationLevelgroup; the group is required for a non-empty patch, and a missing subscription returns typed404ProblemDetails.
GET /api/notification/web-push/configis anonymous and returns only{ enabled, publicKey }; the VAPID private key is never part of the API contract.GET /vapid-public-keyis anonymous and returns only the VAPID public key astext/plain; Blazor consumes it through the generated API client and notification service.GET /api/notification/web-push/subscription?deviceIdentifier=...returns the authenticated current user's safe current-device status without endpoint,p256dh, or auth material.POST /api/notification/web-push/subscriptionscreates or refreshes the authenticated user's tenant-scoped device subscription.DELETE /api/notification/web-push/subscriptions/{subscriptionId}deactivates only a subscription owned by the authenticated tenant/user.- Current-user preference resources advertise
subscribe-web-push; active subscription resources advertiseunsubscribe. Blazor must gate both actions from those HAL links.
Localization admin routes live under /api/admin/localization and require
authentication. They expose provider configuration, secret status, static bundle
health, and no-TMS bundle operations without exposing TMS secret values or raw
provider errors.
GET /api/admin/localization/configurationreturns localization governance and metadata such asTmsApiKeyConfigured; it never returns the plaintext API key.POST /api/admin/localization/test-connectiontests the configured provider from the server side.POST /api/admin/localization/tms-api-key/rotatestores the write-only TMS API key through the shared secret-binding flow.PUT /api/admin/localization/governanceupdates non-secret localization governance settings.POST /api/admin/localization/export-from-tms?languageCode={code}pulls the configured Tolgee/Weblate language into the writable static bundle cache.GET /api/admin/localization/bundle?languageCode={code}returns the merged static bundle for offline/no-TMS operators without calling live providers.POST /api/admin/localization/bundlevalidates and writes a flat static bundle JSON payload, then invalidates translation caches for that language.GET /api/admin/localization/bundle-healthreports whether the writable bundle path is usable.
Static bundle imports accept only flat ui.* and lookup.* string dictionaries.
ProblemDetails and logs must not include raw bundle content or TMS credentials.
Listmonk settings are exposed under /api/integrations/listmonk:
| Route | Route name | Auth | Purpose |
|---|---|---|---|
GET /api/integrations/listmonk/settings |
GetListmonkIntegrationSettings |
[AllowAnonymous], public classification |
Returns sanitized ListmonkIntegrationSettingsDto for admin UI state. |
PUT /api/integrations/listmonk/settings |
UpdateListmonkIntegrationSettings |
[Authorize] |
Updates non-secret Listmonk settings. |
POST /api/integrations/listmonk/credentials/rotate |
RotateListmonkIntegrationCredentials |
[Authorize] |
Rotates the write-only API username/key secret bindings. |
POST /api/integrations/listmonk/test-connection |
TestListmonkIntegrationConnection |
[Authorize] |
Runs a server-side connectivity check with resolved settings and credentials. |
Authenticated event-scoped ticket catalog versions, draft authoring, ticket types, and capacity pool management endpoints are exposed under /api/events/{eventId:guid}/ticketing:
| Route | Route Name | Auth | Purpose |
|---|---|---|---|
GET /api/events/{eventId}/ticketing |
GetEventTicketCatalogManagement |
[Authorize] |
Returns the event ticket catalog management DTO with catalog version, ticket types, capacity pools, normalized lookup metadata, and HAL affordances. Instance monetization is not embedded. |
POST /api/events/{eventId}/ticketing/draft |
CreateEventTicketCatalogDraft |
[Authorize] |
Creates a new draft catalog version with currency code. |
POST /api/events/{eventId}/ticketing/draft:clone |
CloneEventTicketCatalogDraft |
[Authorize] |
Clones active published catalog version into a working draft version. |
POST /api/events/{eventId}/ticketing/ticket-types |
CreateEventTicketType |
[Authorize] |
Adds a new ticket type (pricing mode, price, capacity pool assignment) to draft catalog. |
PUT /api/events/{eventId}/ticketing/ticket-types/{ticketTypeId} |
UpdateEventTicketType |
[Authorize] |
Updates an existing ticket type configuration. |
DELETE /api/events/{eventId}/ticketing/ticket-types/{ticketTypeId} |
DeleteEventTicketType |
[Authorize] |
Removes a ticket type from draft catalog. |
POST /api/events/{eventId}/ticketing/capacity-pools |
CreateEventCapacityPool |
[Authorize] |
Creates a shared capacity pool with oversell policy and seat allocations. |
PUT /api/events/{eventId}/ticketing/capacity-pools/{capacityPoolId} |
UpdateEventCapacityPool |
[Authorize] |
Updates capacity pool limits and oversell policy. |
DELETE /api/events/{eventId}/ticketing/capacity-pools/{capacityPoolId} |
DeleteEventCapacityPool |
[Authorize] |
Deletes a capacity pool. |
GET /api/events/{eventId}/ticketing/publication-preflight |
GetPaidEventPublicationPreflight |
[Authorize], private, no-store |
Returns the current paid-publication readiness and blockers. |
PUT /api/events/{eventId}/ticketing/commercial-disclosures |
UpdateEventTicketCatalogCommercialDisclosures |
[Authorize] |
Updates the merchant, refund-policy, and support disclosures required for a paid catalog. |
GET /api/events/{eventId}/ticketing/payment-connection |
GetEventOrganizerPaymentConnection |
[Authorize], private, no-store |
Returns bounded readiness for the exact event organizer's payment connection. |
POST /api/events/{eventId}/ticketing/payment-connection/onboarding |
StartEventOrganizerPaymentOnboarding |
[Authorize], private, no-store |
Starts or reuses hosted organizer onboarding and returns an absolute HTTP(S) URL plus a reuse flag. |
GET /api/events/{eventId}/ticketing/payment-connection/onboarding/return |
ReturnEventOrganizerPaymentOnboarding |
[Authorize], private, no-store |
Redirects to Studio ticketing; it does not mark the connection ready. |
GET /api/events/{eventId}/ticketing/payment-connection/onboarding/refresh |
RefreshEventOrganizerPaymentOnboarding |
[Authorize], private, no-store |
Redirects to Studio ticketing for a new onboarding attempt; it does not mark the connection ready. |
POST /api/events/{eventId}/ticketing/publish |
PublishEventTicketCatalog |
[Authorize] |
Recomputes paid readiness inside the publish transaction before promoting a draft catalog. |
Ticketing money fields use integer minor units in long ...Minor properties. Percentages use integer basis points, where 10_000 = 100%. Catalog, ticket-type, and capacity-pool mutations must follow the exact relation on the returned HAL resource, including create-draft, clone-draft, create-type, create-pool, publish, edit, and delete.
Paid-event policy settings are separate from instance monetization:
| Route | Route Name | Auth | Purpose |
|---|---|---|---|
GET /api/instance/settings/paid-event-policy |
GetInstancePaidEventPolicySettings |
[Authorize], Admin, private, no-store |
Returns the active instance ceiling. |
PUT /api/instance/settings/paid-event-policy |
UpdateInstancePaidEventPolicySettings |
[Authorize], Admin, private, no-store |
Revises the instance ceiling. |
GET /api/tenants/{tenantId}/settings/paid-event-policy |
GetTenantPaidEventPolicySettings |
[Authorize], private, no-store |
Returns the instance ceiling, tenant override, and effective policy. |
PUT /api/tenants/{tenantId}/settings/paid-event-policy |
UpdateTenantPaidEventPolicySettings |
[Authorize], private, no-store |
Revises a tenant-only narrowing of that ceiling. |
The paid-management resources expose actions only through their exact HAL relations: preflight, commercial-disclosures, payment-connection, start-onboarding, and publish. A paid publish relation requires both fresh readiness and manage-paid-event-commerce for the event's persisted organizer actor. Policy responses expose policy values without policy or tenant identifiers. The connection response exposes only status, merchant country, charge-capability state, requirements state, supported currencies, and readiness timestamp. It omits provider, platform, account, tenant, actor, connection, lineage, and evidence identifiers. Checkout, capture, refunds, disputes, transfers, payouts, and legal/tax/invoice support are separate API capabilities; admission and online QR check-in are documented in Admission And Registration.
Organizer promotion management is authenticated, event-scoped, and private, no-store for reads. Every operation authorizes manage-paid-event-commerce against the persisted Event organizer; the feature is available only for a platform-managed Event and an exact scoped ticket catalog.
| Route | Route Name | Protection | Purpose |
|---|---|---|---|
GET /api/events/{eventId}/promotions?ticketCatalogVersionId={id} |
GetEventPromotions |
[Authorize], private, no-store |
Lists safe definition versions and masked active-code labels for one exact catalog. |
GET /api/events/{eventId}/promotions/{promotionDefinitionId} |
GetEventPromotion |
[Authorize], private, no-store |
Returns one safe promotion-management HAL resource. |
POST /api/events/{eventId}/promotions |
CreateEventPromotionDraft |
[Authorize], Write, Idempotency-Key |
Creates a draft and returns the submitted code once as issuedCode; no plaintext or digest is present in the management DTO. |
PUT /api/events/{eventId}/promotions/{promotionDefinitionId} |
ReviseEventPromotion |
[Authorize], Write, Idempotency-Key |
Creates the next draft version from a published definition; it does not mutate the published version. |
POST /api/events/{eventId}/promotions/{promotionDefinitionId}/publish |
PublishEventPromotion |
[Authorize], Write, Idempotency-Key |
Publishes a draft and persists only the versioned digest and masked suffix for its submitted code. |
POST /api/events/{eventId}/promotions/{promotionDefinitionId}/revoke |
RevokeEventPromotion |
[Authorize], Write, Idempotency-Key |
Immediately revokes a published definition at the server-owned decision instant; callers cannot schedule an effective time. |
POST /api/events/{eventId}/promotions/{promotionDefinitionId}/code:rotate |
RotateEventPromotionCode |
[Authorize], Write, Idempotency-Key |
Retires the active code, creates its replacement, and returns the replacement plaintext once as issuedCode. |
The management resource DTO exposes only event/catalog/version/currency, definition group/version/status, eligibility, discount parameters, UTC window, redemption limits, and a masked promotionCodeDisplayLabel. Tenant, actor, and organizer identifiers are server-only HAL authorization metadata marked [JsonIgnore]. Digests, lookup-key versions, active/retired storage flags, and reservation IDs are absent from JSON and generated clients. Plaintext appears in generated request models only where a caller must submit a code; among responses, only successful create and rotate command wrappers expose the one-time issuedCode. Collection HAL advertises create-promotion; draft items may advertise only publish; published items may advertise revise-promotion, revoke, and rotate-promotion-code. Link presence, not a role or status guess, is the client action authority.
Authenticated and capability-scoped guest orders expose matching apply/remove operations only while the order is READY_FOR_CHECKOUT:
| Route | Route Name | Protection |
|---|---|---|
POST /api/events/{eventId}/registration-orders/{orderId}/promotion |
ApplyAuthenticatedRegistrationOrderPromotion |
[Authorize], Write, Idempotency-Key |
DELETE /api/events/{eventId}/registration-orders/{orderId}/promotion |
RemoveAuthenticatedRegistrationOrderPromotion |
[Authorize], Write, Idempotency-Key |
POST /api/events/{eventId}/registration-orders/guest/{orderId}/promotion |
ApplyGuestRegistrationOrderPromotion |
[AllowAnonymous], PublicTransactional, Idempotency-Key, X-Registration-Order-Capability |
DELETE /api/events/{eventId}/registration-orders/guest/{orderId}/promotion |
RemoveGuestRegistrationOrderPromotion |
[AllowAnonymous], PublicTransactional, Idempotency-Key, X-Registration-Order-Capability |
Guest operations use the fixed 10-per-60-second effective-IP public_transactional window with queue limit zero. Browser-originated unsafe requests still cross the BFF antiforgery boundary. Both guest responses protect idempotency replay metadata. Authenticated operations use the per-user Write policy. The capability is header-only and is compared with the opaque hash on the exact tenant/Event/order row after order-expiry validation; it never enters a URL, response body, HAL link, log, or generated DTO.
Apply accepts { "code": "..." }. Lookup normalizes the code, computes candidates for only the key versions already represented by active scoped codes, then rechecks tenant, Event, catalog, currency, window, eligibility, limits, one-active-reservation, and pinned fee-policy facts inside one serializable transaction. Wrong order ownership/capability, an empty or overlong submitted code, unknown or retired code, wrong scope, exhausted limit, inactive definition, an existing promotion, and pricing mismatch all collapse to the same registration-order 404; the API does not reveal which predicate failed. Syntactically malformed JSON remains subject to the API's global safe 400 validation contract before the redemption handler runs. Success returns only the masked applied label plus promotion discount, platform fee, platform contribution, and final due totals. Order reads expose aggregate pre-discount, discount, post-discount, organizer-directed, fee, organizer-earnings, contribution, and final totals in one currency; organizerDirectedTotalMinor equals postDiscountOrganizerDirectedTotalMinor. They do not expose raw code, digests, key versions, promotion IDs, reservation IDs, or authorization booleans.
Order HAL exposes exactly one of apply-promotion or remove-promotion in READY_FOR_CHECKOUT; authenticated links pass through normal registration-order authorization and guest links remain capability-bound at execution. Promotion removal clears the applied snapshot and reprices from pinned facts. Positive totals proceed to the payment contract below; zero totals continue to use the provider-free finalize relation.
Account-owned and capability-scoped guest orders expose idempotent payment start and retry plus private authoritative status. Guest writes are PublicTransactional, require Idempotency-Key, and accept X-Registration-Order-Capability only as a header. Account writes require authentication and current-account ownership. Status and start responses are HAL resources with only safe state, timestamps, bounded failure codes, and redirect/retry availability; they exclude provider account/session/payment/request identifiers, idempotency keys, capabilities, PII, and raw provider errors.
| Route | Operation | Protection |
|---|---|---|
POST /api/events/{eventId}/registration-orders/{orderId}/payment |
StartAuthenticatedRegistrationPayment |
[Authorize], Write, idempotent, private/no-store |
GET /api/events/{eventId}/registration-orders/{orderId}/payment |
GetAuthenticatedRegistrationPayment |
[Authorize], private/no-store |
POST /api/events/{eventId}/registration-orders/{orderId}/payment/retry |
RetryAuthenticatedRegistrationPayment |
[Authorize], Write, idempotent, private/no-store |
GET /api/events/{eventId}/registration-orders/{orderId}/payment/checkout-target |
GetAuthenticatedRegistrationPaymentCheckoutTarget |
[Authorize], current-account access, private/no-store; BFF resolver use |
POST /api/events/{eventId}/registration-orders/guest/{orderId}/payment |
StartGuestRegistrationPayment |
[AllowAnonymous], PublicTransactional, capability, idempotent, private/no-store |
GET /api/events/{eventId}/registration-orders/guest/{orderId}/payment |
GetGuestRegistrationPayment |
[AllowAnonymous], capability, private/no-store |
POST /api/events/{eventId}/registration-orders/guest/{orderId}/payment/retry |
RetryGuestRegistrationPayment |
[AllowAnonymous], PublicTransactional, capability, idempotent, private/no-store |
GET /api/events/{eventId}/registration-orders/guest/{orderId}/payment/checkout-target |
GetGuestRegistrationPaymentCheckoutTarget |
[AllowAnonymous], capability, private/no-store; BFF resolver use |
GET /api/events/{eventId}/registration-orders/{orderId}/payment/studio |
GetStudioRegistrationPayment |
[Authorize], exact manage-paid-event-commerce, private/no-store |
Payment state is one of Created, Processing, RequiresAction, Unknown, Failed, Cancelled, Succeeded, or NeedsReconciliation. Start only claims or reuses the durable attempt/dispatch effect and never calls the provider synchronously. Retry is available for a parked pre-handoff dispatch with no provider session or an authoritative terminal Failed/Cancelled attempt; ambiguous, processing, succeeded, and reconciliation-required states never advertise it. Idempotency identity binds the tenant key to the resolved path/route values and a SHA-256 capability scope; raw capability values are never persisted or logged. Successful replay restores protected Cache-Control: private, no-store metadata. checkout-redirect is an antiforgery-protected, rate-limited, PathBase-aware same-origin BFF POST executed by browser fetch in every Blazor render mode. Anonymous rate partitions use only trusted effective remote IP plus resolved tenant; authenticated partitions use stable user ID, never checkout or antiforgery cookies. Issue prepares the compact cookie, rechecks RequestAborted, commits the bounded server-side target, rechecks again, and only then writes cookies; cancellation triggers compare-and-delete rollback. The constant GET requires Sec-Fetch-Site: same-origin exactly before validating origin, PathBase, tenant/order, expiry, dedicated-session digest, and current allowlist, then peeks and atomically consumes the target. Split requires healthy Redis and Combined standalone uses bounded expiry scavenging. URL paths and traces contain no bearer ticket, capability, PII, provider account, API token, or provider URL.
Checkout creation runs asynchronously from the Quartz payment-reconciliation-drain job. It uses the authoritative public HTTPS origin for success/cancel navigation, the persisted connected account and idempotency key, and the configured Checkout-host allowlist. Signed Connect callbacks only persist a normalized inbox envelope and make reconciliation due. Authoritative connected-account Checkout and PaymentIntent retrieval, exact amount/currency/application-fee matching, and fenced monotonic settlement determine payment truth; browser return navigation never does.
Terminal Failed or Cancelled retry persists release of the old active slot before creating one replacement. Capability-aware replay recognizes only safe Created + Pending or DispatchPending + Failed replacement states and returns the same attempt/effect; Unknown and Succeeded remain forbidden, and expired guest capability remains 404. A normalized PublicBaseUrl subpath is preserved in Checkout success/cancel URLs.
Admission ticket self-service recovery, credential rotation, and account-owned ticket reads live under /api/tickets. See ADMISSION_AND_REGISTRATION.md for domain concepts and zero-knowledge security architecture.
| Route | Route Name | Protection | Purpose |
|---|---|---|---|
POST /api/tickets/recovery |
RequestAdmissionTicketRecovery |
[AllowAnonymous], PublicTransactional, Idempotency-Key, public_transactional plus chained tenant recovery budget |
Initiates self-service ticket recovery. Accepts { "email": "..." } and returns indistinguishable 202 Accepted to prevent email enumeration. |
POST /api/tickets/recovery/consume |
ConsumeAdmissionTicketRecovery |
[AllowAnonymous], Public, private, no-store, AdmissionTicketRecoveryPolicy, X-Admission-Ticket-Recovery-Capability |
Consumes a single-use recovery capability token passed via header; rotates the capability and delivers the rendered QR SVG and printable ticket DTO. |
Key invariants:
- Zero-Knowledge Bearers: Plaintext QR barcodes and tokens are never persisted in the database; only versioned keyed HMAC-SHA-256 lookup digests (
LookupDigest) are stored. - Wire Payload Format: Standardized
islamu-admission:v1:<43-char-base64url-bearer>(63 characters total). - Indistinguishable Email Responses:
POST /api/tickets/recoveryreturns identical202 Acceptedpayloads regardless of whether the email address has active tickets. - Two-layer request throttling: recovery request traffic must pass both the per-IP
public_transactionalpolicy and the global tenant-scoped recovery budget. Capability consumption keeps the dedicated recovery limiter. - No consume-response replay: capability consumption is intentionally not idempotency-cached. The keyed, expiring capability is atomically single-use, and replaying a cached successful bearer response would violate that authority.
- Single-Use Capabilities: Recovery capability tokens expire and rotate automatically upon consumption or re-request.
Phase 21 check-in is online and server-authoritative. Staff routes use ordinary authenticated event
authority; scanner routes use only the opaque X-Admission-Scanner-Capability scheme bound to one
tenant, event, target, action set, and expiry. Every response is private/no-store, mutation
failures are bounded RFC 7807 responses, and UI controls come only from returned HAL relations.
| Route | Route Name | Protection | Purpose |
|---|---|---|---|
GET /api/events/{eventId}/admission/scanner-capabilities |
ListAdmissionScannerCapabilities |
Staff event_check_in:view |
Lists masked capabilities for the event. |
POST /api/events/{eventId}/admission/scanner-capabilities |
IssueAdmissionScannerCapability |
Staff event_check_in:manage, Idempotency-Key |
Issues one target-scoped capability and discloses plaintext once. |
DELETE /api/events/{eventId}/admission/scanner-capabilities/{scannerCapabilityId} |
RevokeAdmissionScannerCapability |
Staff event_check_in:manage |
Revokes one device authority without stopping other target channels. |
POST /api/events/{eventId}/admission/check-ins |
CheckInAdmission |
Staff event_check_in:manage, Idempotency-Key |
Checks in one opaque credential against one body targetId. |
POST /api/events/{eventId}/admission/check-ins/batch |
BatchCheckInAdmissions |
Staff event_check_in:manage, Idempotency-Key |
Processes 1–100 business outcomes independently in input order; a dependency outage aborts the remaining queue with 503. |
GET /api/events/{eventId}/admission/check-ins/{checkInId} |
GetAdmissionCheckIn |
Staff event_check_in:view |
Returns bounded fact detail and current undo affordance. |
POST /api/events/{eventId}/admission/check-ins/{checkInId}/undo |
UndoAdmissionCheckIn |
Staff event_check_in:manage, Idempotency-Key |
Appends a compensation linked to the exact active fact. |
GET /api/events/{eventId}/admission/check-ins/summary?targetId={targetId} |
GetAdmissionCheckInSummary |
Staff event_check_in:view |
Returns exact-target aggregate counts without roster data. |
GET /api/events/{eventId}/admission/check-ins/audit?cursor={cursor}&pageSize={pageSize} |
GetAdmissionCheckInAudit |
Staff event_check_in:view |
Traverses an export-safe immutable keyset page of 1–100 facts. |
GET /api/events/{eventId}/admission/check-ins/health?targetId={targetId} |
GetAdmissionCheckInHealth |
Staff event_check_in:view |
Returns Active, Stopped, or Unavailable plus dependency state. |
POST /api/events/{eventId}/admission/check-ins/operations/stop |
StopAdmissionCheckIn |
Staff event_check_in:manage |
Stops every admission channel for the exact target. |
POST /api/events/{eventId}/admission/check-ins/operations/restore |
RestoreAdmissionCheckIn |
Staff event_check_in:manage |
Restores the exact target after dependency recovery. |
POST /api/events/{eventId}/admission/check-ins/operations/reconcile |
ReconcileAdmissionCheckIn |
Staff event_check_in:manage |
Appends a bounded post-incident decision without rewriting facts. |
POST /api/admission/scanner/check-ins |
ScannerCheckInAdmission |
AdmissionScanner, Idempotency-Key |
Performs one check-in using the authenticated capability target. |
POST /api/admission/scanner/check-ins/batch |
ScannerBatchCheckInAdmissions |
AdmissionScanner, Idempotency-Key |
Processes a bounded target-fixed scanner batch and aborts remaining work on dependency outage. |
POST /api/admission/scanner/check-ins/{checkInId}/undo |
ScannerUndoAdmissionCheckIn |
AdmissionScanner, Idempotency-Key |
Compensates the exact active fact when the capability permits undo. |
Undo request bodies carry reasonCode, restricted to OperatorCorrection, DuplicateScan,
WrongTarget, or ExceptionalReconciliation; free-form reason text is not accepted or persisted.
Invalid credential, capability, tenant, event, and target states remain indistinguishable. A
dependency failure returns bounded 503; scanner clients stop intake and retain no offline queue.
Authenticated registration-form authoring is rooted at /api/events/{eventId:guid}/registration-workflows. The event manage-registration-workflow relation is emitted only after server-side authorization confirms a verified organizer controller or an explicit event registration-manager assignment. Community contributors, listing submitters, tenant-only curators, machine principals, ambiguous organizers, and unrelated tenants receive neither the relation nor authoring data.
The surface provides event-purpose workflow read/create/update; requirement create/update/delete; form create/read; version create-or-clone/read; draft section, field, option, and bounded-rule mutations; publication preflight; and publication. Reads are authenticated and private, no-store. Writes use the WritePolicy, require a strong quoted If-Match containing the observed parent/root concurrency stamp, and return 409 Conflict for stale observations. Preflight rejects empty forms, incomplete choice options, unresolved or forward condition references, and explicit-consent fields without both a purpose code and text version. Publication always invokes the Application publication facade and atomically pins the exact generated data, UI, logic, and mapping artifacts plus their lowercase SHA-256 hash; callers never supply schema JSON or hashes.
The authoritative preflight operation is POST /api/events/{eventId:guid}/registration-forms/{formId:guid}/versions/{versionId:guid}/preflight (GetRegistrationFormPublishPreflight); the former publish:preflight spelling is not part of the contract. The generated OpenAPI document, NSwag client, and API_CONTRACT_INVENTORY.md are regenerated from this route and checked for byte-level determinism.
Draft authoring resources expose only state-valid, permission-checked HAL relations such as edit, add-section, add-field, add-option, add-rule, preflight, and publish. Published versions expose read, preflight, and new-version navigation only; child mutation relations are omitted. DTOs expose normalized lookup IDs/codes/names, lifecycle status, provenance, schema hash, and concurrency stamps, with no provider question IDs, claims, roles, or local capability booleans.
Participation requirements attach through authenticated POST /api/events/{eventId}/participation/requirements/{requirementId} and detach idempotently through DELETE on the same route. Both operations require the current participation-configuration stamp in a strong quoted If-Match header and are authorized as registration-form attach/detach actions. The workflow management projection exposes persisted isAttached state, while each requirement resource emits exactly one matching, permission-checked attach or detach HAL relation as the action authority. A valid walk-in standalone questionnaire is discovered anonymously at GET /api/events/{eventId}/participation/optional-questionnaire; the HAL optional-questionnaire relation is the only client affordance and the endpoint returns non-leaking 404 when the active published descriptor is unavailable.
Platform fees and optional instance contributions are a separate versioned settings resource:
| Route | Route Name | Auth | Purpose |
|---|---|---|---|
GET /api/instance/settings/platform-monetization |
GetInstancePlatformMonetizationSettings |
[Authorize], Admin classification |
Returns the active fee and contribution revisions as a HAL resource. |
PUT /api/instance/settings/platform-monetization |
UpdateInstancePlatformMonetizationSettings |
[Authorize], Admin classification, Write rate limit |
Creates replacement fee and contribution revisions after expected-version checks. |
Both Application handlers recheck instance-administrator authority before repository access. Fresh defaults are fee disabled with zero basis points and no fixed charges, plus contribution disabled with a zero default option. The response emits edit only when the caller may update the platform-monetization instance setting. Tenant administrators, organizers, and curators have no monetization management path.
Configured in RateLimitingExtensions.cs. All settings are configurable via appsettings.json under RateLimiting section.
- Policy:
global— applied to all endpoints by default. - Mechanism: Token bucket per successfully authenticated API key ID when present, otherwise per remote IP address. Empty, malformed, invalid, revoked, or inactive API keys do not create key partitions and remain in the anonymous/IP partition.
- Defaults: 200 tokens, replenish 40 tokens per 10 seconds.
- IP Resolution: uses
HttpContext.Connection.RemoteIpAddress; trusted forwarded-header middleware updates the effective remote/host values earlier in the pipeline. - Exemption: Localhost (
127.0.0.1,::1) is exempt.
- Policy:
authenticated— for authenticated user endpoints. - Mechanism: Sliding window per API key ID when present, otherwise per
User.Identity.Name. - Defaults: 200 requests per 60-second window, 4 segments.
- Policy:
write— for mutation endpoints (POST,PUT,DELETE). - Mechanism: Fixed window per API key ID when present, otherwise per
User.Identity.Name. - Defaults: 30 requests per 60-second window.
- Policy:
PublicIngestion— for anonymous signed machine callback endpoints. - Mechanism: Fixed window per IP address.
- Defaults: 60 requests per 60-second window.
- Policy:
setup_secret— for instance bootstrap endpoints. - Mechanism: Fixed window per IP address. GET requests include the normalized route in the partition so status, journey, and provider-configuration reads cannot exhaust one another; setup mutations retain one shared IP partition.
- Defaults: 5 requests per partition per 60-second window.
- Keycloak operator routes: the seven
/api/instance/keycloakroutes use the setup or authenticated authority partition selected by the current request; setup authority does not bypass the receipt's setup-generation binding.
- Policy:
AnalyticsRelay— for anonymous browser analytics relay traffic. - Mechanism: Fixed window per IP address.
- Defaults: 120 requests per 60-second window.
- Policy:
AiAssistant— for AI assistant send/model/action endpoints. - Mechanism: Fixed window per API key ID when present, otherwise per authenticated user ID.
- Defaults: 12 requests per 60-second window.
- Policy:
EventOpenGraphImage— for public event Open Graph image rendering. - Mechanism: Concurrency limiter with one fixed
EventOpenGraphImagepartition shared by all requests in the API process. - Defaults: 2 concurrent renders, queue limit 0.
- Returns
429 Too Many Requestswith RFC 6585ProblemDetails. - Includes
Retry-Afterwhen available plusX-RateLimit-LimitandX-RateLimit-Remaining.
In Testing environment, all rate limiters, including EventOpenGraphImage, are replaced with NoLimiter (disabled). Integration factories can opt back into rate limiting to verify 429, Retry-After, and partition behavior.
Configured in RequestTimeoutExtensions.cs. All settings configurable via RequestTimeouts section.
| Policy | Default | Use Case |
|---|---|---|
Default |
30 seconds | Standard operations |
Lookup |
10 seconds | Fast lookup queries |
Complex |
60 seconds | Complex queries, exports |
Timeout expiry returns 504 Gateway Timeout.
Applied via [OutputCache(PolicyName = "...")] on controller endpoints.
| Policy | Duration | Vary By | Use Case |
|---|---|---|---|
LookupData |
1 hour | X-Tenant-Slug, Host |
Lookup tables (event types, languages, etc.) |
PublicData |
1 hour | X-Tenant-Slug, Host |
Anonymous lookup endpoints |
ListData |
30 seconds | X-Tenant-Slug, Host, Authorization, query: pageNumber, pageSize |
Collection listings |
DetailData |
60 seconds | X-Tenant-Slug, Host, Authorization, route: id |
Single-entity detail views |
TenantNav |
5 minutes | X-Tenant-Slug, Host |
Tenant navigation/config endpoints |
PublicExperienceShell |
30 seconds | X-Tenant-Slug, Host |
Public shell and experience settings |
SystemConfig |
10 seconds | Host |
System configuration checks |
SitemapData |
30 minutes | X-Tenant-Slug, Host |
Sitemap output |
The default ASP.NET Core output-cache store is process-local. HybridCache or
IDistributedCache does not distribute output-cache entries or tag eviction:
immediate tag eviction is guaranteed only on the replica handling the request,
while other replicas may serve stale output until the policy TTL expires.
Cross-replica output-cache invalidation is deferred without a dedicated
distributed output-cache dependency.
Injected into native CQS handlers, not controllers. Provides in-memory L1 + distributed L2 caching with stampede protection.
| Setting | Value |
|---|---|
| Default expiration | 30 minutes |
| Local cache expiration | 5 minutes |
| Max payload size | 10 MB |
| Max key length | 512 characters |
Read-through pattern (query handlers): _cache.GetOrCreateAsync(key, factory, options).
Invalidation (command handlers): _cache.RemoveAsync(key).
- Computes SHA256-based weak ETags on
application/jsonandapplication/hal+jsonresponses. - Returns
304 Not Modifiedwhen client sendsIf-None-Matchheader matching current ETag. - Applied globally after output cache in the pipeline.
- Uses
RecyclableMemoryStreamfor efficient memory handling. Bodies larger than 256 KB skip ETag computation.
Added by SecurityHeadersMiddleware to every response:
| Header | Value |
|---|---|
X-Content-Type-Options |
nosniff |
X-Frame-Options |
DENY |
Referrer-Policy |
strict-origin-when-cross-origin |
Permissions-Policy |
camera=(), microphone=(), geolocation=(), payment=() |
Content-Security-Policy |
default-src 'none'; frame-ancestors 'none' |
Non-GET responses additionally receive:
Cache-Control: no-storePragma: no-cache
- Authority: Keycloak OIDC metadata endpoint.
- Multi-client audience validation:
islamu-event-api,islamu-event-blazor. - Custom
AudienceValidator: checks bothaudclaim andazp(Keycloak authorized party) claim. - Clock skew tolerance: 5 minutes.
- Dev mode: accepts self-signed certificates.
- Minimal JWT event logging:
OnAuthenticationFailed(Warning),OnChallenge(Debug). PII-leaking handlers removed.
GET: usually[AllowAnonymous]POST/PUT/DELETE:[Authorize]- Privileged operations: role/policy constrained
- User ID extraction fallback order:
sub→nameidentifier→sid.
KeycloakOpenApiSecurityTransformer always defines HTTP Bearer (JWT) and
header ApiKey (X-API-Key) authentication for native documentation generation.
Operations using default [Authorize] metadata advertise these as separate
alternatives, matching MultiAuth provider selection for Local Identity,
AT Protocol sessions, external JWTs, and API keys. A valid configured Keycloak
authorization URL or authority additionally exposes the existing Keycloak OAuth
flow and alternative; absent configuration never invents an OAuth endpoint.
There is no document-wide security requirement. Anonymous operations, explicit
specialized authentication schemes, and pre-existing operation requirements
retain their own contracts. These descriptions do not change runtime token
validation, tenant binding, scope checks, or resource authorization.
ApiTenantResolutionMiddleware exempts POST /api/auth/local/login,
authenticated GET /api/user for Local-session validation, and authenticated
GET /api/user/admin-authority from tenant resolution. Native Local credentials,
the current user's identity, and persisted administrator authority are
instance-scoped; the BFF can therefore sign in and validate an administrator
at the dedicated admin host without naming an ordinary tenant. Credential
verification retains its rate limiter and no-store response; both identity
reads still require a valid bearer token. The current-user exception is
method-specific: DELETE /api/user, event reads, and other tenant-scoped
API actions still fail closed without a resolved tenant. The persona HTTP
regression verifies these boundaries.
AuthorizationCommandHandlerDecorator and AuthorizationQueryHandlerDecorator check:
IAuthorizedRequestinterface — commands/queries declare required permissions.[AuthorizeResource]attribute — declarative resource-level authorization.ISecureRequest— provides dynamic resource context for permission evaluation.
Denied requests throw AuthorizationException → mapped to 403 Forbidden by exception handler.
Non-interactive callers authenticate with long-lived X-API-Key credentials in the form {keyId}.{secret}. The endpoint contract is otherwise identical to JWT callers — only the credential presentation and principal shape differ.
Owner Types are normalized lookup rows. Write contracts use externalApiKeyOwnerTypeId; read contracts expose externalApiKeyOwnerTypeId, externalApiKeyOwnerTypeCode, and externalApiKeyOwnerTypeName so clients do not depend on domain enum serialization.
| ID | Code | Owner | Tenant Binding | Effective Authority |
|---|---|---|---|---|
1 |
USER |
User | Required | Key inherits the owner user's memberships (tenant/org/group admin claims) |
2 |
ORGANIZATION |
Organization | Required | Acts as organization admin for the owning org within the bound tenant |
3 |
GROUP |
Group | Required | Acts as group admin for the owning group within the bound tenant |
4 |
TENANT |
Tenant | Required | Acts as tenant admin for the bound tenant |
5 |
INSTANCE_ADMIN |
Instance Admin | Nullable credential | Platform operator; tenant-scoped API/MCP execution requires an explicit tenant hint, while explicit host-administration routes may run without tenant context |
Scope Model (ExternalApiKeyScopes): events:read, events:write, organizations:read, organizations:write, groups:read, groups:write, users:read, users:write, lookups:read, mcp:read, registrations:write, mcp:propose, api-keys:manage, admin:tenant, admin:instance. Effective permissions are the intersection of (a) the scopes on the key and (b) the owner's authority ceiling (ExternalApiKeyScopeCeiling). Keys cannot hold scopes above their owner type. Anonymous-safe MCP event tools expose only published public event data; program/session tools first require the event detail query to resolve a Published + Public event before returning lower-level program or session data. Protected MCP event-management reads such as list_my_events, get_event_creation_context, get_event_publish_readiness, event_management_context, and the Phase 5 program/custom-property/registration/team/template/sync contexts require a user bearer session or, for API keys, both mcp:read and the existing event read scope gate (events:read, events:write, or tenant/admin equivalent accepted by MachineScopeMapping). mcp:read alone does not grant private event-management discovery. These reads derive user and tenant context from the authenticated request and existing Application services rather than accepting caller-supplied user, tenant, role, or claim data. The management-context resource derives edit/delete/publish/publish-readiness/add-session/session-create-context availability from REST HAL _links, not from MCP-side role checks. The standalone get_event_publish_readiness tool also requires the REST HAL publish-readiness affordance before it calls GetEventPublishReadinessRequest, so MCP cannot broaden publish readiness beyond the current management action surface. MCP-specific scopes are least-privilege AI-conversation and adapter scopes: mcp:read allows scoped MCP read discovery, mcp:read plus event read scope authority allows protected event-management reads, and mcp:propose is required to discover/call MCP proposal tools and prompts without granting event writes or confirmation authority.
Authentication Flow:
ApiKeyAuthenticationHandlerparses theX-API-Keyheader, splits{keyId}.{secret}.- Repository lookup via
IgnoreTenantFilter(auth path only) returns the stored key. - Secret is SHA256-hashed and verified with fixed-time comparison against
SecretHash. Failed authentication attempts are recorded with boundedoutcome,tenant_id, andowner_typetags only; raw API keys, secrets, and request paths are never metric tags. ApiTenantPostAuthenticationMiddlewareasserts the API-keyTenantIdmatches the resolved request tenant. Tenant-bound keys with mismatched hints return404 Tenant mismatch.InstanceAdminkeys remain nullable credentials, but tenant-scoped API and MCP requests must resolve an execution tenant through the normal tenant hint/host pipeline. With an explicit tenant hint, the middleware binds that tenant for the request; without one, tenant-scoped API/MCP calls fail closed with404andcode=tenant_required. Only explicit host-administration API routes continue without tenant context.- Principal is materialized with claims
explore:api-key:id,explore:tenant:id(absent for the persisted InstanceAdmin credential),explore:api-key:owner:type,explore:api-key:owner:id, and repeatedexplore:api-key:scopeclaims. TouchUsageMetadataupdatesLastUsedAt/LastUsedIp(5-minute throttle per key).
Machine Principal Authorization: IMachinePrincipalAccessor exposes the current ApiKeyPrincipalContext to both authorization providers. CerbosPrincipalBuilder.BuildMachinePrincipalAsync emits a Cerbos principal with is_machine=true, api_key_id, owner_type, owner_id, scopes, and synthesized isInstanceAdmin/tenantMemberships/orgMemberships attributes derived from owner type. FallbackAuthorizationService applies MachineScopeMapping.ScopesPermit as a fast-reject gate before dispatching to owner-type-specific authority checks — so machine principals evaluate consistently against both local and Cerbos-backed authorization.
Management Endpoints (/api/ExternalApiKey):
| Verb | Route | Purpose | Response |
|---|---|---|---|
GET |
/api/ExternalApiKey |
List keys visible to the caller | HAL collection |
GET |
/api/ExternalApiKey/{id} |
Key detail (metadata only, no secret) | HAL resource |
POST |
/api/ExternalApiKey |
Issue once or recover metadata with a required operation key | 200 success-only ExternalApiKeyIssuanceDto |
PATCH |
/api/ExternalApiKey/{id} |
Update policy (scopes, expiry, quotas) | Command result |
DELETE |
/api/ExternalApiKey/{id} |
Revoke key (soft delete, status=Revoked) | 204 No Content |
GET |
/api/ExternalApiKey/usage-report |
Tenant admins see their tenant; instance admins see platform-wide | Aggregated report |
Create/revoke/update emit business metrics (created, revoked, policy_updated) tagged with tenant_id and owner_type.
POST /api/ExternalApiKey requires exactly one Idempotency-Key header:
1..128 ASCII characters matching [A-Za-z0-9._:-]. Missing, duplicate,
comma-combined, whitespace, Unicode, or otherwise invalid values fail 400.
The key is case-sensitive. The native CreateExternalApiKeyCommand also
requires OperationKey and validates the same Domain grammar, so non-HTTP
callers cannot bypass this requirement. OpenAPI marks the header as required.
For example, send Idempotency-Key: key-create-20261005-01 on a single
creation intent. The generated C# client call is
CreateExternalApiKeyAsync(operationKey, dto); keep both arguments stable
after a lost response. The body chooses requested policy and legitimate
organization/group targets, not the current actor or tenant. The handler
resolves the platform user through IAdminContext.ResolveUserIdAsync and
takes scope from the trusted tenant context; InstanceAdmin issuance is
explicitly global (TenantId = null).
Unresolved trusted tenancy is a validation failure for User, Tenant,
Organization and Group issuance: HTTP returns 400 before the clean write-scope
guard or transaction, without a key or receipt. Global InstanceAdmin issuance
continues to work without selecting a tenant.
| Outcome | HTTP | Disclosure |
|---|---|---|
| First acknowledged creation | 200 |
disclosureStatus = "Issued"; apiKey contains the raw credential |
| Authorized retry with the same normalized policy | 200 |
disclosureStatus = "PreviouslyIssued"; apiKey = null; stable id and keyId |
| Same operation identity, different normalized policy | 409 |
Conflict; no new credential |
| Missing or empty authenticated platform-user binding | 401 |
No recovery metadata |
| Tenant-owned issuance without a resolved trusted tenant | 400 |
Validation; no credential or receipt |
| Current owner authority revoked or unavailable | 403 |
No recovery metadata |
| Issued key removed, revoked, expired, or otherwise unusable | 404 |
No recovered credential |
The public success payload requires id, keyId, disclosureStatus, and
apiKey. Its disclosure enum contains exactly Issued and PreviouslyIssued;
it cannot be null or represent failure. Recovery explicitly carries null
apiKey. Native commands retain failure metadata in
CreateExternalApiKeyCommandResponse with no Issue; the controller publishes
only the validated success payload or ProblemDetails. The browser validates
generated success data before accepting it and keeps failure guidance in a
local result, not a fabricated generated success contract.
The input digest covers every accepted field: trimmed name, normalized optional description, owner-type and target IDs, trimmed/lowercased/deduplicated/sorted scopes, UTC expiry, normalized credit period, credit limit, and rollover limit. It excludes current actor and tenant identity; those bind the separate operation fingerprint. Recovery of a committed operation does not generate entropy or create another credential; an uncommitted attempt can still complete issuance.
The creation service distinguishes definitive 400/401/403/404/409
rejections from transport errors and 5xx uncertainty. It renders fixed,
status-specific cancellation, sign-in, access-restoration, or list-review guidance
without reflecting the remote body or exception. The dialog keeps the submitted
policy and operation key frozen; a new intent remains deliberate rather than an
automatic replacement for a rejected request.
[SuppressIdempotencyResponseStorage] bypasses generic replay response capture
for this action; [ResponseCache(NoStore = true)] remains. Recovery comes from
a digest-only issuance receipt, never a saved response body. See
receipt retention,
commit authority,
and the operator recovery guide.
The exact GET-list and POST-creation route, and DELETE with one aggregate ID, may proceed without a tenant only after normal tenant resolution has been attempted. This supports global InstanceAdmin issuance and the existing revoke/reissue remedy on the administrator host. It grants no owner authority: native handlers retain their current ownership checks, and issuance rejects tenant-bound owners without a resolved scope. List, revoke, and issuance resolve the current provider-to-platform-user binding instead of parsing a provider's GUID subject as the platform user ID.
Trusted ERP, CRM, and managed-hosting operators provision customers through the managed-provider composition endpoint:
| Verb | Route | Route Name | Purpose | Response |
|---|---|---|---|---|
POST |
/api/managed-provider-provisioning/clients:ensure |
EnsureManagedProviderClientProvisioned |
Create or rehydrate a provider-customer tenant, external admin identity, tenant-local user state, tenant-admin grant, and optional approved organizer actor. | BaseCommandResponse<ManagedProviderClientProvisioningResultDto> |
Security and tenancy rules:
- The endpoint is
[Authorize], classified asEndpointClass.Admin, and explicitly checksIAdminContext.IsInstanceAdminAsync; ERP customer/admin identities are never treated as instance administrators by this path. - Provider automation identifies the customer with stable
providerKey,externalSystem, andexternalCustomerId; OIDC user linkage uses stableidentityProvider+subject, not mutable email or display names. - The Application command writes provider-neutral
ExternalBindingrecords for the customer tenant, tenant-local user state, user actor, external login, and optional organizer records. Existing bindings rehydrate the original IDs for retry-safe provisioning. - Tenant-local user status/profile/moderation state is stored in
TenantUserandTenantUserProfile; the globalUserremains the auth/account anchor. - Do not send arbitrary tenant headers as provisioning authority. The command creates or resolves the tenant from trusted bindings and returns the resulting internal IDs.
- Send
Idempotency-Keyon HTTP retries. The durable source of truth is still theExternalBindinguniqueness model, so a replay with the same provider/customer IDs returns the existing provisioning result.
Tenant role authority is exposed through explicit role-grant endpoints, not the former tenant-member contract:
| Verb | Route | Route Name | Purpose | Response |
|---|---|---|---|---|
GET |
/api/tenant-user-role-grants |
GetTenantUserRoleGrants |
List active/revoked tenant-local role grants for tenant admins in the resolved tenant or instance admins. | HAL collection of TenantUserRoleGrantListDto |
GET |
/api/tenant-user-role-grants/{id} |
GetTenantUserRoleGrantById |
Retrieve one tenant-local role grant for an authorized tenant or instance admin. | HAL resource of TenantUserRoleGrantDto |
POST |
/api/tenant-user-role-grants |
CreateTenantUserRoleGrant |
Grant a tenant-scoped role to an active TenantUser. |
BaseCommandResponse<Guid> |
DELETE |
/api/tenant-user-role-grants/{id} |
RevokeTenantUserRoleGrant |
Revoke a grant and populate revoke audit fields. | 204 No Content |
Contract rules:
- Create accepts
TenantUserIdand tenant-scopedRoleId; tenant identity is derived fromITenantContext, not request bodyTenantId. - Read DTOs are identity-bearing administrative projections.
GETroutes require authentication plusislamuevent_tenant_user_role_grantresource authorization for actionview; regular authenticated users cannot enumerate or inspect tenant role grants. - Handlers validate that the
TenantUserbelongs to the resolved tenant, is active, is not soft-deleted, and that the role has tenant scope. - Grant changes are create/revoke, not update-in-place. HAL detail resources may expose
revoke; collection resources may exposecreate; clients must render actions from_links. - Cerbos/local authorization uses resource kind
islamuevent_tenant_user_role_grantwithview,create, anddeleteactions.
Organization membership endpoints expose identity, role, and position data for organization administration:
| Verb | Route | Route Name | Purpose | Response |
|---|---|---|---|---|
GET |
/api/organizationmember/{organizationId} |
GetOrganizationMembersByOrganization |
List members of one organization for authorized organization or tenant administrators. | HAL collection of OrganizationMemberDto |
GET |
/api/organizationmember/member/{id} |
GetOrganizationMemberById |
Retrieve one organization member for authorized organization or tenant administrators. | HAL resource of OrganizationMemberDto |
POST |
/api/organizationmember |
AddOrganizationMember |
Add or invite an organization member inside the resolved tenant. | BaseCommandResponse<Guid> |
PUT |
/api/organizationmember/role |
UpdateOrganizationMemberRole |
Change a member role or position. | BaseCommandResponse<Guid> |
DELETE |
/api/organizationmember/{id} |
DeleteOrganizationMember |
Remove a member. | 204 No Content |
Contract rules:
OrganizationMemberDtois an identity-bearing administrative projection. It includestenantId,organizationId,userId,userFullName,userEmail, role, and position fields; it is not a public organization profile DTO.- Member list/detail reads require authentication plus operation resource authorization for resource kind
islamuevent_organization_memberand actionview. Regular authenticated users without tenant-admin or organization-admin authority receive403. - List reads authorize with the resolved tenant id and route organization id. Detail reads authorize by member id, and the authorization decorator enriches the resource attributes from the repository before evaluating Cerbos/local fallback policy.
- Create and HAL collection affordances carry the resolved tenant id and organization id so tenant-admin and organization-admin checks use the same resource/action context as the API path.
- Clients must gate member-management UI from HAL
_linkssuch as collectioncreateand item edit/delete links, not from local role or claim inspection.
Outgoing product webhooks are managed under /api/webhooks. These routes configure ISLAMU Event sending webhook events to external systems through the selected outgoing provider (Disabled, Local, Svix, Composite, or DryRun). Incoming provider callbacks such as Coop, Osprey, payment, email, or future Svix operational callbacks remain separate integration endpoints under /api/integrations/*; they do not require the outgoing provider to be enabled.
| Verb | Route | Route Name | Purpose | Response |
|---|---|---|---|---|
GET |
/api/webhooks/event-types |
GetWebhookEventTypes |
Public primary event catalog with schema/example metadata. | IReadOnlyList<WebhookEventTypeDto> |
GET |
/api/webhooks/consumers |
GetWebhookConsumers |
Tenant-scoped webhook consumers/integration owners visible to the caller. | HAL collection of WebhookConsumerDto |
GET |
/api/webhooks/consumers/{consumerId} |
GetWebhookConsumerById |
One tenant-scoped webhook consumer. | HAL resource of WebhookConsumerDto |
POST |
/api/webhooks/consumers |
CreateWebhookConsumer |
Create a tenant-scoped consumer for Local/Svix/Composite/DryRun/Disabled mode. | BaseCommandResponse<Guid> |
GET |
/api/webhooks/endpoints |
GetWebhookEndpoints |
Tenant-scoped webhook endpoints, optionally filtered by consumer. | HAL collection of WebhookEndpointDto |
GET |
/api/webhooks/endpoints/{endpointId} |
GetWebhookEndpointById |
One tenant-scoped webhook endpoint with enabled event subscriptions. | HAL resource of WebhookEndpointDto |
POST |
/api/webhooks/endpoints |
CreateWebhookEndpoint |
Create a Local/Svix-mirrored endpoint and subscribe it to enabled event types. | BaseCommandResponse<Guid> |
PUT |
/api/webhooks/endpoints/{endpointId} |
UpdateWebhookEndpoint |
Update a tenant-scoped webhook endpoint URL, delivery controls, and event type subscriptions. | BaseCommandResponse<Guid> |
DELETE |
/api/webhooks/endpoints/{endpointId} |
DeleteWebhookEndpoint |
Archive a tenant-scoped webhook endpoint while preserving delivery history. | 204 No Content |
POST |
/api/webhooks/endpoints/{endpointId}/rotate-secret |
RotateWebhookEndpointSecret |
Rotate the endpoint signing secret reference while preserving a bounded previous-secret overlap window. | BaseCommandResponse<Guid> |
POST |
/api/webhooks/endpoints/{endpointId}/test |
TestWebhookEndpoint |
Schedule a signed LocalProvider test delivery for one active tenant-scoped endpoint. | BaseCommandResponse<Guid> |
GET |
/api/webhooks/messages |
GetWebhookMessages |
Tenant-scoped authoritative webhook messages and provider handoff state. | HAL collection of WebhookMessageDto |
GET |
/api/webhooks/messages/{messageId} |
GetWebhookMessageById |
One tenant-scoped webhook message without raw payload material. | HAL resource of WebhookMessageDto |
GET |
/api/webhooks/messages/{messageId}/payload |
GetWebhookMessagePayload |
Separately authorized exact payload bytes while the tenant-scoped retention window remains open. | WebhookMessagePayloadDto |
GET |
/api/webhooks/delivery-attempts |
GetWebhookDeliveryAttempts |
Tenant-scoped LocalProvider delivery attempts, optionally filtered by message or endpoint. | HAL collection of WebhookDeliveryAttemptDto |
GET |
/api/webhooks/delivery-attempts/{attemptId} |
GetWebhookDeliveryAttemptById |
One tenant-scoped delivery attempt with safe HTTP outcome metadata. | HAL resource of WebhookDeliveryAttemptDto |
POST |
/api/webhooks/delivery-attempts/{attemptId}/retry |
RetryWebhookDeliveryAttempt |
Schedule a manual retry from a failed or abandoned LocalProvider delivery attempt. | BaseCommandResponse<Guid> |
GET |
/api/webhooks/bulk-replays/preview |
PreviewWebhookBulkReplay |
Preview bounded eligible and excluded counts for an explicit UTC/consumer/endpoint/event filter. | WebhookBulkReplayPreviewDto |
GET |
/api/webhooks/bulk-replays |
GetWebhookBulkReplays |
List recent tenant-scoped durable replay operations. | HAL collection of WebhookBulkReplayOperationDto |
GET |
/api/webhooks/bulk-replays/{operationId} |
GetWebhookBulkReplayById |
Poll one durable replay operation and its normalized lifecycle evidence. | HAL resource of WebhookBulkReplayOperationDto |
POST |
/api/webhooks/bulk-replays |
ScheduleWebhookBulkReplay |
Queue an idempotent bounded Local replay operation after server-side eligibility re-evaluation. | 202 Accepted with BaseCommandResponse<Guid> |
POST |
/api/webhooks/bulk-replays/{operationId}/cancel |
CancelWebhookBulkReplay |
Cancel a still-queued operation using its observed concurrency version. | BaseCommandResponse<Guid> |
POST |
/api/webhooks/svix/app-portal |
OpenSvixAppPortal |
Generate short-lived backend-only Svix App Portal access. | WebhookProviderPortalAccessDto |
Contract rules:
GET /event-typesis anonymous and cacheable lookup data. It exposes registry-driven schema/example metadata and includes persisted event type IDs after startup catalog synchronization so management clients can create endpoint subscriptions.- Consumer DTOs expose normalized
consumerKindId/name,statusId/name, andproviderModeId/name; they never expose endpoint secrets. - Consumer create derives
tenantIdfromITenantContext, validates domain enum IDs in the Application handler, sets status toActive, and returns conflict ProblemDetails for duplicate tenant-local names. - Endpoint DTOs expose normalized status fields, provider endpoint ids, bounded timeout/retry/rate-limit settings, last success/failure timestamps, and enabled subscription event types. They never expose
secretRefor secret material. - Endpoint create derives
tenantIdfromITenantContext, requires an active tenant-local consumer, validates an absolute HTTP(S) URL, stores only the supplied secret reference, rejects duplicate tenant/consumer URLs, and fails closed when requested event type IDs are missing, duplicated, disabled, or unknown. - Endpoint update replaces URL, delivery controls, and the enabled event-type subscription set after validating all requested event types. It does not rotate signing secrets; secret rotation remains a separate route.
- Endpoint delete is a soft archive operation. Archived endpoints leave active lists and lose mutation HAL affordances while preserving authoritative delivery history.
- Endpoint secret rotation accepts
newSecretRefand optionalpreviousSecretValidForSecondsonly. It never accepts or returns raw signing secret material, rejects unchanged secret references, incrementssecretVersion, stores the old reference aspreviousSecretRef, and sets a boundedpreviousSecretValidUntiltransition window. Repeated calls without anIdempotency-Keycreate distinct rotations. - Endpoint test scheduling creates an authoritative
webhook.testmessage plus one LocalProvider delivery attempt for the target endpoint. It requires an active Local or Composite consumer endpoint; Svix-managed endpoint tests belong in the Svix App Portal because Svix owns provider-side endpoint delivery/replay semantics. - Message DTOs expose tenant, event type, event id, aggregate reference, consumer/provider state, payload hash, and retention timestamps. They intentionally do not expose
payloadJsonor raw sensitive event data. - Payload reads require the distinct
webhook:view-payloadaction. The dedicated response base64-encodes the normalized bytes and includes only content type/encoding, hash, byte length, retention cutoff, and retrieval time. The action writes a mandatoryPAYLOAD_VIEWEDaudit before returning data and fails closed if audit persistence fails. - Payload responses set
Cache-Control: no-store,no-cacheandPragma: no-cache. A missing or cross-tenant message returns the same generic404; a known tenant-local message whose bytes are expired or cleared returns410. HAL emitspayloadonly while the bytes are retained and the caller passes the separate permission check. - Delivery attempt DTOs expose endpoint/message references, attempt number, status, bounded HTTP status/failure/duration metadata, next retry time, and a safe response-body preview only. They do not expose endpoint secrets, request payloads, authorization headers, or full endpoint responses.
- Manual retry is attempt-based. Only failed or abandoned attempt detail resources may expose
retry, and the command delegates scheduling to the LocalProvider delivery drain service. - Bulk replay requires
webhook:bulk-replay. Preview and execution use the messageMaterializedAthalf-open interval[fromUtc,toUtc), with optional exact consumer, endpoint, and event-type filters. Only terminal Local targets (DEAD_LETTEREDorABANDONED) can become eligible. Active holds, expired/cleared payloads, inactive endpoints, nonterminal/succeeded Local work, and every provider publication are excluded and counted; provider conflict, unknown, and manual-reconciliation states have distinct exclusion counts and are never guessed or blindly republished. - Scheduling requires an operator reason and stable
operationKey. Reusing the key with identical authoritative filters returns the existing operation; changing any parameter returns409. Configured operation and per-tenant reserved-item ceilings are checked under a tenant advisory lock. The worker rechecks eligibility in its transaction and only changes eligible Local targets toRETRY_DUE; ordinary Local claim workers continue to enforce tenant/endpoint fairness, in-flight limits, rate limits, signing, and retry policy. Cancellation is available only inQUEUEDand requiresexpectedConcurrencyVersion; worker start and cancellation therefore resolve without an ABA race. - HAL collection resources may expose
create; active endpoint detail resources may exposeupdate,rotate-secret,test, anddelete; archived endpoint detail resources expose no mutation affordances. Message resources may exposedelivery-attempts,provider-publications, and the separately authorized retainedpayloadrelation; retryable attempt detail resources may exposeretry; Svix or Composite consumer detail resources may exposeopen-provider-portal. Clients must render webhook actions from_links, not client-side role checks. - The Svix App Portal route returns only short-lived URL/token data. The Svix API token is resolved server-side through the configured secret provider and is never sent to Blazor.
Managed reporting routing APIs expose tenant-owned provider configuration, readiness, and dashboards without returning provider secrets or report payloads.
| Verb | Route | Route Name | Purpose | Response |
|---|---|---|---|---|
GET |
/api/tenant/settings/moderation-reporting/routing-state |
GetModerationReportingRoutingState |
Current-tenant routing state, lock flags, provider target configured flags, and HAL affordances. | HAL resource of ReportingRoutingStateDto |
PATCH |
/api/tenant/settings/moderation-reporting/routing-state |
UpdateModerationReportingRoutingSettings |
Update supplied policy, Osprey, or Coop groups when their instance delegation locks allow it. Nested credential input is write-only; omitted groups and secret leaves preserve existing values. | BaseCommandResponse<Guid> |
POST |
/api/tenant/settings/moderation-reporting/routing-state/test/{provider} |
TestModerationReportingProvider |
Readiness-check a tenant Osprey or Coop target without external HTTP dispatch or secret output. | BaseCommandResponse<Guid> |
PATCH |
/api/instance/settings/moderation-reporting/locks |
UpdateInstanceModerationReportingProviderLocks |
Independently update general, Osprey, or Coop reporting-provider delegation lock groups. | BaseCommandResponse<Guid> |
GET |
/api/tenant/settings/moderation-reporting/dashboard |
GetTenantModerationReportingDashboard |
Current-tenant aggregate queue and provider-sync health. | HAL resource of TenantModerationReportingDashboardDto |
GET |
/api/admin/control-plane/operations |
GetControlPlaneOperations |
Instance control-plane operations status now includes aggregate moderation-reporting provider-sync and tenant lock-impact metrics. |
HAL resource of ControlPlaneOperationsDto |
Contract rules:
- Routing-state and dashboard reads are tenant-scoped and redacted. They may expose provider target identifiers, configured flags, aggregate counts, and HAL links, but never raw endpoint URLs, API keys, webhook secrets, provider payloads, correlation IDs, report evidence, or raw provider errors.
- Tenant routing PATCH bodies contain optional
policy,osprey, andcoopgroups. Omitted groups preserve persisted values. The general provider lock blocks every routing patch, while provider-specific locks block only a supplied matching provider group. - Tenant update commands accept endpoint URLs and secret values only as request input. Provider credentials are nested explicitly under the matching provider group. Response DTOs, HAL resources, generated response models, logs, metrics, traces, screenshots, and ProblemDetails must not echo those values.
- Provider test actions are readiness checks over effective routing state. They validate lock state, provider enablement, tenant target presence, and configured endpoint/API-key flags; they do not call external provider endpoints.
- HAL rels
routing-state,edit,test-osprey-provider, andtest-coop-providerare the client action source of truth. Clients must not recreate hidden actions from local roles or claims. - Reporter-owned communication consent uses
PATCH /api/event-reports/my/{reportId}/communication-consentwith one requiredconsentgroup containing both purpose-specific choices. Ownership, privacy-erasure fencing, audit-neutral unchanged requests, transactional persistence, and user-scoped cache invalidation remain enforced by the existing command handler.
Incoming webhooks are provider callbacks received by ISLAMU Event. They are separate from outgoing product webhooks and continue to work when the outgoing provider is Disabled, Local, Svix, Composite, or DryRun.
| Verb | Route | Route Name | Auth | Purpose |
|---|---|---|---|---|
POST |
/api/integrations/moderation/osprey/callback |
ModerationIntegrationOspreyCallback |
API-key policy ModerationIntegration.OspreyCallback |
Records bounded Osprey-compatible moderation signals on the local report without executing moderation actions. |
POST |
/api/integrations/moderation/coop/callback |
ModerationIntegrationCoopCallback |
API-key policy ModerationIntegration.CoopCallback plus signed raw-body HMAC verification |
Atomically retains the verified callback and its unique Coop decision-effect pointer. A fenced background worker dispatches the existing decision command and completes the pointer only after command success. |
POST |
/api/integrations/registration/{provider}/{bindingId}/callback |
RegistrationProviderCallback |
[AllowAnonymous] plus provider verifier as authentication |
Reads up to 256 KiB, verifies binding/proof through provider-neutral callback services, captures one registration.provider_submission incoming-webhook effect, and returns 202 Accepted for accepted, duplicate, malformed, unknown, stale, or parked evidence except oversize payloads (413). |
GET |
/api/admin/incoming-webhook-effects/status?tenantId={tenantId}&limit={limit} |
GetIncomingWebhookEffectStatus |
Authenticated plus Webhooks.ViewDelivery authorization |
Returns tenant-scoped safe effect lifecycle rows and HAL item affordances; limit range is 1..200. |
POST |
/api/admin/incoming-webhook-effects/tenants/{tenantId}/{effectOutboxId}/redrive |
RedriveIncomingWebhookEffect |
Authenticated plus Webhooks.RedriveIncoming authorization |
Redrives an eligible dead-lettered effect when expectedProcessingGeneration still matches and the retained callback remains replayable. |
POST |
/api/integrations/svix/operational |
IntegrationSvixOperationalCallback |
[AllowAnonymous] with Svix-compatible signature verification as authentication |
Accepts Svix operational callbacks without requiring the outgoing provider mode to be Svix. Tenant-addressed payloads are captured in the incoming webhook ledger; instance-level operational payloads are verified and acknowledged without side effects. |
Incoming callback rules:
- Raw request bodies are read before JSON parsing and verified against provider signatures where the provider supplies a signature.
- Signed callbacks enforce bounded body sizes, timestamp tolerance, and constant-time signature comparison.
- Verified tenant-scoped callbacks are stored in
incoming_webhook_messagesbefore any Application side effect. Coop decision callbacks create a specialized pointer in the intake transaction; command dispatch occurs later outside that transaction. - Duplicate provider message IDs are treated idempotently and do not re-run side effects.
- The incoming webhook ledger stores the tenant and provider message identifiers needed for idempotency, plus payload hashes, bounded status/failure metadata, and redacted headers only. Logs, metrics, and ProblemDetails use bounded provider/outcome/failure categories and must not include raw payloads, signature headers, secrets, tokens, authorization headers, tenant/user identifiers, provider message IDs, or raw provider errors.
- Coop effect status exposes only internal lifecycle identifiers/state, bounded failure category/detail, attempts, generation/fence, and timestamps. It excludes callback bytes, callback hash, signed provider decision ID, headers, and raw exceptions. HAL emits
redriveonly for a dead-lettered row and remains the client action authority.
Provider-neutral registration integration management is authenticated, event-scoped, private/no-store, and available only when the parent Event HAL exposes manage-registration-channels or view-registration-provider-health. It does not include a concrete Phase 10 provider adapter.
| Verb | Route | Route Name | Authority | Purpose |
|---|---|---|---|---|
GET/POST |
/api/tenants/{tenantId}/events/{eventId}/registration-providers/connections |
GetRegistrationProviderConnections / CreateRegistrationProviderConnection |
Tenant update for connection writes | List or create provider connections with secret-binding IDs only. |
GET/PUT/DELETE |
/api/tenants/{tenantId}/events/{eventId}/registration-providers/connections/{connectionId} |
GetRegistrationProviderConnection / UpdateRegistrationProviderConnection / DeleteRegistrationProviderConnection |
Tenant update | Read, update, or soft-delete a connection; delete fails while bindings reference it. |
PUT |
/api/tenants/{tenantId}/events/{eventId}/registration-providers/connections/{connectionId}/approved-origins |
ReplaceRegistrationProviderApprovedOrigins |
Tenant update | Replace the SSRF-safe browser/embed origin allowlist. |
GET/POST |
/api/tenants/{tenantId}/events/{eventId}/registration-providers/bindings |
GetRegistrationProviderBindings / CreateRegistrationProviderBinding |
manage-registration-channels |
List or create draft bindings for exact form/version and mode/trust lookup IDs. |
GET/PUT/DELETE |
/api/tenants/{tenantId}/events/{eventId}/registration-providers/bindings/{bindingId} |
GetRegistrationProviderBinding / UpdateRegistrationProviderBinding / DeleteRegistrationProviderBinding |
manage-registration-channels |
Manage draft bindings; published mappings are immutable. |
PUT |
/api/tenants/{tenantId}/events/{eventId}/registration-providers/bindings/{bindingId}/mappings |
ReplaceRegistrationProviderMappings |
manage-registration-channels |
Replace draft field/option mappings. |
POST |
/api/tenants/{tenantId}/events/{eventId}/registration-providers/bindings/{bindingId}/publish |
PublishRegistrationProviderBinding |
manage-registration-channels |
Publish when drift is nonblocking. |
GET/POST/PUT/DELETE |
/api/tenants/{tenantId}/events/{eventId}/registration-providers/workflows/{workflowId}/requirements/{requirementId}/channels[/{channelId}] |
GetRegistrationChannels / CreateRegistrationChannel / UpdateRegistrationChannel / DeleteRegistrationChannel |
manage-registration-channels |
Manage native/provider channel order and fallback. |
GET |
/api/tenants/{tenantId}/events/{eventId}/registration-providers/workflows/{workflowId}/requirements/{requirementId}/channels/{channelId}/bindings/{bindingId}/launch-descriptor |
GetRegistrationProviderLaunchDescriptor |
manage-registration-channels |
Return one server-generated redirect/embed/manual descriptor from approved connection origins. |
GET |
/api/tenants/{tenantId}/events/{eventId}/registration-providers/health |
GetRegistrationProviderHealth |
view-registration-provider-health |
Return bounded connection validity, callback age, drift, reconciliation lag, queue depth, and capability codes. |
GET/POST/POST/POST |
/api/tenants/{tenantId}/events/{eventId}/registration-providers/queue, /manual-imports, /queue/retry, /queue/resolve |
queue/manual/retry/resolve routes | manage-registration-channels |
List parked issue codes and manage bounded reconciliation without answers or raw provider payloads. |
POST |
/api/tenants/{tenantId}/events/{eventId}/registration-providers/{bindingId}/reconcile |
PollRegistrationProviderReconciliation |
manage-registration-channels |
Request bounded provider reconciliation when the exact tuple has RECONCILIATION. |
Returned HAL relations include manage-registration-channels, view-registration-provider-health, provider-create, origins, mappings, publish, manual-import, poll, retry, resolve, and launch-descriptor. Clients must use those links as authority and must not infer access from roles, provider names, drift state, or capability booleans.
Reporter communication contracts use two required, independently selected booleans: ReportCaseUpdatesConsent covers acknowledgements, status updates, and final outcomes, while ReportFollowUpContactConsent covers requests for clarification or additional evidence. POST /api/event-reports, reporter-owned reads, and moderation reads expose both values; anonymous submissions force both to false. The pre-1.0 ReporterContactConsent field was removed without a compatibility alias, so clients must regenerate from OpenAPI. PUT /api/event-reports/my/{reportId}/communication-consent updates both purposes for the authenticated reporter's own report and returns the refreshed HAL resource. My Reports detail and collection items expose update-communication-consent only after the current-user User/Update authorization-provider check succeeds, and the write handler repeats that exact provider decision before opening its transaction. Tenant and ReporterUserId ownership checks remain defense in depth; missing identity, provider denial, non-owner, tenant mismatch, and indeterminate authorization fail closed. Clients must render withdrawal controls only when that relation exists.
The HATEOAS system uses a layered architecture to ensure "Plug-and-Play" compatibility for all consumers:
ResourceAssemblerBase<TDto, TListDto>— Base class for assembling HAL responses. Implements the high-performance 4-Phase Capability Planning Pipeline. Most families need no assembly behavior of their own and use the concrete genericHalResourceAssembler<TDto, TListDto>, registered byAddHalResource<...>; declare a subclass only when a family genuinely assembles differently.ILinkPolicy<TDto>/ICollectionLinkPolicy<TDto>— Per-entity link definitions using theyield returnpattern.LinkDefinition— Metadata for a link, including relation, route, and authorization requirements.HateoasAuthorizationEvaluator— The engine that batches and deduplicated permission checks.HateoasLinkGenerator— Resolves named routes to absolute URLs.RouteNames— 100+ named route constants ensuring type-safe link generation.
To prevent
- Candidate Selection: Link policies yield all possible link definitions for the resource(s).
- Normalization: The evaluator extracts
AuthorizationCheckobjects from permission-bearing links. - Batch Decisioning: Deduplicated checks are sent to the
IAuthorizationProviderin a single batch call. - Materialization: Authorized links are resolved to URLs and embedded into the
_linksobject.
Collection endpoints use BuildListResourcesWithBatch to ensure scalability:
- All link definitions for all items in a paginated result are collected first.
- These are flattened into one massive batch (potentially hundreds of checks).
- The evaluator deduplicates identical checks (e.g., if multiple items share the same parent).
- One single gRPC call (Cerbos) or one single profile resolution (Local) authorizes the entire list.
- Default format:
application/hal+jsonwith_linksand_embeddedsections. Prefer: return=minimal(RFC 7240) strips all_linksto save bandwidth for non-UI consumers.PreferHeaderMiddlewarereads thePreferheader and sets a flag consumed by assemblers.
Collection responses include standard pagination links: self, first, prev, next, last.
Link policies use ResourceDescriptors to extract resource metadata from DTOs, ensuring authorization is context-aware.
If the batch authorization call fails (e.g., network error to Cerbos), all permission-bound links are denied by default. Non-permission links (e.g., self) remain unaffected.
Ordinary fixed Event and EventSession lifecycle links are emitted only when the matching Domain lifecycle predicate accepts the DTO's current facts. Event actions use the current EventStatusId; restoration additionally requires the server-projected reversible-moderation-record eligibility. Session detail and collection policies use EventSessionStatusId, ParentEventStatusId, StartTime, EndTime, and EndTimeType, so publish and complete require a Published parent Event and publish requires a valid schedule. A Published Event never advertises archive. Heavy redaction remains a distinct Application/API-owned irreversible safety override rather than an ordinary Domain transition affordance.
HAL omits same-target ordinary fixed lifecycle actions because they do not change resource state, although sending such a command directly is a successful idempotent no-op. A true no-op may emit one structured idempotent outcome log after the unit of work completes for observability, but it does not change state or timestamps and produces no durable write, cache invalidation, or metric. Light moderation is the exception: applying it to an already-Moderated Event may repair missing child-session moderation, producing only the effects required by that repair and no duplicate moderation record, outbox, or federation work. Every advertised lifecycle mutation remains authenticated and permission-bound. Clients must follow _links rather than reconstructing these Domain decisions from status fields, parent status, schedule fields, roles, or claims.
The API's _links payload is the single source of truth for action affordances in the Blazor UI. The server already evaluated every authorization check and only emitted the links the caller is allowed to follow — the client must trust that contract and render UI affordances directly from it.
Blazor components gate mutation buttons (Edit, Delete, Create, etc.) with extension helpers defined in Explore.Blazor.Client/Helpers/HalResourceExtensions.cs:
private void CheckEditPermissions()
{
canEdit = organization?.HasHalLink("edit") ?? false;
}
// In markup:
@if (canEdit) { <AppButton StartIcon="@Icons.Material.Filled.Edit">Edit</AppButton> }The helpers (HasHalLink(this OrganizationDto, string), HasHalLink(this EventListDto, string), etc.) read _links from the generated DTO extension data or from typed client models that explicitly preserve HAL links. Collection-level affordances such as create must come from the HAL collection _links carried through PaginatedResult<T>.Links; they must not be inferred from the first row or from an empty-list fallback.
Never gate mutation UI through client-side role checks (RoleHelper.CanManage, user.IsInRole("OrgAdmin"), ClaimsPrincipal inspection). These duplicate server-side policy, drift over time, and leak authorization logic into the client. If the server didn't emit an edit link, the user is not allowed to edit — period.
Role/claim inspection is acceptable only outside action-gating contexts:
- Navigation menu filtering (
NavMenu.razor— determines which top-level sections are visible). - Eligibility previews for empty-state CTAs (
EventCreationEligibilityService— does the user belong to any org that could create an event?). - Client-side route guards that short-circuit before an API call (e.g. redirecting anonymous users away from
/my/*).
All three cases guard entire pages or menus, not per-resource actions, and none substitute for the authorization decisions the API already encoded in _links.
Every client DTO consumed for affordance gating must:
- Preserve item
_linkseither through[JsonExtensionData] IDictionary<string, object>? AdditionalPropertiesor an explicit[JsonPropertyName("_links")]Linksproperty when mapping to a UI model. - Preserve collection
_linkson paginated wrappers when a page-level action such ascreateis rendered independently of rows. - Have a matching
HasHalLink(this TDto, string linkRel)extension or wrapper method. - Never have a corresponding standalone permission flag (e.g.
CanEdit: bool) on the DTO — permission state must flow exclusively through_links.
Notification preference matrices follow this contract: NotificationPreferenceMatrixDto is served as a HAL resource, and Blazor gates save and set-mute exclusively from _links.
HAL link consumption is protected by three test layers:
Event.API.IntegrationTests/Features/Hateoas/HateoasLinkDeserializationTests.cs— wire-level regression guard that_linkssurvive NSwag round-trip.Event.API.IntegrationTests/Features/Hateoas/OrganizationHateoasAuthTests.cs— verifies authenticated vs. anonymous requests receive different link sets on embedded items.Explore.Blazor.Client.Tests/Pages/Organizations/OrganizationDetailsHateoasTests.cs— bUnit component test confirms Edit button renders iff_links.editis present and the page never callsIOrganizationMemberService.GetMembersAsyncon load.Explore.Blazor.Client.Tests/Helpers/EventTemplateHalResourceExtensionsTests.csandExplore.Blazor.Client.Tests/Pages/Admin/EventTemplateListPageTests.cs— prove event-template collectioncreatelinks survive empty collections and that page-level create is not inferred from row links.
The application uses a custom Specification Pattern for complex filtering, especially on the Event entity.
IQuerySpecification<T>— ComposesIFilterSpecification<T>+ISortSpecification<T>. Immutable builder pattern.IFilterSpecification<T>— Individual filter producingExpression<Func<T, bool>>.ISortSpecification<T>— Sort directives with field name and direction.
EventQuerySpecification is an immutable fluent builder that composes filters via AND logic:
spec = spec
.And(new EventFilter(...))
.And(new EventSubqueryFilter(...))
.And(new IslamicAspectFilter(...))
.And(new EventCustomPropertyProjectionFilter(...))
.SortByDescending(EventSort.StartUtc);
| Filter Class | What It Handles | Mechanism |
|---|---|---|
EventFilter |
Core fields (search, date, status, type, format) | Direct Expression<Func<Event, bool>> |
EventSubqueryFilter |
Junction tables (categories, tags, locations, languages, registration modes) + JSONB metadata | Subquery with Any() / All() |
IslamicAspectFilter |
Islamic module fields (madhab, gender mode) | Module-conditional — silently ignored when module disabled |
TechAspectFilter |
Tech module fields (skill level, stack) | Module-conditional — silently ignored when module disabled |
AspectPresenceFilter |
HasIslamicAspect / HasTechAspect flags | Navigation property null check |
EventCustomPropertyProjectionFilter |
Projection-backed custom property discovery/filtering | Projection query composed alongside typed filters |
Tags and categories support tri-state AND/OR filtering:
- Include AND: all specified tags must be present.
- Include OR: any specified tag matches.
- Exclude AND: exclude only if ALL specified tags present.
- Exclude OR: exclude if ANY specified tag present.
Implemented as separate EventSubqueryFilterType enum values.
Event metadata stored as JSONB supports two filter types:
JsonContains— PostgreSQL@>operator for value matching.JsonKeyExists— PostgreSQL?operator for key existence check.
EventQuerySpecification.ToCacheKeySuffix() deterministically serializes all active filters and sorts into a cache key suffix for HybridCache integration.
Standard pagination via PaginatedResult<T>:
| Parameter | Default | Max | Description |
|---|---|---|---|
pageNumber |
1 | — | Current page (1-based) |
pageSize |
20 | 100 | Items per page |
PaginatedResult.NormalizeParameters() clamps values to valid ranges. Response includes TotalCount, PageNumber, PageSize, TotalPages, HasPrevious, HasNext.
- Create/update flows return
BaseCommandResponse<Guid>withSuccess,Message,Errors,Id. - Many delete flows return
booland map to204 NoContentor404 NotFound. - Explicit purge flows return
BaseCommandResponse<CustomPropertyPurgeResultDto>and are admin-only operations that hard-delete only dependency-free custom-property definitions. - Query flows return DTOs or
PaginatedResult<TDto>wrappers. - All responses wrapped in HAL format by default.
BaseCommandResponse<T>is a success body only. A failed command never serializes as the command response; it becomes RFC 7807 ProblemDetails. Do not declare[ProducesResponseType(typeof(BaseCommandResponse<T>), …)]for a 4xx or 5xx status.
Not every failure is an exception. A command handler that returns Success = false with a FailureCode
produces a ProblemDetails response too, through one of two API-layer authorities:
| Authority | Use |
|---|---|
CommandFailurePolicy |
A capability with its own failure vocabulary. Declare the table once as a static readonly field — ValidatedBy(validation).NotFound(descriptor, codes…).Conflict(title, detail, codes…) — then call Policy.Map(this, response). Rules match in declaration order; policies are immutable and compose, so a variant is the base policy plus one rule. |
MapCommandResponse |
A capability using the shared FailureCodes vocabulary: not_found → 404, admin_required → 403, authentication_required → 401, concurrency_conflict → 409, anything else → 400 ValidationProblemDetails. |
An unmatched failure code falls through to a validation problem rather than collapsing into an untyped 400,
so distinct failures stay distinguishable to clients. Writing a private switch over FailureCode in a
controller is forbidden — that is how two endpoints in the same product came to fail in two different formats.
Exception handling uses .NET 8+ IExceptionHandler chain (not middleware):
ValidationExceptionHandler— CatchesFluentValidation.ValidationExceptionandApplication.Exceptions.ValidationException. Returns400 Bad Requestwith structured errors dictionary.GlobalExceptionHandler— Catches everything else:BadRequestException→400NotFoundException→404AuthorizationException→403QuotaExceededException→422with type/problems/quota_exceededConcurrencyConflictException→409with type/problems/concurrent_updateor/problems/stale_sync_base- Unhandled →
500(detail hidden in production)
All responses use RFC 7807 ProblemDetails with extensions:
traceId— fromHttpContext.TraceIdentifiertimestamp— UTC ISO 8601correlationId— fromX-Correlation-ID/X-Request-IDheader or generated UUID
Validation payloads normalize serializer/model-binding paths before returning
them to callers. JSON-path style keys such as $, $._links, or other
serializer internals are reported as body with a generic invalid-body message
so API responses do not leak parser implementation details or unsupported-field
paths. Unsupported media type responses use 415 ProblemDetails with a stable
title and detail.
Custom-property quota failures use stable extensions code, quotaKey, limit,
scope, and optional actual/attempted. The generic API mapper intentionally
does not emit tenantId; tenant identifiers are only safe on explicitly
authorized/admin surfaces.
Template-sync conflicts keep business stale-base conflicts distinct from technical optimistic concurrency:
| Code | HTTP | Problem type | Meaning |
|---|---|---|---|
concurrent_update |
409 | /problems/concurrent_update |
A mutable row changed since the client loaded it. Reload and retry. |
stale_sync_base |
409 | /problems/stale_sync_base |
The template sync base version changed. Recompute the diff before applying. |
The type field uses IANA RFC 9110 standard URIs (e.g., https://tools.ietf.org/html/rfc9110#section-15.5.5 for 404) instead of httpstatuses.com.
Current implementation detail: ExceptionHandlingExtensions writes traceId from HttpContext.TraceIdentifier.
.NET 10 note: handled exceptions can suppress diagnostics by default once an IExceptionHandler returns true. UseApiExceptionHandling() currently calls plain app.UseExceptionHandler() with no SuppressDiagnosticsCallback override, so treat handled-exception logging/metrics behavior as an explicit runtime decision.
Five policies configured in Program.cs:
| Policy | Origins | Methods | Credentials | Use Case |
|---|---|---|---|---|
InternalAppPolicy |
Configurable | All | Yes | Internal app communication |
ExternalAppPolicy |
Configurable | Specific set | No | External API consumers |
InternalWebsitePolicy |
Configurable (loaded from CorsSettings:AllowedOrigins) |
All | Yes | Internal website |
ExternalWebsitePolicy |
Configurable | GET, OPTIONS only |
No | External read-only |
DevPolicy |
All origins | All | Yes | Development only |
Returns 404 Not Found in single-tenant mode with hiding enabled. Conceals multi-tenant endpoints from discovery.
Returns 403 Forbidden with error payload when endpoint requires multi-tenant mode.
Gates onboarding endpoints behind the setup secret:
- If setup mode is inactive: returns RFC 7807
410 Gonewith codesetup_already_completed. - If
X-Setup-Secretheader is missing/invalid: returns RFC 7807403 Forbiddenwith codeforbidden. - Setup-secret-gated onboarding endpoints use the named
SetupSecretrate-limit policy and advertise429 Too Many RequestsasProblemDetailsin OpenAPI. - Uses
TypeFilterAttributepattern for DI-aware filtering withISetupSecretProvider.
| Decorator | Purpose |
|---|---|
PerformanceCommandHandlerDecorator / PerformanceQueryHandlerDecorator |
Logs requests taking >500ms as warnings |
AuthorizationCommandHandlerDecorator / AuthorizationQueryHandlerDecorator |
Checks IAuthorizedRequest / [AuthorizeResource] attribute; throws AuthorizationException on deny. Reflection results cached via ConcurrentDictionary. Emits OpenTelemetry activity spans on Explore.Authorization source with resource.kind, resource.action, and request.type tags. |
| Endpoint | Contract |
|---|---|
GET /api/event |
Anonymous HAL collection of EventDiscoveryItemDto. Each item is either the existing local EventListDto projection or a bounded FederatedEventDto; the federated projection does not return raw provider payloads, credentials, DIDs, record keys, or external source URLs. |
GET /api/event/federated/{atprotoRecordId}/source |
Anonymous, globally rate-limited 302 to the current tenant-visible normalized HTTPS source. Disabled capability, missing/tombstoned/cross-tenant records, and unsafe targets all return 404. |
GET /api/settings/instance/atproto-federation; keyed /api/settings/instance/atproto-federation/{key} and /api/settings/instance/atproto-federation/{key}/lock mutations |
Instance-admin HAL surface for the exact capability and validation-profile keys. Update and lock affordances are server-produced. |
GET /api/actor, GET /api/actor/{id}, GET /api/actor/by-did/{did} |
Anonymous authoritative global Actor reads. Responses omit tenant participation IDs, private User ownership, and tenant storage-object identifiers. |
GET /api/actor/by-tenant/{tenantId} |
Anonymous tenant-contextual Actor collection containing only locally discoverable Actors, with safe approved participation overrides and tenant-local subscription affordances. |
GET /api/actor/by-tenant/{tenantId}/{id} |
Anonymous exact tenant-contextual Actor detail. Hidden or cross-tenant targets return 404; safe approved participation overrides and tenant-local HAL affordances are composed server-side. |
POST /api/actor/{actorId}/moderation/suspend |
Suspend the global Actor. The body contains only reasonCode; the route selects Suspend. |
POST /api/actor/{actorId}/moderation/reinstate |
Reinstate the global Actor. The body contains only reasonCode; the route selects Reinstate. |
POST /api/actor/atproto-identities/{identityId}/moderation/suspend |
Suspend one exact global ATProto identity credential. The body contains only reasonCode; the route selects Suspend. |
POST /api/actor/atproto-identities/{identityId}/moderation/reinstate |
Reinstate one exact global ATProto identity credential without changing IsActive. The body contains only reasonCode; the route selects Reinstate. |
GET /api/organizations/{organizationId}/legitimacy-evidence |
Authenticated HAL collection of safe retained evidence metadata for the current tenant participation. |
POST /api/organizations/{organizationId}/legitimacy-evidence/upload-session |
Organization-admin creation of a server-bound, private PDF Document upload session owned by the pending participation. |
POST /api/organizations/{organizationId}/legitimacy-evidence |
Attach the finalized eligible Document as retained participation evidence. |
POST /api/organizations/{organizationId}/legitimacy-evidence/{evidenceId}/review |
Tenant-admin approve/reject decision for one pending evidence row; this does not approve the participation. |
federation.atproto_events_enabled is the single capability for tenant presentation of inbound community events and eligible outbound event/RSVP enqueue. federation.atproto_event_validation_profile=community_lexicon relaxes only the required local business fields for publication; it does not relax supplied-value validation, authorization, privacy, projection completeness, or record validation. Outbound publication additionally requires the owner's self-scoped federation.atproto_publish_my_events consent and one exact linked encrypted ATProto session.
The four moderation POST routes are authenticated instance operations. Their commands authorize update of the instance setting resource global-actor-moderation, then handlers recheck instance-admin authority before target lookup. A tenant administrator cannot mutate either global state. Same-state retries return success without another aggregate update or moderation record. Every accepted request invalidates HybridCache Event tags and output-cache discovery, detail, home, and sitemap tags.
Event publication is database-first: the committed local lifecycle mutation and immutable PdsSyncOutbox intent share one transaction, and CarpaNet PDS I/O occurs later under a fenced worker claim. Every eligible public event/session/aspect/resolved-lookup/EAV value must be mapped natively or rendered into the one community event description; coverage, privacy, JSON/DAG-CBOR size, or validation failure prevents enqueue, with no truncation or silent omission. RSVP egress represents only a committed active registration as community.lexicon.calendar.rsvp#going, ignores organizer approval state, and remains blocked until the event's settled URI/CID can form the exact strongRef.
Ingress uses one globally leased Jetstream consumer for exactly community.lexicon.calendar.event and community.lexicon.calendar.rsvp. Authoritative DID/collection/record-key state, current source version, typed event projection, tenant presentation, quarantine/tombstone effects, and cursor advancement are persisted atomically. Public clients must treat HAL links as action authority: federated items have no write affordances, and source exists only when the server can safely resolve the internal redirect route.
Public Event reads require a published, public, non-deleted Event and active Actor. Local User Events additionally require an active TenantUser; local Organization and Group Events require approved, visible, unsuspended participation, without rechecking organizer eligibility. Inbound federated Events instead require the current visible tenant presentation, non-tombstoned record, and exact active DID identity owned by the Actor. Anonymous child reads inherit the same parent gate. Authorized management detail remains available through view-management when public eligibility fails, and HAL omits public affordances from that management representation.
Inbound projection discovery keeps public Draft, Cancelled, and Completed projections, deduplicates Published projections to the local Event branch, and hides Moderated, Archived, deleted, non-public, tombstoned, stale-presentation, or identity-ineligible projections. The exact source redirect applies the same base gate. Outbound planning skips an ungrounded ineligible Create, converts a grounded ineligible Update to a fenced Delete, and rechecks identity, session, source version, ownership, record key, and CID fences at delivery. RSVP behavior is unchanged.
The removed raw /api/atprotorecord, /api/indexeddid, /api/userexternallogin, /api/actorkeystore, and /api/syncstate surfaces have no compatibility aliases. Clients cannot assert provider, DID, PDS, key, tenant, user, encrypted signing material, or ingestion cursor state through generic CRUD. Authenticated session-metadata reads and idempotent local session deletion remain credential-free; verified authentication, fenced Jetstream ingestion, and other dedicated federation internals are the only authorities over linked identity and provider-owned state. The checked-in OpenAPI contract and API Contract Inventory are the route/schema authority.
Generic Actor POST, PATCH, and DELETE routes are also absent. Actor creation, promotion, identity linking, and retirement are dedicated verified onboarding, federation, moderation, or consolidation workflows rather than browser-authored identity CRUD.
Every event has a typed EventParticipationConfiguration read projection with normalized handling-mode, advance-registration-obligation, and optional identity-access lookup facts plus guest-recovery policy and its own concurrency stamp. The former isRegistrationRequired and externalRegistrationUrl fields do not exist.
Organizers update this isolated resource through authenticated PATCH /api/events/{eventId}/participation with the configuration concurrency stamp as one required strong quoted GUID entity tag in If-Match. Authorization uses the organizer/assignment-only manage-registrations event action; listing contributors, tenant administrators, and instance administrators receive no automatic authority. Clients discover the capability through configure-participation rather than inspecting roles or claims.
Public participation is HAL-authored and fail-closed for published public events only. INFORMATION_ONLY and WALK_IN emit no participation CTA. EXTERNAL_MANAGED emits at most one external-registration relation selected from reviewed active stored public actions and routed through the stored-ID redirect. PLATFORM_MANAGED emits permission-bound start-registration for authenticated callers and sign-in-to-register for anonymous callers; both target the existing protected native registration operation. Clients must not render a CTA from mode fields or raw URLs. External labels distinguish an unverified source (View original event page) from organizer authority (Register on organizer website).
The anonymous redirect records only the bounded explore.event_public_actions.engagements metric dimensions action_kind, surface, and outcome=redirect_issued. It creates no engagement row, captures no identity or event/action identifier in labels, and never claims that a registration completed.
Generic event-registration reads are authenticated self-service contracts. /api/eventregistration, /api/eventregistration/by-session/{eventSessionId}, /api/eventregistration/by-user/{userId}, and /api/eventregistration/{id} return only registrations owned by the authenticated user; cross-user route IDs fail with 403 Forbidden. EventRegistrationDto and EventRegistrationListDto intentionally omit serialized user identity fields (userId, userFullName, userEmail). Organizer attendee-management views require a separate resource-authorized projection instead of these generic routes.
Meter name: Explore.Business. Tags vary by counter and include dimensions such as tenant_id, event_type, resource, action, result, and owner_type where those dimensions apply.
| Counter | Description |
|---|---|
explore.events.created |
Events created |
explore.events.published |
Events published |
explore.registrations.created |
Event registrations |
explore.event_public_actions.engagements |
Stored public-action redirects issued, using only bounded action_kind, surface, and outcome tags |
explore.organizations.created |
Organizations created |
explore.authorization.decisions |
Authorization check outcomes |
explore.external_api_keys.created |
External API keys created |
explore.external_api_keys.revoked |
External API keys revoked |
explore.external_api_keys.authentication_attempts |
External API-key authentication attempts with bounded outcome/tenant_id/owner_type tags only |
explore.external_api_keys.throttled |
External API-key throttling events |
explore.external_api_keys.policy_updated |
External API-key policy updates |
explore.external_api_keys.rotated |
External API-key rotations |
explore.idempotency.cleanup_runs |
Expired idempotency cleanup attempts by bounded mode and outcome tags |
explore.idempotency.cleanup_rows |
Expired idempotency cleanup eligible/deleted row counts by bounded mode and outcome tags |
explore.notifications.fanout_runs |
Notification fanout run outcomes by bounded tenant_id, fanout_kind, and outcome tags |
explore.notifications.fanout_subscribers |
Aggregate notification fanout subscriber decisions by bounded tenant_id, fanout_kind, and outcome tags |
explore.event_reports.submissions |
Event-report intake outcomes by bounded tenant_id, outcome, and failure_category tags |
explore.event_reports.workflow_actions |
Moderation report triage/assign/decide/execute outcomes by bounded tenant_id, action, outcome, and failure_category tags |
explore.event_reports.provider_syncs |
Osprey/Coop provider sync outcomes by bounded tenant_id, provider, outcome, and failure_category tags |
explore.event_reports.provider_callbacks |
Moderation provider callback outcomes by bounded tenant_id, provider, outcome, and failure_category tags |
event_role_assignment.changed |
Event role assignment changes |
Authorization decisions are also traced via ActivitySource named Explore.Authorization with resource.kind, resource.action, and request.type tags.
- Polls
outbox_messagestable for pending events at configurable interval (default 5s). - Processes in batches (default 100) with optimistic locking (
TryMarkAsProcessing). - Dispatches via
IOutboxMessageDispatcher; current routing is handled byCompositeOutboxMessageDispatcher, which sendsEventPublishedNotificationFanoutRequestedto internal notification fanout and fails closed for retired external broker event types. - Exponential backoff retry:
InitialRetryDelaySeconds × 2^retryCount, capped atMaxRetryDelaySeconds. - Dead-letters messages after
MaxRetryCountexhausted. - Configuration section:
OutboxProcessor(Enabled, PollingIntervalSeconds, BatchSize, MaxRetryCount, InitialRetryDelaySeconds, MaxRetryDelaySeconds, VerboseLogging).
GET /api/notification/streamis an authenticatedtext/event-streamendpoint for one-way notification refresh hints.- The stream emits
notification-refreshevents with minimal unread-count state only; notification bodies, entity IDs, user IDs, deduplication keys, and PII are not sent through SSE. - The endpoint disables request timeout, sends no-store/no-cache headers, and sets
X-Accel-Buffering: no. Do not addtext/event-streamto response compression or proxy buffering rules. - Existing notification list/detail/unread APIs remain the source of truth. Blazor keeps polling as fallback if the SSE stream disconnects or is unavailable.
- AI assistant run progress uses authenticated polling as the supported transport.
POST /api/ai/assistant/conversations/{conversationId}/messagesreturns202 Acceptedwith aLocationroute toGET /api/ai/assistant/conversations/{conversationId}/runs/{runId}. - Send-message requests accept
mode: "ask" | "build".askis text-only and disables action schemas/tool proposals;buildpermits proposal-only actions such as event-draft creation, still requiring HAL affordance checks and explicit user confirmation before any write side effect runs. - The run-status response is a HAL resource. It includes
selfand conversationuplinks, pluscancel-runonly while the run is queued or in progress. - Streaming is intentionally not part of the current AI assistant contract.
ai_assistant.streaming_enabledremains disabled until a separate hardening slice covers transport buffering, cancellation, timeout behavior, authentication, logs, and non-streaming fallback. - Polling responses must remain safe metadata only: status, provider label, model label, timestamps, bounded failure code/message, and HAL links. Do not return prompt content, provider response bodies, tool payloads, provider request IDs tied to content, endpoint URLs, API keys, or raw provider exceptions.
- Polls
PdsSyncOutboxfor committed, due AT Protocol event/RSVP delivery intents and reconciles missing active RSVP intents in bounded pages. - Claims rows with owner, token, monotonic fence, and expiring lease so crashed workers are reclaimable and stale workers cannot settle or fail a successor claim.
- Rechecks effective capability, the owner's current self-consent, exact linked DID/session, source version, public-location privacy, and immutable payload immediately before CarpaNet PDS I/O.
- Retries the same stable record key with bounded exponential backoff; permanent or exhausted failures are dead-lettered with a stable failure code, never a provider response body.
- Settles URI/CID, primary record ownership/presentation, outbox completion, and the local Event's
AtprotoRecordIdtransactionally. RSVP claims remain blocked until the event URI/CID strong reference exists.
- Grace period: 25 seconds on
SIGTERM. - Health checks return
503during shutdown for load balancer draining. - Uses cooperative cancellation via
app.Lifetime.StopApplication().Console.CancelKeyPresssetsisShuttingDownflag and triggers graceful stop. Kestrel.KeepAliveTimeout: 30 seconds.Host.ShutdownTimeout: 30 seconds.
Write operations support the Idempotency-Key HTTP header for safe retries:
- Client sends
Idempotency-Key: <UUID>on POST/PUT/PATCH/DELETE requests. - Every endpoint marked
RequireIdempotencyKeypublishes a required, non-nullable OpenAPI header parameter; generated clients therefore require the argument rather than relying on an implicit hook. - Server atomically persists an in-progress claim by
(Key, TenantId)in PostgreSQL before dispatching the write. - Duplicate requests within 24 hours replay the cached response with original status code when the original response was persisted.
- Ordinary fixed Event and EventSession lifecycle commands also treat a request for the current target status as a successful no-op: no lifecycle state, audit timestamp, or downstream side effect is changed. HAL does not advertise these same-target actions. Already-Moderated light moderation may instead repair missing child-session moderation; only actual repair effects run, without duplicating the moderation record, outbox, or federation work.
- Reusing the same key for a different write request is rejected with
409 Conflict. The request identity includes method, normalized target, content type, request-body hash, and a principal fingerprint. - Capability-scoped payment writes hash the capability into that fingerprint. A matching completed key re-executes the current access guard instead of blindly replaying, so capability revocation or order expiry returns the same uniform
404; durable payment claim/retry state keeps the repeated valid operation idempotent. - A matching request while the original claim is in progress receives
409 Conflictwith codeidempotency_request_in_progress; it must retry for the completed replay. - Required claim or result persistence failures return
503 Service Unavailablewith codeidempotency_unavailable, never a successful write response. - Persisted responses must have status
200through499, body size at or below 1 MB, and blank,application/json, orapplication/problem+jsoncontent type. 5xx, large, or non-JSON responses are not persisted for replay.- Keys expire after 24 hours for replay eligibility. Expired rows are ignored by reads; the
idempotency-cleanupQuartz job physically deletes expired rows after the configuredIdempotencyCleanup:ExpirationGraceHourssafety buffer. - Entity:
IdempotencyRecordwithKey,TenantId, request fingerprint fields,StatusCode,ResponseBody,CreatedAt, andExpiresAt.
The seven /api/instance/keycloak operation routes are explicitly excluded from
this mechanism. In particular, inspection and apply can carry ephemeral
administrator credentials, and receipt state rather than a replayed HTTP response
is the only supported repeat-operation record.
- Algorithms: Brotli + Gzip at
CompressionLevel.Fastest. - Enabled for HTTPS.
- Additional MIME types:
application/json,application/hal+json.
- Tenant context is resolved per request.
- Resolution behavior:
SingleTenant: default tenant is bound immediately.MultiTenant:ApiTenantResolutionMiddlewareresolves trustedX-Tenant-Slugfirst, then normalizedRequest.Host.Hostafter forwarded-header processing; unresolved non-API-key requests fail closed with404.- API-key requests may carry a requested tenant hint through pre-auth middleware and are finalized by
ApiTenantPostAuthenticationMiddleware, which can return404 Tenant mismatch,404 tenant_required, or401 API key authentication failed.
- EF query filters enforce tenant scoping in persistence.
- Hierarchical Settings: Governance settings follow a 5-tier resolution cascade: User → Group → Organization → Tenant → Instance. Resolution is performed in batch via
HierarchicalSettingsResolverwith support for instance-level locks and single-tenant bypass.
- Core events:
GET /api/event— list with full specification pattern filteringGET /api/event/{id}— detail with HATEOAS linksGET /api/event/public/{slugCode}/og-image— anonymous same-origin 1200x630 PNG for eligible published/public events; rechecks eligibility on every request, returns a strong quoted ETag, varies byHostandX-Tenant-Slug, and returns304 Not Modifiedfor a matchingIf-None-Matchwithout shared output cachingGET /api/event/{id}/management-detail— authenticated management detail, including draft/internal/moderated events visible to the principal through eventview-managementGET /api/event/{id}/moderation/history— authenticated safe moderation audit history for authorized management viewsGET /api/event/management/by-actor/{actorId}— authenticated actor-owned management list, including hidden rows authorized by per-eventview-managementGET /api/event/{id}/publish-readiness— authenticated/resource-authorized publish readiness diagnosticsPOST /api/event— createPOST /api/event/import— authenticated import/backfill create with provenancePOST /api/event/with-sessions— create with sessions in one requestPOST /api/event/{id}/publish— publish after readiness and concurrency validationPOST /api/event/{id}/archive— archive after concurrency validationPOST /api/event/{id}/cancel— cancel after concurrency validationPOST /api/event/{id}/moderation/light— light moderation; exposed by HAL relationmoderate-lightwhen the caller has moderation authorityPOST /api/event/{id}/moderation/heavy— irreversible heavy redaction; exposed by HAL relationmoderate-heavyonly after backend redaction, storage-deletion retry, and generic notification safety are availablePOST /api/event/{id}/moderation/unmoderate— restore a reversibly light-moderated event toPublished; exposed by HAL relationunmoderateonly when the latest moderation record allows unmoderation
- Event sessions and program items:
GET /api/eventsession/GET /api/eventsession/{id}/GET /api/eventsession/by-event/{eventId}— anonymous public session reads; only scheduled, published sessions under public published events are returnedGET /api/eventsession/management/by-event/{eventId}— authenticated management read that can return draft/internal sessions when authorizedPOST /api/eventsession/drafts— create an unscheduled draft session under an eventPOST /api/eventsession/{id}/schedule— assign a real schedule and local projections after concurrency validationPOST /api/eventsession/{id}/publish— publish a scheduled session after parent-event/readiness checksPOST /api/eventsession/{id}/cancel— cancel a draft/submitted/review/approved/published session after concurrency and parent-event lifecycle validationPOST /api/eventsession/{id}/complete— complete a published session after confirming the parent event is still publishedPOST /api/eventsession/{id}/archive— archive a draft, cancelled, or completed session after concurrency and parent-event lifecycle validationEventSessionDtoandEventSessionListDtoexposeparentEventStatusId, nullablestartTime,endTime, and local projection fields. Lifecycle_linksare computed from the current session status, parent Event status, and full schedule shape; clients must not infer actions from those fields. UseconcurrencyStampwhen following advertised writes.
- Aspect endpoints:
- Islamic:
GET /api/event/{id}/aspects/islamic(GetEventIslamicAspect),POST(CreateEventIslamicAspect), groupedPATCH(UpdateEventIslamicAspect), andDELETE(DeleteEventIslamicAspect). - Tech:
GET /api/event/{id}/aspects/tech(GetEventTechAspect),POST(CreateEventTechAspect), groupedPATCH(UpdateEventTechAspect), andDELETE(DeleteEventTechAspect).
- Islamic:
- Module governance:
/api/module/*(available,enabled,enable,disable,schema)
- Public experience:
GET /api/publicexperience/settingsPOST /api/a/t— anonymous-safe analytics relay for relay transport mode
- Federation:
GET /api/event— anonymous typed local/federated event discovery; federated items appear only for an effectively enabled tenant and are de-duplicated against local ATProto ownership.- Accepted inbound community events are imported internally through
ImportAtprotoFederatedEventCommandinto tenant-localEventandEventSessionrows. There is no public ATProto-import endpoint; the normal event/session read APIs expose the mapped aggregates, while the primary record retains the complete accepted source JSON. GET /api/event/federated/{atprotoRecordId}/source— anonymous, rate-limited redirect to the currently tenant-visible bounded HTTPS source. Clients render this action only from the item-levelsourceHAL relation.GET /api/event/my— authenticated local event list with optionalatprotoDeliveryStatusand stableatprotoDeliveryFailureCode; no provider body is returned.GET|PUT|DELETE /api/settings/instance/atproto-federation...— instance administrator read/update/reset and lock/unlock operations for the two administrator ATProto federation settings, with HAL as action authority./api/indexeddid/*— DID indexing metadata under existing authorization./api/auth/atproto/*— server-private bootstrap/current/refresh/revoke bridge, excluded from public OpenAPI and generated browser clients.- No
/api/atprotorecordCRUD/read contract exists. Lifecycle-owned outbox delivery and authoritative Jetstream ingestion are the onlyAtprotoRecordwrite authorities.
- Notifications (all
[Authorize]): -GET /api/notification— paginated list with?isRead=and?notificationTypeId=filtersGET /api/notification/{id}— detailGET /api/notification/unread-count— unread count (partial index optimized)GET /api/notification/stream— SSE unread-count refresh hints (text/event-stream)PATCH /api/notification/{id}/read— mark single as read (idempotent)POST /api/notification/read-all— bulk mark all as read (YouTube-style, timestamp cutoff)DELETE /api/notification/{id}— soft deleteGET /api/notification/preferences/me— current user's HAL notification preference matrixPATCH /api/notification/preferences/me— patch supplied current-user preference cellsPUT /api/notification/preferences/me/mute— set current-user non-essential notification mute stateGET|PATCH /api/organization/{id}/notification-preferencesplusPUT .../mute— organization-scoped notification preferencesGET|PATCH /api/group/{id}/notification-preferencesplusPUT .../mute— group-scoped notification preferencesGET /api/notification/web-push/config— public enabled flag and VAPID public key onlyGET /api/notification/web-push/subscription?deviceIdentifier=...— safe current-device subscription statusPOST /api/notification/web-push/subscriptions— enroll or refresh the current browserDELETE /api/notification/web-push/subscriptions/{subscriptionId}— deactivate an owned browser subscription
- Actor subscriptions (all
[Authorize]):GET /api/actor-subscriptions— current user's paged actor subscriptionsGET /api/actor-subscriptions/actors/{targetActorId}— current user's subscription state for a target actorPOST /api/actor-subscriptions— subscribe to an organization/group actorPATCH /api/actor-subscriptions/actors/{targetActorId}/notification-level— patch the route-owned subscription notification-level group with a concurrency stampDELETE /api/actor-subscriptions/actors/{targetActorId}— unsubscribe with concurrency stamp
- Footer management:
GET /api/footer/config: public footer config (AllowAnonymous)GET /api/footer/settings: authenticated scalar settings, typed social links, governance locks, and HALedit/manage-link-groupscapabilitiesPATCH /api/footer/settings: presence-awaregeneral,template,description,socialLinks, andcopyrightgroups; omitted leaves and instance-locked scalar leaves are preservedGET /api/footer/link-groups: list link groups (Authorize)GET /api/footer/link-groups/{id}: link group detail (Authorize)POST /api/footer/link-groups: create link group; requires authenticated tenant update authorizationPATCH /api/footer/link-groups/{id}: update supplied link-group fields; requires authenticated tenant update authorizationDELETE /api/footer/link-groups/{id}: delete link group; requires authenticated tenant update authorizationPOST /api/footer/link-groups/reorder: reorder link groups; requires authenticated tenant update authorizationPOST /api/footer/link-groups/{groupId}/links: create link in group; requires authenticated tenant update authorizationPATCH /api/footer/links/{id}: update supplied link fields; requires authenticated tenant update authorizationDELETE /api/footer/links/{id}: delete link; requires authenticated tenant update authorization- Link mutations remain explicit operations and repeat the effective link-group governance check server-side. Clients render link management only when the settings resource includes
manage-link-groups.
- Actor appearance:
- Actor entities include appearance fields (BackgroundColor, BackgroundEffect, BannerColor, BannerPictureId, BackgroundImageId) managed via actor update endpoints.
- Instance MCP governance:
GET /api/instance/settings/mcp— instance MCP runtime enablement and tenant override lock state.PUT /api/instance/settings/mcp— updatemcp.enabled,mcp.enable_legacy_sse,governance.lock_tenant_mcp, andgovernance.lock_tenant_mcp_legacy_sse. - Endpoint path and stateless mode remain startup-only and are not exposed as runtime-editable fields.
- Authenticated UI shell context:
GET /api/ui-shell/contextreturns the current tenant's server-authoritative workspace availability, organization/group publisher actors, explicitly authorized settings scopes, deployment mode, organization-centric pinned actor, and resolved navigation defaults.- The response is a plain DTO, requires authentication, sends
Cache-Control: private, no-store, and is never shared with the anonymous public-experience shell. Instance administration alone does not grant Studio or Tenant settings access.
- Building
Explore.API/Explore.API.csprojinReleaseruns ASP.NET Core build-time OpenAPI generation and refreshes the checked-inschemas/openapi_islamu-event.jsoncontract. - Contract invariant and parity tests assert the runtime
/openapi/islamu-event.jsonshape without writing generated files. Explore.ApiContractInventorywrites the committed endpoint inventory to API_CONTRACT_INVENTORY.md.- HAL schema transformers shape OpenAPI schemas so generated clients preserve HAL extension data.
Explore.Blazor.Client/Explore.Blazor.Client.csprojusesschemas/openapi_islamu-event.jsonas NSwag input and regeneratesExplore.Blazor.Client/Clients/EventApiClient.g.csbeforeCoreCompile.- DTO changes should follow API-first regeneration workflow (see
docs/CONTRIBUTING.md).
Public HAL detail wrappers must be registered in Explore.API/OpenApi/HalOpenApiSchemaCatalog.cs. If a new HalResourceOf*Dto wrapper is omitted, OpenAPI can emit an empty wrapper schema and generated clients lose the DTO fields even though runtime HAL responses are correct.
For contract changes, regenerate from server DTOs in this order:
dotnet build src/Explore.API/Explore.API.csproj --configuration Release --no-restore --verbosity minimal -maxcpucount:1
dotnet run --project eng/tools/Explore.ApiContractInventory/Explore.ApiContractInventory.csproj --configuration Release
dotnet msbuild src/Explore.Blazor.Client/Explore.Blazor.Client.csproj /t:GenerateApiClient /p:Configuration=Release /p:Restore=false /m:1 /v:minimalThe API build is the provenance for schemas/openapi_islamu-event.json; the named integration test generates docs/API_CONTRACT_INVENTORY.md; the NSwag target consumes that schema and generates Clients/EventApiClient.g.cs. These artifacts are never hand-edited.
The generated contract now includes ImportEvent, CreateDraftEventSession, ScheduleEventSession, PublishEventSession, CancelEventSession, CompleteEventSession, and ArchiveEventSession operations. NSwag emits nullable client properties for draft-capable session schedule fields, so callers must handle DateTimeOffset? and TimeSpan? for session schedule/local projections.
Before v1.0, intentional breaking API contract changes may be accepted when they make the API, HAL affordances, or generated OpenAPI contract cleaner. They still require an entry in API_CHANGELOG.md, regenerated OpenAPI/inventory/generated-client artifacts through the documented workflow when applicable, and retained contract-governance evidence. Do not hand-edit schemas/openapi_islamu-event.json, docs/API_CONTRACT_INVENTORY.md, or generated NSwag client output. At v1.0, breaking schema diffs become blocking per governance.
- Account buyers read the private payment projection at
GET /api/events/{eventId}/registration-orders/{orderId}/payment.refundedAmountMinorfollows exact provider-proven buyer-refund evidence even if platform-fee settlement still needs operator action; an individual operation is namedRefundedonly after both legs are exact. Requested, pending, action-required, unknown, failed, and cancelled outcomes remain distinct. POST .../{orderId}/payment/refundsis an authenticated, account-owner-authorized, rate-limited, idempotent event-cancellation refund request. Therequest-refundHAL relation is its UI authority.POST .../{orderId}/payment/material-change-choicerecords the account owner's closed-setaccept_new_termsorrequest_refundresponse. Therespond-material-changerelation appears only while a pending durable choice exists.- Studio reads
GET .../{orderId}/payment/studioand may create a bounded refund withPOST .../{orderId}/payment/studio/refundsonly throughmanage_paid_event_commerceand thecreate-refundrelation. A definitively provider-blocked settlement exposesretry-refund;POST .../{orderId}/payment/studio/refunds/{refundAttemptId}/retryrequeues that same attempt for authoritative reconciliation without creating another buyer refund. - Event-authorized campaign operations are
GET /api/events/{eventId}/refund-campaigns,GET /api/events/{eventId}/refund-campaigns/{campaignId}, and idempotentPOST .../{campaignId}/resume. Campaign resources expose only bounded progress counters, omit free-text decision reasons, and emitresume-refund-campaignonly for resumable state. Resume explicitly requeues provider-blocked attempts; automatic reconciliation never repeats a definitive provider rejection. - Every response is private/no-store. Provider account, payment/refund IDs, idempotency material, request IDs, raw provider errors, and purchaser PII are not part of the contract.
Purchase authority is exposed as two private, idempotent HAL operations:
| Caller | Route | Authority |
|---|---|---|
| Authenticated account | POST /api/events/{eventId}/registration-orders/{orderId}/purchase-authority |
Current account plus a server-verified personal/group/organization actor |
| Guest capability | POST /api/events/{eventId}/registration-orders/guest/{orderId}/purchase-authority |
Opaque order capability plus persisted verified-contact or order-scoped name-only mode |
The JSON request can select access mode and actor context where applicable. It cannot supply tenant, account, normalized-contact hash, enforcement key, quantity, policy version, or idempotency authority. Quantity comes from persisted order lines, current policy lineage is selected inside Application, and the BFF creates the durable operation key.
Both routes use write/public-transactional rate limits, require the HTTP idempotency header, return private/no-store responses, and expose stable 400/403/404/409/503 shapes. A successful HAL resource publishes supportsHardCrossOrderCeiling and enforcementScopeCode; name-only mode reports order scope rather than claiming cross-order person identity.
RegistrationOrder HAL resources publish reserve-purchase-authority only while the order is currently payable. OpenAPI and EventApiClient.g.cs are generated artifacts; the purchase request, result, enum, and flattened HalResourceOfTicketPurchaseGovernanceResource schemas are covered by generated-contract tests.
The private exact resource is GET|POST|DELETE /api/events/{eventId}/registration-orders/{registrationOrderId}/lines/{registrationOrderLineId}/waitlist. Offer acceptance and supply withdrawal are subordinate pointer routes. Reads accept the opaque registration-order capability only in X-Registration-Order-Capability; writes require authentication, Idempotency-Key, rate limiting, replay protection, and private no-store responses.
FairReturnWaitlistDto publishes only an opaque resource UUID, bounded position, bounded status/reason codes, and optional offer expiry. A position of zero means unavailable and values above 999 are capped. Server-only Can* and stop-control facts are JSON-ignored and drive HAL relations; clients must not infer actions from roles, local claims, status strings, or payment state. Unknown identities, seller conflicts, stale offers, and authority failures use the same private not-found envelope.
The Blazor BFF mirrors these routes under /bff, forwards through the generated IEventApiClient, retains cookie authority, validates antiforgery before write-rate limiting, and never accepts capabilities in query strings or response bodies.
docs/SECURITY-MODEL.md— auth, JWT, CORS, security headersdocs/ARCHITECTURE.md— Clean Architecture layers, request flowdocs/OPERATIONS.md— rate limiting config, timeouts, shutdowndocs/CODEBASE_INSIGHTS.md— non-obvious patternsdocs/MULTI_TENANCY.md— tenant resolution and isolationdocs/OUTBOX_PATTERN.md— outbox pattern implementation detailsdocs/FOOTER_MANAGEMENT.md— footer management systemdocs/CONTRIBUTING.md— development workflow