ADR 012 Public Config Surface - digitalspace/eagle-demi GitHub Wiki
ADR-012: A second config document for the public site
Status
Accepted.
Context
GET /config serves DEMI's own frontend: it reads the config container's one document, overlays
it on app settings, and degrades to app settings alone when the document is missing or Cosmos is
unreachable. Its OVERRIDABLE_KEYS (ENVIRONMENT, KEYCLOAK_*, BANNER_COLOUR, USE_MOCK_DATA)
are DEMI's own settings, not eagle-public's.
eagle-public boots from eagle-api's GET /api/config, backed by eagle-api's own MongoDB Config
document. eagle-api has no Config write controller — that document is edited in Mongo by hand — so
there is no write to hang a push handler off the way projects and documents push on their own
controllers.
Decision
Seed a second item, id: 'public', into the same config container. GET /config/public serves
it, unauthenticated like /config. PUT /eagle/config/public writes it, on the authenticated write
chain like every other eagle-api push. eagle-api keeps it current: GET /api/config compares the
payload it is about to serve against the last one it sent, and calls PUT /eagle/config/public when
the two differ (including on the first read after the pod starts). The push is fire-and-forget and retried on a later read if DEMI
rejects it. The read path is the only hook available, since Config has no write controller of its
own. A devbox script, src/scripts/seed-public-config.js, reads eagle-api's live /api/config and
writes the allowlisted keys directly — this is the bootstrap, run before the first push exists to
create the document, and the manual override, run to force the copy back to eagle-api's live
payload. It is not how the copy stays current day to day.
eagle-api's Mongo Config document stays the source of truth and the kill switch for both
ACCESS_GATE and the search backend. This copy follows it automatically; see Seeding for the
bootstrap command.
/config/public fails closed, the opposite of /config. A missing or unreadable document
answers 503 with Cache-Control: no-store rather than defaulting, because a defaulted payload
would serve ACCESS_GATE: false and open eagle-public's access curtain — the wrong direction to
fail in. /config degrades to app settings because there both was already a document-free answer,
answered a private admin app, and the app settings are DEMI's own real values. Neither is true here:
there is no meaningful default ACCESS_GATE, and the caller is the public internet.
/config/public sits outside the ACL and the entity catalog, for the reason /config and
apikeys already do: both endpoints are unauthenticated by design, so there is no visibility
predicate to compose and no read[] to check. What gates a public, unauthenticated payload is the
controller's key allowlist (PUBLIC_KEYS) rather than a permission field — a key reaches an
anonymous caller only once it is added to that list, not merely once it is written into the
document. An allowlisted key can also be dropped by a shape check (EXTENDED_PROJECT_PAGES
today), on the push, the seeder and the GET alike.
PUBLIC_KEYS is its own list, not a slice of OVERRIDABLE_KEYS. The two documents answer
different frontends with different key sets; sharing a list would let a key meant for the DEMI
admin console become public because the lists were merged.
Consequences
- eagle-public gains a second config source (
CONFIG_PATH, see eagle-public'sConfiguration Managementwiki page) that it tries before falling back to/api/config, never in place of it. - The push rides
GET /api/config, so this copy only follows changes a request actually surfaces. A pod that never receives a read after a Mongo edit holds a stale hash until one arrives; in practice every environment gets frequent reads from the frontends themselves. - The
configcontainer holds two items with different failure behaviour on the same read path; a third audience needs its own item and its own allowlist, not a branch on either existing one. - No bicep change: the container already partitions by
/id, and a second item under an existing partition key needs no schema change.
See also
- Seeding — the
db:seed-public-configbootstrap command - Connecting-an-Application-to-DEMI — reading
/config/publicwith no credential - ADR-004: Read ACL Authorization Model — why this route
composes neither
visibilityFor()norsystemAccess() - ADR-011: Public Read Surface — the other reads eagle-public takes from DEMI, and why configuration itself was set aside there