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}(backendto_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}(backendnode_url)baseOf(uri)→ the URI's origin, ornullif it is not an absolute URInodeBaseOf(uri)→ everything before the first/node/, path kept:{base}/node/{id}→{base}and{base}/api/proxy/{domain}/node/{id}→{base}/api/proxy/{domain};nullif there is no/node/segment. It is byte-identical to the olduri.split('/node/')[0]. Use it, notbaseOf, to find a node's domain base:baseOfdrops 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.jsreads a node's own id withidOf(node)and a relation row's related node with one helper,_relIdOf(row):externalUUIDfirst (a cross-domain row'stargetis a/node-by-refURL, which names no id), thenidOf(row, "target"), thenidOf(row, "origin")— sotargetIriandoriginIriwin 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 (externalUUIDor a/node-by-reftarget) keeps its pre-W4 id, the uuid in itstarget/originURL and''for a/node-by-refURL. 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
domainKeyread the node's stored_eidos_domainBase, as an exactbaseURIor the proxy form{origin}/api/proxy/{domain}. A node without that stamp falls back to the pre-W4 match of a URL-shaped@idagainst each domain'sbaseURI, and the primary domain comes last. The hop-chip and "What's inside" navigation askresolveNodeBase(exported asEidosCore.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.jsandexport-electrical.jsread ids throughidOf(export-electrical.jsvia_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'sapplyPropagationkeys the__abs__cross-domain node cache by the target's uuid (the URL only when the row names no id), and_bustAbsCacheindetail-engineering.jsclears__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/#1006canonicalNodeShareUrl(app.js:5059) builds that fromEidosIRI.toIri(uuid); the old{BASE}/node/<uuid>form still opens, but is no longer emitted. - The IRI is identity only, never fetched.
apiFetchAbsolutefetches an IRI (or a legacy bare uuid) as/node/<uuid>from the node's resolved domain, andnavigateToAbsoluteUriopens it by identity throughnavigateToUri. Before W5, a post-S7 incoming pill or signal row would have requestedid.ontoteq.com. - The detail panel reads an unstamped node's
@idhost only when the@idis a node URL, so an IRI@idnever 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 toid.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.