| """routes_nav.py β X2's `GET /api/v1/nav`: the registry, filtered by what this session may open. |
| |
| SERVER-FILTERED, not client-filtered. The client renders what it is given and never decides who |
| may see what β a nav that hides a link the API would still serve is a UI courtesy, not a |
| permission. `core.perms.nav_pages` is the single predicate (shared with `may_open`'s page gate), |
| so the nav and the 403 can never disagree about a grant. |
| |
| The full rule set β archived is invisible to everyone, `group_only` rows are excluded so no |
| `parent` reference can dangle, `nav: False` surfaces ship as `chrome: 'utility'`, and the |
| `prefs.json` Library preference is deliberately NOT applied β is documented on |
| `core.perms.nav_pages`, which is where it belongs: one place, both callers. |
| """ |
| import time |
|
|
| from fastapi import APIRouter, Body, Depends |
|
|
| from deps import Session, err, perms, require_session |
|
|
| router = APIRouter(prefix="/api/v1") |
|
|
| |
| |
| |
| _NAV_PREFS_KEY = "nav_prefs" |
| _MAX_NAV_FOLDERS = 16 |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _NAV_META_KEY = "nav_meta" |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _ICON_SHAPES = frozenset({"folder", "star", "flag", "tag", "bookmark", "grid", |
| "chart", "map", "users", "clock", "heart", "bolt"}) |
| _ICON_TONES = frozenset({"neutral", "blue", "green", "yellow", "red"}) |
| _MAX_NAV_NAME = 60 |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _NAV_RECENTS_KEY = "nav_recents" |
| _MAX_RECENTS = 50 |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| def _now() -> int: |
| return int(time.time()) |
|
|
|
|
| def _clean_nav_prefs(raw, page_keys, *, keep_unknown_ut=False): |
| """Validated wholesale replacement, the `clean_folders` posture: prune, never invent. |
| |
| `page_keys` is the set of TOP-LEVEL keys this session may see; a placement of an unknown |
| or invisible key is dropped (it can return when the grant does β placement is cosmetic, |
| so pruning is loss-free). Unknown folder refs drop the placement, not the folder. |
| |
| `keep_unknown_ut` is the STORE-BLIP escape hatch β see `_placeable_top_keys`. When the |
| `ut_*` listing could not be read, a `ut_` key absent from `page_keys` is KEPT rather than |
| pruned: keeping a placement whose database may since have been deleted is cosmetically |
| harmless, while pruning a live one is the silent data loss this function just stopped |
| causing. Never widened to non-`ut_` keys β those come from the compiled registry, which |
| cannot fail to enumerate. |
| """ |
| raw = raw if isinstance(raw, dict) else {} |
| folders, seen = [], set() |
| for f in (raw.get("folders") or [])[:_MAX_NAV_FOLDERS]: |
| if not isinstance(f, dict): |
| continue |
| fid = str(f.get("id") or "").strip()[:40] |
| name = " ".join(str(f.get("name") or "").split())[:40] |
| if not fid or fid in seen or not name: |
| continue |
| seen.add(fid) |
| folders.append({"id": fid, "name": name}) |
| ids = {f["id"] for f in folders} |
| placement = {} |
| src = raw.get("placement") |
| if isinstance(src, dict): |
| for k, v in src.items(): |
| k, v = str(k)[:60], str(v)[:40] |
| if v not in ids: |
| continue |
| if page_keys is None or k in page_keys: |
| placement[k] = v |
| elif keep_unknown_ut and k.startswith("ut_"): |
| placement[k] = v |
| return {"folders": folders, "placement": placement} |
|
|
|
|
| def _placeable_top_keys(session): |
| """`(keys, enumerated)` β the keys a placement may name, INCLUDING this tenant's databases. |
| |
| β THIS IS THE ITEM-6 FIX (wave 27, contract C1), and the bug it closes was invisible by |
| construction. The old version read `perms.nav_pages` alone β the compiled REGISTRY β and |
| `perms.py` contains no `ut_` or `user_tables` reference at all, because a tenant's |
| user-created databases are merged into the nav payload by `nav()` BELOW the permission wall. |
| So every `ut_*` key was absent from this set, and `_clean_nav_prefs` pruned every placement |
| naming one β on READ and on WRITE. |
| |
| The symptom was "a database I drag into a folder falls out of it later", never "it does not |
| work": the write answered **200 OK** having stored `{}`, the client kept its optimistic copy |
| (`Shell.commitPrefs` reverts only on `!ok`), and the folder held for the rest of the session. |
| The next reload served the pruned copy and the database was back at root. Built-in modules |
| (`customer_data`, `product_data`) ARE in the registry and persisted fine, which is exactly |
| why it read as intermittent rather than as a missing feature. |
| |
| β THE SAME MISTAKE, ALREADY MADE AND ALREADY FIXED ONE FUNCTION AWAY. `nav()`'s recents |
| block (see its `allowed` comment) hit this in wave 23 and solved it by pruning against the |
| ASSEMBLED page list. This is that lesson applied to the second consumer, which the wave-23 |
| fix did not reach. |
| |
| `enumerated` is False when the `ut_` listing raised β the caller must then NOT treat this set |
| as authoritative for `ut_` keys (`keep_unknown_ut`). Pruning a person's whole arrangement |
| because the store blinked would be the original defect wearing a different cause. |
| """ |
| pages = perms.nav_pages(session.user) or [] |
| keys = {p["key"] for p in pages if not p.get("parent")} |
| try: |
| import core.user_tables as user_tables |
| |
| |
| |
| |
| |
| |
| |
| |
| for e in user_tables.nav_entries(viewer=session.uname, is_admin=session.admin, |
| st=session.runtime): |
| keys.add(str(e.get("key") or "")) |
| except Exception: |
| return keys, False |
| return keys, True |
|
|
|
|
| def _clean_icon(raw): |
| """A stored/incoming icon, or None. Prune, never invent β `clean_folders`' posture.""" |
| if not isinstance(raw, dict): |
| return None |
| shape, tone = raw.get("shape"), raw.get("tone") |
| if shape not in _ICON_SHAPES or tone not in _ICON_TONES: |
| return None |
| return {"shape": shape, "tone": tone} |
|
|
|
|
| def _read_nav_meta(runtime): |
| """The tenant's `nav_meta`, validated on the way OUT as well as in. |
| |
| Re-validating a read looks redundant and is not: the bucket outlives this code, an older |
| build may have written a shape this one no longer accepts, and the whitelist is mirrored |
| from a vocabulary that lives in another language. A row whose icon does not survive |
| validation renders the default mark β never a crash, and never a half-drawn glyph. |
| """ |
| try: |
| stored = runtime.get(_NAV_META_KEY) or {} |
| except Exception: |
| return {} |
| if not isinstance(stored, dict): |
| return {} |
| out = {} |
| for key, meta in stored.items(): |
| if not isinstance(meta, dict): |
| continue |
| entry = {} |
| icon = _clean_icon(meta.get("icon")) |
| if icon: |
| entry["icon"] = icon |
| |
| |
| |
| |
| |
| name = meta.get("name") |
| if str(key).startswith("ut_") and isinstance(name, str) and name.strip(): |
| entry["name"] = " ".join(name.split())[:_MAX_NAV_NAME] |
| if entry: |
| out[str(key)] = entry |
| return out |
|
|
|
|
| def _read_recents(runtime, uname, allowed): |
| """This user's recents, newest first, PRUNED to what they may currently see. |
| |
| β PRUNED ON READ, never on write, and both halves of that are deliberate: |
| |
| Β· the WRITE happens on every route open β it is the one hot path this file has β so it |
| must not build the whole nav to validate one key; |
| Β· a key whose grant was REVOKED must stop being offered without anyone running a |
| migration, and must come back if the grant does. That is `_clean_nav_prefs`' own |
| prune-never-invent posture, applied to a second bucket for the same reason. |
| |
| A key that is not in `allowed` therefore reaches the store and never reaches a screen β |
| which also means a stuffed key cannot be used to discover what exists: it comes back only |
| if the session could already see it. |
| """ |
| try: |
| stored = (runtime.get(_NAV_RECENTS_KEY) or {}).get(uname) or {} |
| except Exception: |
| return [] |
| if not isinstance(stored, dict): |
| return [] |
| out = [] |
| for key, at in stored.items(): |
| key = str(key) |
| if allowed is not None and key not in allowed: |
| continue |
| try: |
| at = int(at) |
| except (TypeError, ValueError): |
| continue |
| if at <= 0: |
| continue |
| out.append({"key": key, "at": at}) |
| out.sort(key=lambda r: r["at"], reverse=True) |
| return out[:_MAX_RECENTS] |
|
|
|
|
| @router.get("/nav") |
| def nav(session: Session = Depends(require_session)): |
| """`{pages: [{key,label,source?,chrome}], landing}`. |
| |
| Note what this does NOT do: it never returns an empty `pages` list as a way of saying "you |
| are not allowed". A session that may open nothing at all is a misconfigured account, and it |
| gets an explicit 403 β an empty 200 is indistinguishable from "the registry is empty" and is |
| how a permission bug hides in plain sight (X2's never-an-empty-200 rule). |
| """ |
| pages = list(perms.nav_pages(session.user) or []) |
| |
| |
| |
| |
| tcfg = getattr(session.runtime.tenant, "config", None) or {} |
| tmods = tcfg.get("modules", "all") |
| |
| |
| |
| |
| |
| |
| |
| |
| _omitted = [] |
| if tmods != "all": |
| allowed = {str(k) for k in (tmods or [])} |
| _omitted = sorted({str(p.get("key")) for p in pages |
| if p.get("key") not in allowed |
| and (p.get("parent") or "") not in allowed}) |
| pages = [p for p in pages |
| if p.get("key") in allowed or (p.get("parent") or "") in allowed] |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _ut_defs, _ut_locked_mode, _degraded = {}, "", [] |
| try: |
| import core.user_tables as user_tables |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _ut_defs = user_tables.all_defs(st=session.runtime) or {} |
| |
| |
| |
| _ut_locked_mode = str(user_tables.AUTOMATION_RECORD_MODE) |
| _lent = user_tables.lend(session.runtime, **{user_tables.STORE_KEY: _ut_defs}) |
| for e in user_tables.nav_entries(viewer=session.uname, is_admin=session.admin, |
| st=_lent): |
| pages.append({"key": e["key"], "label": e["label"], |
| "source": e.get("source") or "Blank", "chrome": "main"}) |
| except Exception: |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _degraded.append("databases") |
| pass |
| if not pages: |
| if tmods != "all": |
| |
| |
| |
| |
| return {"pages": [], "landing": None, "empty": "no_databases"} |
| raise err(403, "no_surfaces", |
| "your account has no dashboards assigned β ask an administrator") |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| meta = _read_nav_meta(session.runtime) |
| |
| |
| |
| |
| |
| |
| for p in pages: |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| key = str(p.get("key", "")) |
| if key.startswith("ut_"): |
| _creator = str((_ut_defs.get(key) or {}).get("createdBy") or "") |
| _mine = bool(session.admin or (_creator and _creator == session.uname)) |
| p["manage"] = _mine |
| p["canDelete"] = _mine |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if (_ut_locked_mode |
| and (_ut_defs.get(key) or {}).get("recordMode") == _ut_locked_mode): |
| p["locked"] = True |
| elif session.admin: |
| p["manage"] = True |
| entry = meta.get(p.get("key")) if meta else None |
| if not entry: |
| continue |
| if entry.get("icon"): |
| p["icon"] = entry["icon"] |
| if entry.get("name"): |
| p["label"] = entry["name"] |
| landing = perms.landing_page(session.user) |
| if landing and not any(p.get("key") == landing for p in pages): |
| landing = pages[0].get("key") |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| recents = _read_recents(session.runtime, session.uname, |
| {str(p.get("key", "")) for p in pages}) |
| |
| |
| |
| |
| |
| |
| return {"pages": pages, "landing": landing, "recents": recents, |
| "omitted": _omitted, "degraded": _degraded} |
|
|
|
|
| @router.post("/nav/opened") |
| def nav_opened(body: dict = Body(default=None), |
| session: Session = Depends(require_session)): |
| """WAVE 23 (C10) β stamp a page as JUST OPENED. Fire-and-forget from the client. |
| |
| The client calls this on every route commit, so this is the only write in this file on a |
| hot path, and three things follow from that: |
| |
| Β· `flush='async'` β the coalescing mode ([[store-async-flush]]). A blocking upload per |
| page open against an HF-Dataset-backed store would put a network round trip inside every |
| navigation. `nav_prefs`/`nav_meta` stay `sync` because a folder rename is not a hot path; |
| this is. |
| Β· NO VALIDATION OF THE KEY against the nav. Building the page list to check one string |
| would make the stamp cost more than the navigation that triggered it β and it would buy |
| nothing, because the READ prunes to what the session may currently see. An unknown or |
| revoked key is stored and never served back. |
| Β· THE MAP IS CAPPED HERE TOO. Read-side capping alone would let a hostile or buggy client |
| grow one user's document without bound; `_MAX_RECENTS` entries survive, oldest first to |
| go, which is the same rule the read applies. |
| """ |
| body = body if isinstance(body, dict) else {} |
| key = str(body.get("key") or "").strip()[:60] |
| if not key: |
| raise err(400, "bad_request", "no page was named") |
| if not session.runtime.available(): |
| raise err(503, "store_unavailable", |
| "the tenant store is unavailable β nothing was recorded") |
| stamp, uname = _now(), session.uname |
|
|
| def _up(data): |
| data = data if isinstance(data, dict) else {} |
| mine = dict(data.get(uname) or {}) if isinstance(data.get(uname), dict) else {} |
| mine[key] = stamp |
| if len(mine) > _MAX_RECENTS: |
| |
| |
| |
| def _at(item): |
| try: |
| return int(item[1]) |
| except (TypeError, ValueError): |
| return 0 |
| mine = dict(sorted(mine.items(), key=_at, reverse=True)[:_MAX_RECENTS]) |
| data[uname] = mine |
| return data |
|
|
| try: |
| session.runtime.update(_NAV_RECENTS_KEY, _up, flush='async') |
| except Exception: |
| raise err(503, "store_unavailable", |
| "the tenant store refused the write β nothing was recorded") |
| return {"key": key, "at": stamp} |
|
|
|
|
| @router.post("/nav/meta") |
| def save_nav_meta(body: dict = Body(default=None), |
| session: Session = Depends(require_session)): |
| """WAVE 19 (R8 / C1) β set one database's icon and/or name, tenant-wide. |
| |
| A PATCH OF ONE KEY, not the wholesale replace `/nav/prefs` uses two routes up, and the |
| asymmetry is deliberate. Prefs are one user's complete picture of their own rail, so |
| replacing the document whole is what makes a deleted folder stay deleted. This bucket is |
| shared by every admin in the tenant: a wholesale write here means whoever saves last |
| silently erases what the other one named while their tab was open. |
| |
| THREE WALLS, all fail-closed: |
| Β· the SESSION must be able to open the key at all (the same predicate the nav uses, so |
| the rail and this route cannot disagree about what exists); |
| Β· the WRITE is admin-only β renaming a database is a change every user in the tenant |
| sees, which is the definition of an administrative act here; |
| Β· `name` is refused outright for a non-`ut_` key. Built-in labels are compiled registry |
| literals: honouring an override would leave this payload and `core/registry.py` |
| calling the same module two different things, and the wave-16 lesson is that the |
| client must not be the place that decides what a payload meant. |
| |
| `icon: null` CLEARS. An absent field is untouched β which is what makes a rename and an |
| icon change two independent writes rather than a race between them. |
| """ |
| body = body if isinstance(body, dict) else {} |
| key = str(body.get("key") or "").strip() |
| if not key: |
| raise err(400, "bad_request", "no database was named") |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if key.startswith("ut_"): |
| import core.user_tables as user_tables |
| if not user_tables.get(key, st=session.runtime) or not user_tables.may_open( |
| key, session.uname, session.admin, st=session.runtime): |
| raise err(403, "forbidden", "that database belongs to another user") |
| else: |
| if not session.admin: |
| raise err(403, "forbidden", |
| "only an administrator can change a built-in database's icon") |
| session.require(key) |
|
|
| patch = {} |
| if "icon" in body: |
| icon = _clean_icon(body.get("icon")) |
| if body.get("icon") is not None and icon is None: |
| |
| |
| |
| raise err(400, "bad_icon", "that icon is not one of the available shapes and tones") |
| patch["icon"] = icon |
| if "name" in body: |
| if not key.startswith("ut_"): |
| raise err(400, "name_not_allowed", |
| "only a database you created can be renamed β this one's name comes " |
| "from the module registry") |
| name = " ".join(str(body.get("name") or "").split())[:_MAX_NAV_NAME] |
| if not name: |
| raise err(400, "bad_request", "a database needs a name") |
| patch["name"] = name |
| if not patch: |
| raise err(400, "bad_request", "nothing to change") |
|
|
| if not session.runtime.available(): |
| raise err(503, "store_unavailable", |
| "the tenant store is unavailable β nothing was saved") |
|
|
| def _up(data): |
| data = data if isinstance(data, dict) else {} |
| entry = dict(data.get(key) or {}) if isinstance(data.get(key), dict) else {} |
| for field, value in patch.items(): |
| if value is None: |
| entry.pop(field, None) |
| else: |
| entry[field] = value |
| |
| |
| |
| if entry: |
| data[key] = entry |
| else: |
| data.pop(key, None) |
| return data |
|
|
| try: |
| session.runtime.update(_NAV_META_KEY, _up) |
| except Exception: |
| raise err(503, "store_unavailable", |
| "the change was not saved β the store refused the write") |
| return {"key": key, "meta": _read_nav_meta(session.runtime).get(key, {})} |
|
|
|
|
| @router.get("/nav/prefs") |
| def nav_prefs(session: Session = Depends(require_session)): |
| """This user's database-list folders, re-validated at serve time against what they may |
| currently see (a revoked page's placement vanishes with the page, and returns with it).""" |
| try: |
| stored = (session.runtime.get(_NAV_PREFS_KEY) or {}).get(session.uname) or {} |
| except Exception: |
| stored = {} |
| keys, enumerated = _placeable_top_keys(session) |
| return {"prefs": _clean_nav_prefs(stored, keys, keep_unknown_ut=not enumerated)} |
|
|
|
|
| @router.post("/nav/prefs") |
| def save_nav_prefs(body: dict = Body(default=None), |
| session: Session = Depends(require_session)): |
| """Wholesale replace, like the table folder stratum β the client sent its complete |
| picture, validated here; a partial merge would resurrect deleted folders forever.""" |
| keys, enumerated = _placeable_top_keys(session) |
| clean = _clean_nav_prefs(body or {}, keys, keep_unknown_ut=not enumerated) |
| if not session.runtime.available(): |
| raise err(503, "store_unavailable", |
| "the tenant store is unavailable β nothing was saved") |
|
|
| def _up(data): |
| data = data if isinstance(data, dict) else {} |
| if clean["folders"] or clean["placement"]: |
| data[session.uname] = clean |
| else: |
| data.pop(session.uname, None) |
| return data |
|
|
| try: |
| session.runtime.update(_NAV_PREFS_KEY, _up) |
| except Exception: |
| raise err(503, "store_unavailable", |
| "the folder change was not saved β the store refused the write") |
| return {"prefs": clean} |
|
|
|
|
| @router.get("/nav/schema/{key}") |
| def nav_schema(key: str, session: Session = Depends(require_session)): |
| """The database's schema drawer payload: its field contract + the semantic measures this |
| session may build with. Fail-closed on the SAME predicate as the nav β a key the session |
| may not open answers 403, never a redacted schema.""" |
| |
| |
| |
| if key.startswith("ut_"): |
| import core.user_tables as user_tables |
| defn = user_tables.get(key, st=session.runtime) |
| if not defn or not user_tables.may_open(key, session.uname, session.admin, |
| st=session.runtime): |
| raise err(403, "forbidden", "that database belongs to another user") |
| |
| |
| |
| return {"key": key, |
| "label": (_read_nav_meta(session.runtime).get(key, {}).get("name") |
| or defn.get("label") or key), |
| "source": defn.get("source") or "Blank", |
| "fields": [{"key": f["key"], "label": f["label"], "type": f["type"], |
| "source": f.get("source") or "overlay", |
| "description": str(f.get("description") or "")} |
| for f in (defn.get("fields") or [])], |
| "measures": []} |
| session.require(key) |
| pages = perms.nav_pages(session.user) or [] |
| page = next((p for p in pages if p.get("key") == key), None) |
| if page is None: |
| raise err(404, "unknown_page", f"{key!r} is not a database this session can see") |
| fields = [] |
| if key in ("customer_data", "cohort", "customers"): |
| try: |
| import aios_grid |
| for f in aios_grid.FIELDS: |
| entry = {"key": f["key"], "label": f["label"], "type": f["type"], |
| "source": f["source"], |
| "description": str(f.get("description") or "")} |
| if f.get("options"): |
| entry["options"] = list(f["options"]) |
| fields.append(entry) |
| except Exception: |
| fields = [] |
| measures = [] |
| try: |
| from core import measure_resolve |
| team_id = perms.scope_team_id(session.user) |
| for m in measure_resolve.offer(team_id) or []: |
| measures.append({"key": str(m.get("key") or ""), |
| "label": str(m.get("label") or m.get("key") or ""), |
| "type": str(m.get("type") or "")}) |
| except Exception: |
| measures = [] |
| out = {"key": key, "label": page.get("label") or key, |
| "source": page.get("source") or "", "fields": fields, "measures": measures} |
| if not fields: |
| |
| out["note"] = "This database has not published a field contract yet." |
| return out |
|
|