Skip to content

EidosPaths (frozen path register)

Verified against the code · 421c63ba3fab5894cdb9e707eed8ef694881d60d · 2026-10-20

956 D1b-1 (2026-10-20): 4 finding(s) fixed — head plugins load before eidos-paths.js, _EXCEPTIONS holds only domains.json (two-phase omission documented in the eidos-paths.js header), the contract guard is a lexical scan, the freshness test compares in memory. Re-verified against main 421c63ba.

Layer: frontend

window.EidosPaths is the frontend's canonical path register. Every path that goes to the backend is either (a) a bare property read like EidosPaths.me (static path), or (b) a builder call like EidosPaths.nodeNodeId(uid) (templated path, with each parameter URL-encoded). Loaded before app.js and the plugins that follow it (index.html:6979; the three <head> plugins rule-sentence.js, domain-routing.js and tree-chip-type.js at lines 29-31 load earlier), so a caller never needs to hand-write '/node/' + uid.

Generated, not hand-maintained

frontend/eidos-paths.js is auto-generated by scripts/gen_frontend_paths.py from docs/api/openapi.yaml. Regenerate with:

python scripts/gen_frontend_paths.py

One entry per unique contract path. Static paths (/me, /relations, /tree) land as string literals. Templated paths (/node/{nodeId}, /mutations/node/{nodeUuid}/impact) land as arrow builders, each parameter wrapped in encodeURIComponent. The register is Object.freezed at module end so a caller cannot mutate it at runtime.

Two freshness + contract tests

  • tests/test_frontend_paths_generated_fresh.py — calls gen_frontend_paths.generate() in memory and asserts the string equals the committed file; nothing is written to disk. A new OpenAPI path, a rename, or any manual edit of frontend/eidos-paths.js fails this test.
  • tests/test_frontend_paths_in_contract.py — static lexical scan (the _CALL / _ALIAS regexes plus a string/comment token scanner; no AST parser) of every deployed script in index.html plus frontend/plugins/*.js. For each HTTP layer call (apiFetch, _apiFetch, EidosApi.raw, EidosApi.request), the first argument must be EidosPaths.<name>, a frozen literal exception, or a runtime expression. Aliasing the HTTP layer under a new name is explicitly forbidden — a renamed alias would hide the call from the scan.

The one chokepoint not in the register

The EidosMutations-owned two-phase routes /mutations/{node,node-update,relation}/{preview,commit} are deliberately absent from EidosPaths. They are reached only through that layer; promoting them into the register would let any caller hit commit directly, bypassing the two-phase guard. That omission is documented in the frontend/eidos-paths.js header (and its generator). The in_contract test's _EXCEPTIONS list holds only domains.json / /domains.json; it never mentions the two-phase routes.

Dev/test runtime guard

EidosApi.raw(path, opts) consults pathRegistry + devMode() injected by configure(). In dev or test (devMode() === true AND pathRegistry set to window.EidosPaths), the pathname of the request URL is asserted present in the register before fetch( is called. In prod both branches short-circuit and the check is a single boolean eval. This complements the static scan by catching aliasing or dynamic-dispatch holes the static scanner cannot see.

Load order

<script src="frontend/eidos-api.js">     <!-- defines window.EidosApi; dep-light -->
<script src="frontend/eidos-paths.js">   <!-- freezes window.EidosPaths -->
<script src="frontend/app.js">           <!-- calls EidosApi.configure({pathRegistry: window.EidosPaths, ...}) -->

eidos-api.js loads BEFORE eidos-paths.js in frontend/index.html (per the EidosApi entity's own load-order block). eidos-api.js is dep-light and reads pathRegistry lazily through the configure() seam, so the file-load order does not matter for correctness — app.js wires the frozen register in during bootstrap once both files have loaded.