ADR 011 Public Read Surface - digitalspace/eagle-demi GitHub Wiki
ADR-011: eagle-public reads move onto DEMI's public read surface
Status
Accepted.
Context
eagle-public (the react line) reads most models from eagle-api. Only Project/Document/
DocumentChunk search, the assessment-rail project point read, and document download via
bulk-downloads ride DEMI. List, Organization, CommentPeriod, Comment, RecentActivity and
ProjectNotification have no Cosmos container and no /search branch: eagle-public reads them from
eagle-api's own /api/search and a handful of bespoke routes, and always will unless DEMI can serve
them.
eagle-api already has one dataset it does not serve at all: /api/search?dataset=Comment throws
(Search missing match aggregation), because it has no match-aggregation case for Comment and
never needed one — GET /api/public/comment?period= is comment's only route. Any read DEMI cannot
answer for that dataset therefore has no eagle-api fallback either.
Two more routes complete the public surface. POST /demi-search/gate (DEMI POST /api/gate) checks
the access gate password: 204 on match, 401 on mismatch, 503 when no password is configured. The
password comes from Key Vault secret access-gate-password, set by scripts/deploy-infra.sh test
from the OpenShift secret demi-app-secrets; nobody writes the value by hand.
GET /demi-search/documents/<id>/download (DEMI GET /api/documents/<id>/download) has three
modes. All three run the same visibility check first. The eagle-edge /demi-search/documents/*
pattern routes it.
- JSON (no query): the signed object store URL in a JSON body.
- Redirect (
redirect=1, or anAcceptstartingtext/html):302to the signed URL. Withinline=1as well, pdf, png, jpeg, gif and webp are signed withContent-Disposition: inlineand their real content type; every other type downloads. - Stream (
inline=1withoutredirect=1): DEMI sends the file itself, so the address bar keeps the DEMI host and the link does not expire. It answers200, a single-range206,304or416withContent-Type,Content-Disposition: inline,Content-Length,Accept-Ranges,Last-Modifiedand anETag.
Last-Modified is the later of the object's date and the record's updatedAt, because a rename or a visibility change moves the record, not the object, and Front Door revalidates by date alone.
Stream mode applies to the inline types above, up to DOWNLOAD_STREAM_MAX_BYTES (32 MiB by
default). A larger file, another type, a stat or read failure on the store, or a limit of 0 gets
the redirect 302, never JSON. Without inline=1 the file downloads as an attachment.
Cache headers follow from who asked. An anonymous stream answer says public, max-age=300. A
credentialed one says private, no-store. Every other answer on the route, including the 302
and every error, says no-store, because Front Door keeps an answer with no Cache-Control for
one to three days. After an unpublish a cached copy can still be served for up to five minutes at
the edge and five more in a browser that already holds it. The edge route is described in
Front-Door-Per-App-Split in the eagle-dev-guides wiki. The setting and its checks are in
Operating-DEMI-in-Azure.
Since eagle-public v3.0.0-beta.30 the react line reads nothing from eagle-api at runtime except
/api/usage/events, which is the analytics ingest path, not an eagle-api route.
Decision
Serve the rest of eagle-public's reads from DEMI's existing /search endpoint, behind the
SEARCH_API_PATH switch it already has. No new public URL, no new nginx location, no new config
field: List, Organization, CommentPeriod, Comment, RecentActivity and ProjectNotification
become datasets GET /search recognizes, served from four new Cosmos containers
(commentPeriods, comments, notifications, and lists holding both List and Organization
rows). Field-level redaction runs the same way as every other dataset, at the response boundary,
never inside a point-read handler.
Writes stay on eagle-api. Comment submission, attachment upload, CAC sign-up and removal, and
the gate password were unchanged by this decision. DEMI's four new containers are mirror targets
only, kept current by demiPush.js on every eagle-api write (see Sync-Architecture) and by a
backfill for what predates the push (see Public-Read-Backfill).
Since 2026-10-02 (eagle-public PR #952, in v2.9.0) the Angular site sends no write to eagle-api. The
comment, CAC sign-up and attachment upload calls are gone from the app. A comment period that ENGAGE
runs links out to ENGAGE, and any other open period shows "Comments are not accepted on this site."
The one POST the site sends is the gate password check, to DEMI /gate. eagle-api keeps the push, so
the mirror containers still follow it.
A model DEMI is missing reaches it through eagle-api's push. eagle-api is the one writer of Eagle Mongo and the one pusher of Eagle records into DEMI; a second writer of those records would let DEMI and Eagle disagree with no way to tell which is right. The exception is ENGAGE engagements: DEMI owns those rows and writes them into Eagle through eagle-api's HTTP API (ADR-014-Sync-Out).
/api/config stayed on eagle-api and was the kill switch. Moving configuration itself onto
DEMI was considered and set aside: it would make the switch that turns DEMI reads off depend on the
system those reads are switching to.
Since 2026-10-02 that switch is gone. eagle-public boots from /demi-search/config, which Front
Door rewrites to DEMI GET /api/config/public (see ADR-012-Public-Config-Surface). If that
fails after three attempts the app shows its unavailable page; it does not fall back to
/api/config. An empty SEARCH_API_PATH means /demi-search, not eagle-api. eagle-api's Mongo
Config document is still the source of truth, copied to DEMI by eagle-api.
Document download stays presigned through DEMI's bulk-downloads path
(createBulkDownload([id])), which already existed for the project reads DEMI serves today; comment
attachments move onto the same path instead of eagle-api's direct download route.
Notifications get their own container rather than folding into projects. A notification has no
project of its own; storing it on the projects container would surface it in the project list, the
map and the projects search index, none of which is correct for it.
Consequences
- Reads and writes for these six datasets sit on different systems: a write lands in Eagle Mongo and pushes into DEMI Cosmos on the same request, and a read served by DEMI can be briefly behind a write eagle-api has already returned 200 for.
- Until 2026-10-02, blanking
SEARCH_API_PATHreturned eagle-public to eagle-api for every read this ADR moves, in one step, because the switch was per-request, not per-dataset. That switch no longer exists. Sending the site back to eagle-api reads takes a code change in eagle-public. - A DEMI outage has no fallback for any public read, and none for the site's own configuration.
Commentnever had an eagle-api equivalent for the shape eagle-public needs (paged, filtered by period, withx-total-count); now no dataset has one. - Comment author is PII gated on
isAnonymous. A catalog or predicate error on thecommentscontainer publishes a name that should be withheld; nothing else in this design would catch it before it reached a browser. - The reconciliation that already exists for projects and documents
(
src/scripts/reconcile-eagle.js) extends to the new containers, so drift between eagle-api and DEMI is visible for all of them the same way.
See also
- Sync-Architecture — how these containers are kept current
- Search-Index-Reference — the Cosmos-served datasets and their query parameters
- Connecting-an-Application-to-DEMI — the mirror routes and their wire contract
- Public-Read-Backfill — filling the containers from before the push existed, and the
misfiledParentrepair