Date: 2026-04-29
Sub-project owner: External API
Brief: ../../subprojects/04-external-api.md
Predecessors: Foundation, Data & Domain, Internal API
Ship the public REST API (CCE.Api.External) — ~55 endpoints covering BRD §4.1.1–4.1.18 (public functional requirements) and §6.2.1–6.2.36 (public user stories). Includes published-content reads, search, knowledge maps, interactive city, smart-assistant proxy, community endpoints, registration, profile, notifications, plus BFF cookie wiring for the public Web Portal SPA per ADR-0015.
In scope:
- All public endpoints needed for the BRD requirements above.
- BFF authentication (
/auth/login,/auth/callback,/auth/refresh,/auth/logout) with httpOnly + SameSite=Strict cookie sessions. - Bearer-JWT auth as a parallel mode for non-browser clients (mobile / curl / direct API).
- Search backend (Meilisearch) + indexer hosted in the Internal API process.
- Redis-backed output cache for anonymous reads (60-second TTL, config-driven).
- Tiered rate limiting (anonymous / authenticated / search-and-write) per IP and per session, config-driven.
- HtmlSanitizer for all user-submitted content.
- Country-scoped query enforcement via
ICountryScopeAccessor(was deferred from Sub-3). - 5 new ADRs (0030–0034).
- Annotated tag
external-api-v0.1.0.
Out of scope (deferred):
- Smart-assistant LLM provider integration — Sub-project 8 (Integration Gateway). Phase 9 ships a stubbed
ISmartAssistantClientwith a fixed-response implementation. - KAPSARC ingestion pipeline — Sub-8. Phase 9 only ships the read-side query endpoint over
CountryKapsarcSnapshotrows that Sub-8 will populate. - Mobile app OIDC flow — Sub-9. Bearer support exists in v0.1.0; the mobile-specific token endpoint isn't.
- Active cache invalidation on admin writes — TTL-only invalidation in v0.1.0; cross-process MediatR invalidation can land in Sub-8.
- Full-text fuzzy/synonym tuning — Meilisearch defaults shipped; later milestones tune.
CCE.Application— handlers + DTOs + service abstractions for every new feature. Continues the MediatR command/query handler pattern from Sub-3. New abstractions:ISearchClient,IHtmlSanitizer,ISmartAssistantClient,ICountryScopeAccessor.CCE.Api.External— minimal-API endpoint mapping under/api/...and/auth/.... Mirrors the per-feature endpoint folders (NewsEndpoints,SearchEndpoints,BffAuthEndpoints, etc.) ofCCE.Api.Internal.CCE.Api.Common— Bearer-vs-cookie dual-auth middleware, output-cache middleware, tiered rate-limiter setup. Shared with the Internal API where applicable.CCE.Infrastructure— Meilisearch HTTP client,MeilisearchIndexerhosted service (Internal API only),RedisOutputCache,HtmlSanitizerWrapper, stubbedSmartAssistantClient,HttpContextCountryScopeAccessor.
The External API exposes 4 BFF endpoints. The cookie session encrypts { access, refresh, expiresAt } via ASP.NET Data Protection; ~4 KB; httpOnly + Secure + SameSite=Strict. PKCE pair stored in a short-lived cce.pkce cookie during the authorize round-trip.
BffSessionMiddleware runs after rate limiting and before authentication: when cce.session is present, it decrypts, refreshes if needed, and synthesizes an Authorization: Bearer <access> header so downstream code (the existing AddCceJwtAuth) is identical to the Bearer-token path.
Bearer requests skip the BFF middleware. Either path lands at the same [Authorize(Policy = Permissions.X.Y)] enforcement.
RedisOutputCacheMiddleware caches anonymous GET responses on whitelisted routes. Cache key: "out:{path}?{sortedQueryString}", varies on Accept-Language. Body + Content-Type stored together. Invalidation: timeout-only (config: Caching:OutputTtlSeconds, default 60). Authenticated requests bypass entirely.
Three tiers — Anonymous, Authenticated, SearchAndWrite. Each binds to RateLimit:<Tier>:RequestsPerMinute (defaults: 120 / 600 / 30). Per-IP for anonymous; per-session-or-Bearer-sub for authenticated. 429 with Retry-After. Implemented atop Microsoft.AspNetCore.RateLimiting.
MeilisearchIndexer : IHostedService in CCE.Infrastructure.Search, registered only in the Internal API host. Subscribes to existing domain events (NewsPublishedEvent, ResourcePublishedEvent, EventScheduledEvent, plus a new PagePublishedEvent). On each event, upserts the document into the appropriate Meili index. On startup, runs a drift check (SELECT COUNT vs index doc count) and triggers a full reindex if delta exceeds threshold.
ISearchClient (Application) is the read-side abstraction used by External; wraps Meilisearch's HTTP API.
IHtmlSanitizer interface in Application; HtmlSanitizerWrapper (Infrastructure) wraps the NuGet HtmlSanitizer. Allowlist: <p>, <br>, <strong>, <em>, <a href>, <ul>/<ol>/<li>, <blockquote>, <code>, <pre>. <a href> allows https:// only. All FluentValidators on user-content commands run input through the sanitizer.
ICountryScopeAccessor.GetAuthorizedCountryIds() returns IReadOnlyList<Guid>? — null means no scope (admin / ContentManager). For StateRep users, returns active state-rep-assignment country ids. Country-scoped queries apply WHERE country_id IN (@allowedIds) when not null. ContentManager + SuperAdmin bypass; anonymous users on public reads bypass too (public reads are not country-scoped).
The existing per-API path split from Sub-3 Phase 0.5 already serves /swagger/external/v1/swagger.json. The drift-check script (scripts/check-contracts-clean.sh) regenerates contracts/openapi.external.json and asserts no drift.
Identical to Sub-3 (route prefixes, HTTP shapes, FluentValidation, audit interceptor, ProblemDetails). Differences:
- Anonymous routes are gated only by the rate limiter and the output-cache middleware — no
[Authorize]attribute. - Public DTOs are intentionally narrower than admin DTOs. Example:
PublicResourceDtoomitsUploadedByIdandIsDeleted. Don't reuse admin DTOs. - Country-scoped routes call
ICountryScopeAccessor.GetAuthorizedCountryIds()and apply the filter in the handler. Where the result is null (non-StateRep), no filter applies. - Search hits are emitted as a polymorphic
SearchHitDtowith aTypediscriminator.
GET /auth/loginGET /auth/callbackPOST /auth/refreshPOST /auth/logout
GET /api/news(paged)GET /api/news/{slug}GET /api/eventsGET /api/events/{id}GET /api/events/{id}.icsGET /api/resources(paged, filter by category/country)GET /api/resources/{id}GET /api/resources/{id}/downloadGET /api/pages/{slug}GET /api/homepage-sectionsGET /api/topics(read-only listing of community topics)GET /api/categories(resource categories)GET /api/countriesGET /api/countries/{id}/profile
GET /api/search?q=&type=&page=&pageSize=— single endpoint with optionaltypeenum filter (news/events/resources/pages/knowledge-maps)
POST /api/users/register(proxy + redirect to Keycloak signup)GET /api/mePUT /api/me(locale, interests, knowledge level, avatar URL)POST /api/users/expert-request(submit expert registration request)GET /api/me/expert-status
GET /api/me/notifications(paged)GET /api/me/notifications/unread-countPOST /api/me/notifications/{id}/mark-readPOST /api/me/notifications/mark-all-read
GET /api/community/topics/{slug}(single topic with metadata)GET /api/community/topics/{id}/posts(paged)GET /api/community/posts/{id}GET /api/community/posts/{id}/repliesGET /api/me/follows(own follows: topics + users + posts)
POST /api/community/postsPOST /api/community/posts/{id}/repliesPOST /api/community/posts/{id}/ratePOST /api/community/posts/{id}/mark-answerPUT /api/community/replies/{id}(within edit window)POST /api/me/follows/topics/{topicId}+DELETE /api/me/follows/topics/{topicId}POST /api/me/follows/users/{userId}+DELETEPOST /api/me/follows/posts/{postId}+DELETE
GET /api/knowledge-mapsGET /api/knowledge-maps/{id}GET /api/knowledge-maps/{id}/nodesGET /api/knowledge-maps/{id}/edges
GET /api/interactive-city/technologiesPOST /api/interactive-city/scenarios/run(anonymous OK)POST /api/me/interactive-city/scenarios(save)GET /api/me/interactive-city/scenariosDELETE /api/me/interactive-city/scenarios/{id}
POST /api/assistant/query(stub —ISmartAssistantClientreturns a fixed-response in v0.1.0; real LLM in Sub-8)GET /api/kapsarc/snapshots/{countryId}(over CountryKapsarcSnapshot rows)POST /api/surveys/service-rating(Anonymous OK; usesSurvey.Submitpermission which permits Anonymous)
Plus internal lifecycle endpoints (/health, /health/ready) inherited from Foundation.
- SPA →
GET /auth/login?returnUrl=/news/123 - External generates PKCE pair, sets
cce.pkcehttpOnly cookie, redirects to Keycloakcce-publicrealm authorize endpoint withcode_challenge. - User authenticates at Keycloak.
- Keycloak redirects to
/auth/callback?code=...&state=.... - External exchanges
code + verifierfor tokens at Keycloak token endpoint. - External Data-Protection-encrypts
{ access, refresh, expiresAt }intocce.sessioncookie (Secure, HttpOnly, SameSite=Strict, 30-min sliding). - External 302s to
returnUrl.
- SPA →
/api/me. Browser auto-attachescce.session. BffSessionMiddlewaredecrypts cookie. IfexpiresAtis past, calls Keycloak refresh, rotates cookie. If refresh fails, clears cookie and 401s.- Middleware writes
Authorization: Bearer <access>synthetic header. AddCceJwtAuthvalidates the token;RoleToPermissionClaimsTransformerflattens groups to permissions;[Authorize]policies pass.- Handler runs.
- Anonymous →
GET /api/search?q=carbon+capture&type=news - Rate limiter: 30 req/min per IP (
SearchAndWritetier). - Endpoint calls
ISearchClient.SearchAsync(query, type, page, pageSize, ct). MeilisearchClientissues HTTP POST tohttp://meilisearch:7700/indexes/{type}/search.- Returns
PagedResult<SearchHitDto>. - Async fire-and-forget
_db.SearchQueryLogs.Add(...)for analytics (append-only).
- Authenticated user →
GET /api/resources/{id}/download - Handler loads
Resource. VerifiesIsPublished == true. - Loads associated
AssetFile. VerifiesVirusScanStatus == Clean. Else 403. - Calls
IFileStorage.OpenReadAsync(asset.Url, ct)and pipes stream toResponse.BodywithContent-Typefrom asset. - Increments
Resource.ViewCountasync.
- RegisteredUser →
POST /api/community/postsbody{ topicId, content, locale, isAnswerable }. - Rate limiter:
SearchAndWritetier. CreatePostCommandValidatorruns FluentValidation;IHtmlSanitizer.Sanitize(content)strips disallowed HTML.- Handler calls
Post.Create(topicId, currentUserId, content, locale, isAnswerable, _clock). - Saves via
IPostService.SaveAsync→ firesPostCreatedEvent→ notification handler enqueues notifications for topic followers. - Returns
201 PostDtowithLocationheader.
- RegisteredUser →
POST /api/me/notifications/{id}/mark-read - Handler loads
UserNotification; checksnotif.UserId == currentUserId(else 404 — never leak ownership). - Calls
notif.MarkRead(_clock). Saves. - Returns 204.
Continues Sub-3's pipeline; ExceptionHandlingMiddleware already maps DomainException → 400, ConcurrencyException/DuplicateException → 409, KeyNotFoundException → 404, ValidationException → 400. New mappings added in Phase 0:
MeilisearchExceptionfrom the Meili client → 503 with typehttps://cce.moenergy.gov.sa/problems/search-unavailable.OperationCanceledExceptionfrom cancelled requests → 499 (no body — client disconnected).
Public 4xx/5xx responses use RFC 7807 ProblemDetails. Internal exception details are never leaked in Detail for public-facing errors; only the correlation id is exposed.
- Application unit tests (
CCE.Application.Tests) — every handler: happy + permission-fail + validation-fail + sanitization-applied where relevant. ~80 new tests. - Integration tests (
CCE.Api.IntegrationTests) — each endpoint: anonymous-401-or-200, authenticated-200, rate-limit-breach-429. BFF flow: dedicated end-to-end test using existing Keycloak Testcontainer. ~70 new tests. - Search tests — Meilisearch Testcontainer per test class verifies index-and-query roundtrip. ~10 tests.
- Architecture tests — existing 12 stay green; one new rule:
External_does_not_depend_on_Internal.
To be written in Phase 9 of this sub-project:
- ADR-0030 — Country-scoped query pattern via
ICountryScopeAccessor. (Was deferred from Sub-3; lands here.) - ADR-0031 — BFF cookie + Bearer dual-mode authentication.
- ADR-0032 — Meilisearch as primary search backend with
ISearchClientabstraction. - ADR-0033 — Redis output cache for anonymous reads (60-second TTL, timeout-only invalidation).
- ADR-0034 —
HtmlSanitizerfor user-submitted content.
- New CPM packages:
Meilisearch.Dotnet,HtmlSanitizer(by mganss). permissions.yaml: no new permissions (Survey.Submitalready permits Anonymous; existingCommunity.*permissions cover the community endpoints).docker-compose.ymladdsmeilisearch:v1.xcontainer.
- ~55 endpoints implemented and permission-gated.
- BFF cookie + Bearer dual auth working end-to-end (verified with E2E test against Keycloak Testcontainer).
- Output-cache middleware mounted on whitelisted anonymous reads.
- Tiered rate limiter live with config-driven limits.
- Meilisearch indexer +
GET /api/searchquery endpoint. - HtmlSanitizer integrated on every user-content command/validator.
- Country-scoped reads enforce
ICountryScopeAccessorfor StateRep. - OpenAPI
external-api.yamlexported and drift-checked. - 5 new ADRs (0030–0034).
-
docs/external-api-completion.mdDoD report. - CHANGELOG entry.
-
external-api-v0.1.0annotated tag. - ~160 net new backend tests on top of Sub-3's totals.
10 phases (0–9). Master plan + per-phase plan files written in project-plan/plans/2026-04-29-external-api/.
| # | Phase | Tasks (rough) | Deliverable |
|---|---|---|---|
| 0 | Cross-cutting | ~6 | BFF auth + dual-mode + output cache + rate limiter + Meilisearch client + sanitizer + scope accessor |
| 1 | Public reads | ~9 | 14 anonymous-OK content endpoints |
| 2 | Search | ~3 | Indexer + search endpoint + tests |
| 3 | Registration + profile | ~5 | Self-service profile + expert-request submission |
| 4 | Notifications | ~4 | User-facing notification CRUD-read |
| 5 | Community reads | ~5 | Topic browsing + post/reply reads |
| 6 | Community writes | ~7 | Post/reply/rate/follow + sanitization |
| 7 | Knowledge map | ~4 | Graph traversal endpoints |
| 8 | Interactive city | ~5 | Scenario run + save endpoints |
| 9 | Smart assistant + KAPSARC + survey + release | ~6 | Stubs + ADRs + completion + tag |
~54 tasks total. Same just-in-time-per-phase plan-writing approach as Sub-3.