Skip to content

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 Authorization explicitly; the layer reads from the injected authHeaders().
  • Primary-proxy routing for secondary domains — a request against a non-primary base becomes 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 generic Error with status. Callers branch on status === 409 (version conflict) uniformly.
  • 401 handling — onUnauthorized fires the login overlay from one place.
  • Timeouts — timeoutMs defaults to 8000ms; a caller may opt out (timeoutMs: 0) explicitly. raw() defaults to 0 so 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 validates opts against the declared set, resolves the URL, applies auth + proxy routing, builds the body (JSON by default), runs an AbortController for the timeout, and decodes the response per as.
  • raw(path, opts) is the non-throwing passthrough — returns the Response for ANY status so a caller keeps its bespoke error UI. raw() defaults to timeoutMs: 0 and throwOnError: false to match a migrated raw-fetch site exactly. It is also the chokepoint for the dev/test runtime path guard (4.4c): when pathRegistry is wired AND devMode() returns true, the pathname is asserted present in the EidosPaths register before the call proceeds.

Thin wrappers over request() cover the common verbs: getJson, postJson, putJson, del, getBlob.

  • 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-paths entity).

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 -->