Domain Visibility¶
Verified against the code · 4996d1ceb770fb4545edebc5fd140792bfb23830 · 2026-10-20
956 D1b-4 (2026-10-20): 1 finding(s) fixed — the combined hidden state is read by _isHiddenDomainBase (app.js:3067), by the init() tree-fetch filter (:3514-3516, which computes the union itself) and by renderDomainVisibilityList (:15997); all are tree/tab/Admin surfaces. Re-verified against main 4996d1ce.¶
Overview¶
EIDOS Explorer supports two independent mechanisms for hiding a domain from the tree panel and tab bar. Both mechanisms affect only the tree/tab surface — they are UX controls, not security controls. Node access is always governed by IAM AccessRules regardless of visibility state.
The two layers can be combined: a domain hidden at the deployment level by an operator (Layer 1) is invisible to every user on that instance, while per-user hiding (Layer 2) lets individual users tune their own view independently.
Layer 1 — Deployment-Wide (Operator)¶
When to use: suppress an entire domain for all users on a given deployment (e.g. a team that does not use the Documents domain).
Where configured: config/domains.yaml (SSOT) or configs/<env>/deploy.json (per-deployment override).
Scope: everyone on that deployment.
How it works¶
Add hidden: true to the domain entry in config/domains.yaml:
# config/domains.yaml
- id: document
hidden: true # hides the tab for EVERYONE on this deployment
enabled: true
isPrimary: false
# ...
The field is a passive marker. The field is absent by default (the domain is visible), and setting it to true is the only way to hide at this layer. There is no third "forced-visible" state — the field is binary.
The flag flows through the generation pipeline verbatim:
config/domains.yaml (SSOT)
→ scripts/generate_domain_config.py (passes all fields verbatim)
→ frontend/domains.json + backend/domains.json
→ DOMAIN_REGISTRY.domains[key].hidden === true (read at SPA startup)
After regenerating and redeploying, frontend/app.js reads d.hidden from the registry at three guard points:
| Guard | Where | Effect |
|---|---|---|
| Tree-fetch filter | tree-loading initialisation in app.js |
Hidden domain skipped in the remote-domain list; no /tree call is made |
| Add-Information domain list | relation target picker in app.js |
Hidden domain absent from the cross-domain relation target dropdown |
_isHiddenDomainBase(base) |
app.js |
Returns true for a hidden domain's baseURI; used to gate tree/tab render only |
Constraints¶
- The primary domain (
isPrimary: true) cannot carryhidden: true. The primary domain is the orientation anchor — it runs the auth layer, the proxy layer, and serves the SPA. Hiding it would break the application. See the Visible Not Accessible invariant for the broader rule, and GCF Contract 15 for the orientation invariant. - Deployment-level hidden domains do not appear in the per-user Admin → Domain visibility list — they are invisible to per-user toggling (
renderDomainVisibilityListskips any domain whosed.hiddenis truthy).
Layer 2 — Per-User (Any User)¶
When to use: individual users who want to declutter their own tree view without affecting other users on the same deployment.
Where configured: Admin → Domain visibility (in the Data Exchange tab), per browser.
Scope: this browser's localStorage only; does not affect any other user.
How it works¶
The Admin → Domain visibility panel (#domain-visibility-list) renders one checkbox per non-primary, non-deployment-hidden domain. The implementation is in renderDomainVisibilityList() in frontend/app.js:
for (const [key, d] of Object.entries(DOMAIN_REGISTRY.domains || {})) {
if (d.isPrimary) continue; // primary domain never offered for hiding
if (d.hidden) continue; // deployment-hidden domains not shown here
// render checkbox …
cb.onchange = () => {
setDomainHidden(key, !cb.checked);
reloadTreesFromRegistry();
};
}
State is persisted in localStorage.eidos_tree_prefs under the hiddenDomains key — an array of domain key strings:
{ "hiddenDomains": ["document", "signal"] }
Two core functions in app.js operate on this store:
getHiddenDomainKeys() -> string[]— returns the hidden set for the current browser.setDomainHidden(key, hidden)— writes the hidden state forkeyand persists.
The per-user hidden set is unioned with the deployment-level d.hidden flag at tree-fetch time and inside _isHiddenDomainBase:
// Union check — either layer causes the domain to be hidden
(d.hidden || userHidden.has(key))
Constraints¶
- The primary domain is never offered as a checkbox (
if (d.isPrimary) continue;) — a user cannot hide the primary. - Deployment-hidden domains are also skipped — a user cannot "un-hide" a Layer-1-hidden domain for themselves. Layer 1 overrides Layer 2.
- Hiding is per-browser, not per-account. A user who logs in from a different browser sees their own default (nothing hidden) until they re-apply their preferences.
The Primary-Cannot-Be-Hidden Rule¶
The primary domain (the one with isPrimary: true in the registry) can never be hidden by either layer:
- Layer 1:
hidden: trueon the primary domain is a configuration error. The primary domain runs the authentication layer, the proxy layer, and serves the SPA — hiding it would break the application. - Layer 2:
renderDomainVisibilityList()explicitly skips the primary domain. No checkbox is rendered for it.
This rule is enforced by GCF Contract 15 (orientation invariant, R26-R33) and is a direct consequence of the primary being the orientation anchor of the frontend.
Visible ≠ Accessible — The Core Invariant¶
A hidden domain is not a restricted domain. Hiding is a UX convenience; access is controlled by IAM AccessRules only.
When a domain is hidden by either layer:
| Surface | Hidden? | Unchanged? |
|---|---|---|
| Domain tab in tree panel | ✅ hidden | |
| Tree node list for that domain | ✅ hidden | |
Routing (/api/proxy/<domain>/…) |
✅ fully routable | |
| Cross-domain relation resolution | ✅ links still resolve | |
| Global search results | ✅ still indexed and returned | |
Direct /node/{id} access |
✅ accessible if IAM allows | |
| Backend proxy forwarding | ✅ unchanged | |
| IAM AccessRule evaluation | ✅ unchanged |
Three places in frontend/app.js read the combined hidden state (Layer 1 ∪ Layer 2): _isHiddenDomainBase(base) (:3067), the tree-fetch filter in init() (:3514-3516), which computes the union itself (!d.hidden && !_userHidden.has(key)) rather than calling the guard, and renderDomainVisibilityList() (:15997), which skips d.hidden and reads the per-user set for checkbox state. All three are tree/tab or Admin-panel surfaces. _isHiddenDomainBase's own doc-comment reads:
Use this ONLY to keep user-facing tree/tab surfaces from rendering them, never in routing/proxy/prefix lookups (domain-routing.js stays hidden-blind).
Routing lookups (frontend/plugins/domain-routing.js), proxy forwarding (backend/server.py), and the backend IAM path (access_rules.db) never consult the hidden flag or the per-user hidden set. A node in a hidden domain is still reachable via a direct link, a cross-domain relation, or a global search result — the user just cannot see the domain's tree tab.
See the Visible Not Accessible invariant for the formal guarantee.
Summary — The Two Layers at a Glance¶
| Layer 1 | Layer 2 | |
|---|---|---|
| Who sets it | Operator | Individual user |
| Where | config/domains.yaml or configs/<env>/deploy.json |
Admin → Domain visibility |
| Scope | Everyone on this deployment | This browser only |
| Storage | frontend/domains.json (via generate_domain_config.py) |
localStorage.eidos_tree_prefs.hiddenDomains |
| Effect | Domain absent from tree/tab for all users | Domain absent from tree/tab for this user |
| Affects IAM / access | No | No |
| Primary domain | Cannot carry hidden: true |
Not offered as a checkbox |
| Interaction | Overrides Layer 2 (hidden domains are not shown as per-user checkboxes) | Only applies to Layer-1-visible domains |
Both layers hide the same thing — a domain's tree tab and node tree — through the same union check (d.hidden || userHidden.has(key)), applied by _isHiddenDomainBase and by the init() tree-fetch filter. Both are UX controls. Neither one is, and neither one should ever be used as, a security boundary.