EidosApi (frontend HTTP layer)¶
Verified against the code · 421c63ba3fab5894cdb9e707eed8ef694881d60d · 2026-10-20
956 D1b-1 (2026-10-20): 1 finding(s) fixed — the three head plugins and shortcuts.js load before eidos-api.js; only app.js and later plugins follow it. Re-verified against main 421c63ba.¶
Layer: frontend
EidosApi is the single surface through which the browser talks to the backend.
Every request from app.js and the plugins eventually reaches
EidosApi.request(); a raw fetch( outside frontend/eidos-api.js is a
ratchet violation, caught by tests/test_frontend_fetch_ratchet.py using a true
Babel AST (not a grep) over every deployed script. The ratchet is frozen at
equality (not <=), so a new raw fetch( sneaking in bumps the count and
trips CI.
Why one layer¶
Before trin 4.1a (#674) the HTTP concerns were scattered. Timeouts lived at each call site, auth headers were re-constructed on the fly, the 401-redirect path was duplicated, and the primary-proxy routing for secondary domains was re-implemented in multiple callers. Centralising those concerns in one layer gives us:
- Auth-header injection in one place — the caller never names
Authorizationexplicitly; the layer reads from the injectedauthHeaders(). - Primary-proxy routing for secondary domains — a request against a
non-primary
basebecomes a/api/proxy/{domain}call to the primary. Secondary services never see the user's Bearer token. - Canonical error shape (Contract 05 / the §3.1 envelope) — a 4xx/5xx
response is unwrapped once, into one of
NotFoundError,AccessDeniedError, or a genericErrorwithstatus. Callers branch onstatus === 409(version conflict) uniformly. - 401 handling —
onUnauthorizedfires the login overlay from one place. - Timeouts —
timeoutMsdefaults to 8000ms; a caller may opt out (timeoutMs: 0) explicitly.raw()defaults to0so a migrated raw-fetch site keeps its "no browser-side abort" property. - Response decoding —
as: 'json' | 'blob' | 'text' | 'response'is a single declared axis.
The configure(deps) seam¶
eidos-api.js is loaded BEFORE app.js and the plugins that load after it
(index.html:6978), so it is defined by the time anything calls it. It is not
the first script: plugins/rule-sentence.js, domain-routing.js and
tree-chip-type.js (<head>, lines 29-31) and shortcuts.js (line 6975) load
before it. It stays dependency-light on
purpose: app.js wires the concrete refs in ONE place via
EidosApi.configure({...}) during bootstrap. The layer never reaches into
app.js's lexical globals (BASE_URL, authHeaders, …), which are const
in a classic script and fragile to reference across files.
configure(deps) injects:
| Dep | Shape | Purpose |
|---|---|---|
primaryBase |
string OR () => string |
The primary origin — read late (BASE_URL is empty until loadConfig() runs). |
authHeaders |
() => ({Authorization: 'Bearer …'}) |
Auth header injection. |
resolveDomain |
(base) => domainKey \| null |
baseToDomain — picks the proxy segment. |
onUnauthorized |
() => void |
Login overlay trigger (401). |
NotFoundError |
ctor | Thrown on 404. |
AccessDeniedError |
ctor | Thrown on 403. |
devMode |
() => bool |
4.4c runtime path guard: on in dev/test, off in prod. |
pathRegistry |
window.EidosPaths or null |
The frozen path register — guard disabled when null. |
configure() is idempotent-ish — a second call replaces the deps. Tests pass
their own stubs; app.js wires live refs once at bootstrap.
The request/raw split¶
request(path, opts)is the real surface. It validatesoptsagainst the declared set, resolves the URL, applies auth + proxy routing, builds the body (JSON by default), runs anAbortControllerfor the timeout, and decodes the response peras.raw(path, opts)is the non-throwing passthrough — returns theResponsefor ANY status so a caller keeps its bespoke error UI.raw()defaults totimeoutMs: 0andthrowOnError: falseto match a migrated raw-fetch site exactly. It is also the chokepoint for the dev/test runtime path guard (4.4c): whenpathRegistryis wired ANDdevMode()returns true, the pathname is asserted present in theEidosPathsregister before the call proceeds.
Thin wrappers over request() cover the common verbs: getJson, postJson,
putJson, del, getBlob.
Related guards¶
- tests/test_frontend_fetch_ratchet.py — raw-fetch ratchet (AST-based, frozen-at-equality). Also freezes XMLHttpRequest / EventSource / navigator.sendBeacon / fetch-aliases at 0.
- tests/test_frontend_paths_in_contract.py — static path scan: every HTTP
layer call site uses
EidosPaths.<name>or a tolerated literal. - frontend/eidos-paths.js — the generated path register (see
eidos-pathsentity).
Load order¶
<script src="frontend/eidos-api.js"> <!-- defines window.EidosApi; dep-light -->
<script src="frontend/eidos-paths.js"> <!-- defines window.EidosPaths (frozen) -->
<script src="frontend/app.js"> <!-- calls EidosApi.configure({...}) -->
<script src="frontend/plugins/*.js"> <!-- later plugins; consumers only -->