Skip to content

EidosIRI (frontend identity façade)

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

956 D1b-1 (2026-10-20): 1 finding(s) fixed — W5 share links carry the identity IRI hash (canonicalNodeShareUrl, #1000/#1006), not {BASE}/node/. Re-verified against main 421c63ba. #967 WP8 P3 (2026-10-21): TARGET_REF_KEY now cites the backend's private _EXT_TARGET_REF_KEY (the public alias is deleted; literal unchanged). Checked against the P3 change on top of main 562594ac. #989 WP9 S3 (2026-10-22): toIriParam added; app.js and detail-panel.js send every outgoing query/body node id as the IRI, the fetch ratchet counts bare ones at 0, and the escaped/unescaped IRI and legacy hashes are pinned. Checked against frontend/eidos-iri.js, app.js and detail-panel.js on the #989 branch (base 3c69a14f). #989 WP9 S4a (2026-10-23): FME 2.0.0 writes every identity value as its IRI; infra.target_ref.relation_target_iris writes to and external_uuid as node IRIs whatever the target's kind, a placeholder to kept as its IRI. Checked against target_ref.py and export_service.py on the #989 branch (base 8e8c8dd3). #1114 WP9 S5a (2026-10-23): toIri returns an id that is already a node IRI unchanged; the response paths still carrying a bare node uuid are frozen in a shrink-only allowlist (tests/test_wp9_s5_bare_id_ratchet.py). Checked against frontend/eidos-iri.js on the #1114 branch (base c7bada31). #1119 WP9 S5b (2026-10-23): every frontend and backend-internal reader of an allowlisted field reads the IRI companion (EidosIRI.idOf / infra.iri.companion_id), pinned by an AST taint scan and a stripped-response test; no response changed. Checked against eidos-tree.js, app.js, detail-panel.js, the export plugins and the backend fan-outs on the #1119 branch (base 4cafd4a7). #1120 WP9 S5c (2026-10-23): REST responses drop each bare node id whose IRI companion is set (infra.iri.without_bare_ids); a null companion keeps the value; the S5a allowlist is 42 -> 8, each kept value with its reason. Checked against backend/infra/iri.py and tests/test_wp9_s5_bare_id_ratchet.py on the #1120 branch (base fc560689). #1127 #607 S3 (2026-10-25): an occurrence IRI hash goes through the unchanged idOf; /resolve names its product. Checked against frontend/app.js on the #1127 branch (base 6774a2b6).

Layer: frontend

window.EidosIRI is the one frontend door for turning a node reference into a bare uuid and back. It mirrors backend/infra/iri.py (the backend identity façade) so the frontend and backend speak the same identity vocabulary. It is stateless and Object.freezed.

idOf: two input shapes

idOf(nodeOrString, field?) returns a bare uuid or null.

Input Result
"92dccd89-…" (bare uuid) the same uuid
"https://id.ontoteq.com/92dccd89-…" 92dccd89-…
"{base}/node/92dccd89-…" 92dccd89-…
"{base}/api/proxy/location/node/92dccd89-…" 92dccd89-…
"{base}/node-by-ref?ref=…" null — a ref, not an id
anything else null

For an object, idOf reads iri first, then @id, then uri, and returns the first one that parses. With a field name, it reads that field's IRI companion first: idOf(row, "target") reads targetIri, then target. It never scans for any key ending in Iri — a node response also carries isPartOfIri, which names the parent, not the node.

The /node/ branch gives exactly what the old inline split in uriToId gave, so every existing caller sees the same result. Because the IRI form and the URL form of one node give the same uuid, a cache keyed by uriToId(x) || x never gets a second entry for the same node.

Builders

  • toIri(uuid) → https://id.ontoteq.com/{uuid} (backend to_iri); an id that is already a node IRI is returned as is, never wrapped twice (WP9 S5)
  • toIriParam(uuid) → encodeURIComponent(toIri(uuid)), the IRI as a query value (WP9 S3)
  • nodeUrl(base, id) → {base}/node/{id} (backend node_url)
  • baseOf(uri) → the URI's origin, or null if it is not an absolute URI
  • nodeBaseOf(uri) → everything before the first /node/, path kept: {base}/node/{id} → {base} and {base}/api/proxy/{domain}/node/{id} → {base}/api/proxy/{domain}; null if there is no /node/ segment. It is byte-identical to the old uri.split('/node/')[0]. Use it, not baseOf, to find a node's domain base: baseOf drops the proxy and base path.
  • TARGET_REF_KEY → "externalTargetRef" (backend private _EXT_TARGET_REF_KEY)

Scope: W1–W5

Slice W1 (#955) adds the façade and makes uriToId in app.js delegate to it. The R2 frontend ratchet fell from 11 to 10.

Slice W2 (#955) moves the other 10 R2 sites in app.js (6 template literals, 1 concat, 3 /node/ splits) onto nodeUrl and nodeBaseOf, and adds nodeBaseOf. The R2 frontend ratchet fell from 10 to 0.

Slice W3 (#955) leaves the façade unchanged. In app.js, restoreFromHash now accepts any hash idOf recognises, so an IRI or a legacy bare uuid opens the node too. A new app.js function, resolveNodeBase, finds the node's domain base from its identity. The network lookup (GET /resolve) is in app.js, not in the façade (see Canonical Deep Link).

Slice W4 (#955) moves the plugins onto the façade and leaves the façade itself unchanged:

  • detail-panel.js reads a node's own id with idOf(node) and a relation row's related node with one helper, _relIdOf(row): externalUUID first (a cross-domain row's target is a /node-by-ref URL, which names no id), then idOf(row, "target"), then idOf(row, "origin") — so targetIri and originIri win over the legacy URL fields. The Documents reveal match uses it. The relation chips (hop graph, spider seeds, spider chips, related rows, cable test) use _chipIdOf(row) instead: the same identity-first read for in-model rows, but a cross-domain row (externalUUID or a /node-by-ref target) keeps its pre-W4 id, the uuid in its target/origin URL and '' for a /node-by-ref URL. Such a row is never fetched from the panel's domain, dragged as a node or pinned by uuid.
  • A node's domain comes from its identity first: the panel header and domainKey read the node's stored _eidos_domainBase, as an exact baseURI or the proxy form {origin}/api/proxy/{domain}. A node without that stamp falls back to the pre-W4 match of a URL-shaped @id against each domain's baseURI, and the primary domain comes last. The hop-chip and "What's inside" navigation ask resolveNodeBase (exported as EidosCore.resolveNodeBase) and fall back to the panel's domain. A failed lookup is logged as a warning and never leaves an unhandled rejection.
  • detail-engineering.js and export-electrical.js read ids through idOf (export-electrical.js via _relEndpointId(row, field)); the electrical export's ids are unchanged for rows without an IRI.
  • The relation command writes the target ref under TARGET_REF_KEY, so no plugin names the backend field any more. The R3 frontend ratchet fell from 7 to 0.
  • app.js's applyPropagation keys the __abs__ cross-domain node cache by the target's uuid (the URL only when the row names no id), and _bustAbsCache in detail-engineering.js clears __abs__{uuid} after an edit.

Slice W5 (#955) closes WP6 and leaves the façade unchanged. It proves the frontend is ready for WP5 S7, when @id, uri, target, origin and isPartOf become https://id.ontoteq.com/<uuid> IRIs:

  • One fixture suite (tests-frontend/wp6-w5-post-s7-identity.test.js) runs the tree, detail panel, share link and cross-domain flows on the post-S7 shape (acceptance A) and on today's responses (acceptance B). Both give the same outcome: node store and tree index keyed by bare uuid with no duplicates, each node fetched from its own domain, and share links in the identity IRI form {origin}{pathname}#https://id.ontoteq.com/<uuid> (unescaped). Since #1000/#1006 canonicalNodeShareUrl (app.js:5059) builds that from EidosIRI.toIri(uuid); the old {BASE}/node/<uuid> form still opens, but is no longer emitted.
  • The IRI is identity only, never fetched. apiFetchAbsolute fetches an IRI (or a legacy bare uuid) as /node/<uuid> from the node's resolved domain, and navigateToAbsoluteUri opens it by identity through navigateToUri. Before W5, a post-S7 incoming pill or signal row would have requested id.ontoteq.com.
  • The detail panel reads an unstamped node's @id host only when the @id is a node URL, so an IRI @id never reaches the host match's miss warning.
  • Playwright acceptance C (tests-e2e/specs/navigation/wp6-w5-post-s7-identity.spec.ts) rewrites live DEV responses into the post-S7 shape for the deep link, the share-link round trip and a primary→secondary hop, and asserts no request to id.ontoteq.com.

WP5 S7 (#954) made that shape real. The backend's node responses now carry @id, uri, isPartOf, hasPart[].@id, target, origin and inheritedFrom as IRIs, through the proxy too, and drop eidos:id. The S3 companions (iri, *Iri) stay. A cross-domain row addressed by ref keeps its /node-by-ref target, and a row without a known uuid keeps its target and gets no IRI. The façade did not change. tests/test_wp5_s7_cutover.py (case G) evaluates tests-frontend/fixtures/wp6-w5-identity-shapes.js with Node and checks it against real S7 output.

WP9 S2 (#1101) completes the companions. Every GET response object row that names a node by its bare id now also carries that node's IRI. Nothing else changes, and the stored keys stay bare uuids. The companions sit on object rows only: a plain list of ids (for example the /metrics/model finding id lists or include_ids' node_ids) and a map keyed by node id get no IRI, and archive and FME export documents keep their shape. The companion follows the id field's own spelling: id → iri, nodeId → nodeIri, node_id → node_iri, parent_id → parent_iri, subtree_root → subtree_root_iri, anchor_node_id → anchor_node_iri, and a bare field gets <field>Iri (parentIri on /nodes, rootIri on /tree). Raw relation rows (GET /relations, the IAM relation and export rows) get fromIri, toIri and, where the row has an external uuid, external_iri, all built by infra.target_ref.relation_node_iris. Newly covered: /nodes, /relations, /tombstones, both /tree shapes, /api/export, the /admin/iam/* tree, rule, preview and user-access rows, /documents/repos, /node/{id}/documents, /changelog, /activity/heatmap, /metrics/model (max_container, max_relations_node), /me/node-notes/all, /admin/rulebook/for-node, the IIS interface rows and the two report anchors. An IRI is never guessed. An external or unresolved target, or an unknown remote uuid, gets null, and the IAM restricted target's iri follows toIri, so a cross-domain placeholder id never turns into an IRI. Archive exports keep their shape: the secondary-domain fan-out in routes/domain_io.py drops the peers' new companions. tests/test_wp9_s2_iri_companions.py sweeps every GET route (A), pins the existing fields against a golden captured before S2 (B), and checks an IAM matrix so that no body names a node the pre-S2 body did not name (C).

WP9 S3 (#989) moves the frontend's outgoing node ids onto the IRI and adds toIriParam. Every node id that app.js or detail-panel.js sends in a query value is toIriParam(uuid): GET /resolve id, GET /metrics/batch and GET /nodes/meta ids (each id in the list), GET /nodes-referencing-external uuid, and node on /me/node-notes. The POST /notifications/node body's node_id is toIri(uuid). The backend already accepted the IRI on each of these (WP9 S1), so app traffic no longer logs bare_uuid_deprecated. Only the wire value changes: the node store, the tree index and the response lookups stay keyed by the bare uuid, which is how the backend answers. A note's own note_id is not a node id and is unchanged. The read side did not change: restoreFromHash decodes the hash before idOf reads it, so #<uuid>, #{BASE}/node/<uuid>, the GraphAnalyzer form #<encoded {BASE}/node/<uuid>> and the node IRI, escaped or not, all open the node (tests-frontend/hash-deeplink-routing.test.js). Since #1127 an occurrence IRI hash goes through the same idOf. /resolve names its product, and the app opens the product with the signal row revealed (see the Canonical Deep Link page). The façade itself did not change: an occurrence IRI has the node-IRI shape. The fetch ratchet (tests/test_frontend_fetch_ratchet.py, through scripts/babel-build/count_bare_id_query.js) fails on any ?id=, ?ids=, ?uuid= or ?node= value that is not toIriParam, and any node_id object field that is not toIri.

WP9 S4a (#989) moves the full-model export onto the IRI: FME 2.0.0 writes every node and relation identity value as its IRI, in every mode (see the Export Engine page). infra.target_ref.relation_target_iris writes a relation row's to and external_uuid as node IRIs whatever the target's kind, so a placeholder to is kept, as its IRI (the overlay stores it as an IRI inside eidos:target since #1102 S8); the kind stays readable from the external and unresolved fields. The audit sections keep the recorded bare uuids.

WP9 S5 (#1114) starts the cut-over of the bare ids S2 kept. Slice S5a changes no response: it makes toIri idempotent and freezes where a response still carries a bare node uuid as a value. tests/test_wp9_s5_bare_id_ratchet.py runs the S2 sweep over every GET route and the IAM matrix and fails on a bare uuid at a route and JSON path that its allowlist does not name (A). The allowlist (15 routes, 42 paths, among them /nodes, /relations, /tree, /children/{id}, /api/export and the /admin/iam/* trees and rules) may only shrink: adding an entry, or leaving an entry whose field is gone, fails (B). Each later slice that drops a route's bare ids deletes its entries.

Slice S5b (#1119) moves the readers, still with no response change: every frontend and backend-internal reader of an allowlisted field takes the node id from the IRI companion S2 put next to it, so S5c can drop the bare field. The frontend reads through idOf (EidosTree._ingest reads iri / rootIri, an external relation row's target is idOf(row.targetIri), never externalUUID); the backend reads a peer's /tree, /nodes, /relations, /tombstones and full-model provenance through infra.iri.companion_id / node_rows_by_id. tests/test_wp9_s5b_reader_companions.py holds the two checks: an AST taint scan (scripts/babel-build/count_bare_field_reads.js) follows each allowlisted response through the frontend and fails on a read of its bare field (A), and each backend reader gives the same result on a response with every bare field stripped (B). Four allowlisted values have no companion to move to (the /tree children map, the two /metrics/model orphan-finding lists and an external row's targetLabel fallback); S5c decides their shape.

Slice S5c (#1120) drops the bare ids from the responses. Each allowlisted route passes its body through infra.iri.without_bare_ids, which removes a bare node-id field (id, rootId, parent, parent_id, from, to, external_uuid, externalUUID, subtree_root, nodeId, anchor_node_id) wherever its IRI companion in the same row is set. A field whose companion is null keeps its value: a root's parent: null, and the placeholder to of an external target whose remote uuid is unknown, which never becomes an IRI. That rule matches what the primary's readers restore from a peer's companions, so a primary builds the same full-model archive off a cut secondary. _SystemContextResponse loses anchor_node_id, and an external target known only by uuid gets an empty targetLabel until its owning domain names it, never the uuid itself. The allowlist goes from 42 entries to 8, each kept on purpose with its reason next to it: the placeholder to (and the restricted target.id) on /relations and /api/export; the /tree children map, which is keyed by uuid and has no companion form; and the four /metrics/model values, because that response is also the FME archive's eidos:metrics snapshot and dropping them would change FME output. tests/test_wp9_s5c_bare_ids_dropped.py holds the acceptance tests.

Load order

<script src="eidos-modules.js">
<script src="eidos-iri.js">        <!-- defines window.EidosIRI; no dependency -->
<script src="plugins/…">           <!-- every plugin loads after it -->
…
<script src="app.js">              <!-- uriToId delegates to EidosIRI.idOf -->

tests/test_deployed_html_entry_scripts.py fails if a deployed page loads app.js without eidos-iri.js, or loads it after app.js or a plugin.